refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -17,36 +17,111 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
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 FC, type MouseEvent, useCallback, useEffect, useMemo, useRef} from 'react';
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
import * as FavoriteMemeActionCreators from '~/actions/FavoriteMemeActionCreators';
import * as MediaViewerActionCreators from '~/actions/MediaViewerActionCreators';
import * as MessageActionCreators from '~/actions/MessageActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {deriveDefaultNameFromMessage} from '~/components/channel/embeds/EmbedUtils';
import {AudioPlayer} from '~/components/media-player/components/AudioPlayer';
import {VideoPlayer} from '~/components/media-player/components/VideoPlayer';
import {AddFavoriteMemeModal} from '~/components/modals/AddFavoriteMemeModal';
import {MediaModal} from '~/components/modals/MediaModal';
import styles from '~/components/modals/MediaViewerModal.module.css';
import {MediaContextMenu} from '~/components/uikit/ContextMenu/MediaContextMenu';
import {Platform} from '~/lib/Platform';
import FavoriteMemeStore from '~/stores/FavoriteMemeStore';
import MediaViewerStore from '~/stores/MediaViewerStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import {formatAttachmentDate} from '~/utils/AttachmentExpiryUtils';
import {createSaveHandler} from '~/utils/FileDownloadUtils';
import {buildMediaProxyURL} from '~/utils/MediaProxyUtils';
import {openExternalUrl} from '~/utils/NativeUtils';
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<MobileMediaOptionsSheetProps> = 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: <TrashIcon size={20} />,
onClick: () => {
onDelete();
onClose();
},
danger: true,
},
],
},
],
[mediaMenuData.groups, onClose, onDelete, t],
);
return (
<MenuBottomSheet isOpen={isOpen} onClose={onClose} groups={mediaMenuGroupsWithDelete} title={t`Media Options`} />
);
});
const MediaViewerModalComponent: FC = observer(() => {
const {t, i18n} = useLingui();
const {isOpen, items, currentIndex, channelId, messageId, message} = MediaViewerStore;
const {enabled: isMobile} = MobileLayoutStore;
const videoRef = useRef<HTMLVideoElement>(null);
const [isMediaMenuOpen, setIsMediaMenuOpen] = useState(false);
const currentItem = items[currentIndex];
@@ -62,6 +137,66 @@ const MediaViewerModalComponent: FC = observer(() => {
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)
@@ -95,11 +230,15 @@ const MediaViewerModalComponent: FC = observer(() => {
)),
);
}
}, [channelId, messageId, currentItem, defaultName, isFavorited, memes]);
}, [channelId, messageId, currentItem, defaultName, i18n, isFavorited, memes]);
const handleSave = useCallback(() => {
if (!currentItem) return;
const mediaType = currentItem.type === 'audio' ? 'audio' : currentItem.type === 'video' ? 'video' : 'image';
const mediaType = (() => {
if (currentItem.type === 'audio') return 'audio';
if (currentItem.type === 'video') return 'video';
return 'image';
})();
createSaveHandler(currentItem.originalSrc, mediaType)();
}, [currentItem]);
@@ -123,15 +262,11 @@ const MediaViewerModalComponent: FC = observer(() => {
);
const handlePrevious = useCallback(() => {
if (currentIndex > 0) {
MediaViewerActionCreators.navigateMediaViewer(currentIndex - 1);
}
}, [currentIndex]);
MediaViewerActionCreators.navigateMediaViewer((currentIndex - 1 + items.length) % items.length);
}, [currentIndex, items.length]);
const handleNext = useCallback(() => {
if (currentIndex < items.length - 1) {
MediaViewerActionCreators.navigateMediaViewer(currentIndex + 1);
}
MediaViewerActionCreators.navigateMediaViewer((currentIndex + 1) % items.length);
}, [currentIndex, items.length]);
const handleThumbnailSelect = useCallback(
@@ -180,6 +315,28 @@ const MediaViewerModalComponent: FC = observer(() => {
[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}) => (
<MediaContextMenu
message={message}
originalSrc={currentItem.originalSrc}
proxyURL={currentItem.src}
type={currentItem.type}
contentHash={currentItem.contentHash}
attachmentId={currentItem.attachmentId}
embedIndex={currentItem.embedIndex}
defaultName={defaultName}
onClose={onClose}
onDelete={handleDelete}
/>
));
}
}, [currentItem, defaultName, handleDelete, message, isMobile]);
const isBlob = currentItem?.src.startsWith('blob:');
const imageSrc = useMemo(() => {
@@ -187,15 +344,18 @@ const MediaViewerModalComponent: FC = observer(() => {
if (isBlob) {
return currentItem.src;
}
const baseProxyURL = getBaseProxyURL(currentItem.src);
if (currentItem.type === 'gif') {
return buildMediaProxyURL(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 currentItem.src;
return baseProxyURL;
}
const maxPreviewSize = 1920;
@@ -214,10 +374,11 @@ const MediaViewerModalComponent: FC = observer(() => {
}
}
return buildMediaProxyURL(currentItem.src, {
return buildMediaProxyURL(baseProxyURL, {
format: 'webp',
width: targetWidth,
height: targetHeight,
animated: currentItem.animated,
});
}, [currentItem, isBlob]);
@@ -225,12 +386,14 @@ const MediaViewerModalComponent: FC = observer(() => {
() =>
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.src.startsWith('blob:')) {
if ((item.type === 'image' || item.type === 'gif' || item.animated) && !item.src.startsWith('blob:')) {
const baseProxyURL = getBaseProxyURL(item.src);
return {
src: buildMediaProxyURL(item.src, {
src: buildMediaProxyURL(baseProxyURL, {
format: 'webp',
width: 320,
height: 320,
animated: Boolean(item.animated),
}),
alt: name,
type: item.type,
@@ -243,7 +406,7 @@ const MediaViewerModalComponent: FC = observer(() => {
type: item.type,
};
}),
[items],
[items, t],
);
if (!isOpen || !currentItem) {
@@ -265,20 +428,19 @@ const MediaViewerModalComponent: FC = observer(() => {
: undefined;
const getTitle = () => {
switch (currentItem.type) {
case 'image':
return t`Image preview`;
case 'gif':
return t`GIF preview`;
case 'gifv':
return t`GIF preview`;
case 'video':
return t`Video preview`;
case 'audio':
return t`Audio preview`;
default:
return t`Media preview`;
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();
@@ -323,16 +485,32 @@ const MediaViewerModalComponent: FC = observer(() => {
}
if (currentItem.type === 'video') {
const hasNaturalVideoDimensions = currentItem.naturalWidth > 0 && currentItem.naturalHeight > 0;
const videoAspectRatio = hasNaturalVideoDimensions
? `${currentItem.naturalWidth} / ${currentItem.naturalHeight}`
: '16 / 9';
return (
<div className={styles.videoPlayerContainer}>
<div
className={styles.videoPlayerContainer}
style={
{
'--video-natural-width': hasNaturalVideoDimensions ? `${currentItem.naturalWidth}px` : '960px',
'--video-aspect-ratio': hasNaturalVideoDimensions
? currentItem.naturalWidth / currentItem.naturalHeight
: 16 / 9,
aspectRatio: videoAspectRatio,
} as CSSProperties
}
>
<VideoPlayer
src={currentItem.src}
width={currentItem.naturalWidth}
height={currentItem.naturalHeight}
duration={currentItem.duration}
autoPlay
fillContainer
isMobile={isMobile}
fillContainer={isMobile}
className={styles.videoPlayer}
/>
</div>
@@ -356,10 +534,16 @@ const MediaViewerModalComponent: FC = observer(() => {
);
}
const imageAlt = (() => {
if (currentItem.type === 'gif') return t`Animated GIF`;
if (currentItem.animated) return t`Animated image`;
return t`Image`;
})();
return (
<img
src={imageSrc}
alt={currentItem.type === 'gif' ? t`Animated GIF` : t`Image`}
alt={imageAlt}
className={styles.image}
style={{
objectFit: 'contain',
@@ -369,52 +553,66 @@ const MediaViewerModalComponent: FC = observer(() => {
);
};
const canFavoriteCurrentItem =
Boolean(channelId) &&
Boolean(messageId) &&
(currentItem.type === 'image' ||
currentItem.type === 'gif' ||
currentItem.type === 'gifv' ||
currentItem.type === 'video');
return (
<MediaModal
title={modalTitle}
fileName={fileName}
expiryInfo={
expiryInfo
? {
expiresAt: expiryInfo.expiresAt,
isExpired: expiryInfo.isExpired,
}
: undefined
}
dimensions={dimensions}
isFavorited={
channelId &&
messageId &&
(currentItem.type === 'image' || currentItem.type === 'gif' || currentItem.type === 'gifv')
? isFavorited
: undefined
}
onFavorite={
channelId &&
messageId &&
(currentItem.type === 'image' || currentItem.type === 'gif' || currentItem.type === 'gifv')
? handleFavoriteClick
: undefined
}
onSave={currentItem.type !== 'gifv' ? handleSave : undefined}
onOpenInBrowser={handleOpenInBrowser}
enablePanZoom={currentItem.type === 'image' || currentItem.type === 'gif' || currentItem.type === 'gifv'}
currentIndex={currentIndex}
totalAttachments={items.length}
onPrevious={currentIndex > 0 ? handlePrevious : undefined}
onNext={currentIndex < items.length - 1 ? handleNext : undefined}
thumbnails={thumbnails}
onSelectThumbnail={handleThumbnailSelect}
>
<div
className={styles.mediaContextMenuWrapper}
onContextMenu={handleContextMenu}
role="region"
aria-label={modalTitle}
<>
<MediaModal
title={modalTitle}
fileName={fileName}
expiryInfo={
expiryInfo
? {
expiresAt: expiryInfo.expiresAt,
isExpired: expiryInfo.isExpired,
}
: undefined
}
dimensions={dimensions}
isFavorited={canFavoriteCurrentItem ? isFavorited : undefined}
onFavorite={canFavoriteCurrentItem ? handleFavoriteClick : undefined}
onSave={currentItem.type !== 'gifv' ? handleSave : undefined}
onOpenInBrowser={handleOpenInBrowser}
enablePanZoom={currentItem.type === 'image' || currentItem.type === 'gif' || currentItem.type === 'gifv'}
currentIndex={currentIndex}
totalAttachments={items.length}
onPrevious={items.length > 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()}
</div>
</MediaModal>
<div
className={styles.mediaContextMenuWrapper}
onContextMenu={handleContextMenu}
role="region"
aria-label={modalTitle}
>
{renderMedia()}
</div>
</MediaModal>
{isMobile && message && (
<MobileMediaOptionsSheet
currentItem={currentItem}
defaultName={defaultName}
isOpen={isMediaMenuOpen}
message={message}
onClose={() => setIsMediaMenuOpen(false)}
onDelete={handleDelete}
/>
)}
</>
);
});