mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
refactor(files): share the media lightbox logic between desktop and phone
useMediaLightbox holds the signed image address of the file on screen, paging by button, arrow keys and swipe, and Escape to close. The desktop lightbox keeps its own index and thumbnail strip, the phone lightbox stays controlled by the files tab; both draw their own overlay over the hook.
This commit is contained in:
@@ -6,6 +6,7 @@ import type { TripFile } from '../../types'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import { openFile as openFileUrl } from '../../utils/fileDownload'
|
||||
import { triggerDownload, isVideo } from './FileManager.helpers'
|
||||
import { useMediaLightbox } from './useMediaLightbox'
|
||||
import VideoPlayer from '../Journey/VideoPlayerLazy'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
|
||||
@@ -22,41 +23,15 @@ interface ImageLightboxProps {
|
||||
export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) {
|
||||
const { t } = useTranslation()
|
||||
const [index, setIndex] = useState(initialIndex)
|
||||
const [imgSrc, setImgSrc] = useState('')
|
||||
const [touchStart, setTouchStart] = useState<number | null>(null)
|
||||
const file = files[index]
|
||||
|
||||
const fileIsVideo = isVideo(file?.mime_type)
|
||||
|
||||
useEffect(() => {
|
||||
setImgSrc('')
|
||||
// Images use a one-shot signed URL; a video must use the plain same-origin
|
||||
// URL (cookie auth) so its many Range requests all authenticate (#823).
|
||||
if (!file || isVideo(file.mime_type)) return
|
||||
// Arrowing through the gallery leaves several mints in flight; only the one for
|
||||
// the file still on screen may paint.
|
||||
let current = true
|
||||
getAuthUrl(file.url, 'download').then(u => { if (current) setImgSrc(u) }).catch(() => {})
|
||||
return () => { current = false }
|
||||
}, [file?.url, file?.mime_type])
|
||||
|
||||
const goPrev = () => setIndex(i => Math.max(0, i - 1))
|
||||
const goNext = () => setIndex(i => Math.min(files.length - 1, i + 1))
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
if (e.key === 'ArrowLeft') goPrev()
|
||||
if (e.key === 'ArrowRight') goNext()
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [onClose])
|
||||
const { file, imgSrc, fileIsVideo, hasPrev, hasNext, goPrev, goNext, onTouchStart, onTouchEnd } = useMediaLightbox({
|
||||
files,
|
||||
index,
|
||||
onIndexChange: setIndex,
|
||||
onClose,
|
||||
})
|
||||
|
||||
if (!file) return null
|
||||
|
||||
const hasPrev = index > 0
|
||||
const hasNext = index < files.length - 1
|
||||
const navBtn = (side: 'left' | 'right', onClick: () => void, show: boolean): React.ReactNode => show ? (
|
||||
<button type="button" onClick={e => { e.stopPropagation(); onClick() }}
|
||||
style={{
|
||||
@@ -82,14 +57,8 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
|
||||
role="presentation"
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)', zIndex: 2000, display: 'flex', flexDirection: 'column', paddingBottom: 'var(--bottom-nav-h)' }}
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
||||
onTouchStart={e => setTouchStart(e.touches[0].clientX)}
|
||||
onTouchEnd={e => {
|
||||
if (touchStart === null) return
|
||||
const diff = e.changedTouches[0].clientX - touchStart
|
||||
if (diff > 60) goPrev()
|
||||
else if (diff < -60) goNext()
|
||||
setTouchStart(null)
|
||||
}}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }}>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// FE-FILES-LIGHTBOX-001 to FE-FILES-LIGHTBOX-008: the media viewer logic behind the
|
||||
// desktop file manager's lightbox and the phone's files tab lightbox.
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { TouchEvent } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { buildTripFile } from '../../../tests/helpers/factories';
|
||||
import { useMediaLightbox, type MediaLightboxOptions } from './useMediaLightbox';
|
||||
|
||||
const getAuthUrl = vi.fn(async (url: string, _purpose: string) => `${url}?token=t`);
|
||||
vi.mock('../../api/authUrl', () => ({
|
||||
getAuthUrl: (url: string, purpose: string) => getAuthUrl(url, purpose),
|
||||
}));
|
||||
|
||||
const IMG = buildTripFile({ id: 1, mime_type: 'image/png', url: '/files/1' });
|
||||
const VID = buildTripFile({ id: 2, mime_type: 'video/mp4', url: '/files/2' });
|
||||
const IMG2 = buildTripFile({ id: 3, mime_type: 'image/jpeg', url: '/files/3' });
|
||||
const FILES = [IMG, VID, IMG2];
|
||||
|
||||
function setup(index: number, files = FILES) {
|
||||
const onIndexChange = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const hook = renderHook(
|
||||
(props: Partial<MediaLightboxOptions>) => useMediaLightbox({ files, index, onIndexChange, onClose, ...props }),
|
||||
{ initialProps: {} }
|
||||
);
|
||||
return { ...hook, onIndexChange, onClose };
|
||||
}
|
||||
|
||||
function touch(x: number) {
|
||||
return { touches: [{ clientX: x }], changedTouches: [{ clientX: x }] } as unknown as TouchEvent;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getAuthUrl.mockReset();
|
||||
getAuthUrl.mockImplementation(async (url: string) => `${url}?token=t`);
|
||||
});
|
||||
|
||||
describe('useMediaLightbox', () => {
|
||||
it('FE-FILES-LIGHTBOX-001: an image gets its signed address', async () => {
|
||||
const { result } = setup(0);
|
||||
expect(result.current.file).toBe(IMG);
|
||||
expect(result.current.fileIsVideo).toBe(false);
|
||||
await waitFor(() => expect(result.current.imgSrc).toBe('/files/1?token=t'));
|
||||
expect(getAuthUrl).toHaveBeenCalledWith('/files/1', 'download');
|
||||
});
|
||||
|
||||
it('FE-FILES-LIGHTBOX-002: a video mints no token and keeps an empty image address', () => {
|
||||
const { result } = setup(1);
|
||||
expect(result.current.fileIsVideo).toBe(true);
|
||||
expect(result.current.imgSrc).toBe('');
|
||||
expect(getAuthUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-FILES-LIGHTBOX-003: a token minted for a file paged away from never paints', async () => {
|
||||
let resolveFirst: (v: string) => void = () => {};
|
||||
getAuthUrl.mockReturnValueOnce(new Promise((r) => (resolveFirst = r))).mockResolvedValueOnce('/files/3?token=b');
|
||||
const { result, rerender } = setup(0);
|
||||
rerender({ index: 2 });
|
||||
await waitFor(() => expect(result.current.imgSrc).toBe('/files/3?token=b'));
|
||||
await act(async () => {
|
||||
resolveFirst('/files/1?token=a');
|
||||
});
|
||||
expect(result.current.imgSrc).toBe('/files/3?token=b');
|
||||
});
|
||||
|
||||
it('FE-FILES-LIGHTBOX-004: paging stops at both ends', () => {
|
||||
const first = setup(0);
|
||||
expect(first.result.current.hasPrev).toBe(false);
|
||||
expect(first.result.current.hasNext).toBe(true);
|
||||
first.result.current.goPrev();
|
||||
expect(first.onIndexChange).not.toHaveBeenCalled();
|
||||
first.result.current.goNext();
|
||||
expect(first.onIndexChange).toHaveBeenCalledWith(1);
|
||||
|
||||
const last = setup(2);
|
||||
expect(last.result.current.hasNext).toBe(false);
|
||||
last.result.current.goNext();
|
||||
expect(last.onIndexChange).not.toHaveBeenCalled();
|
||||
last.result.current.goPrev();
|
||||
expect(last.onIndexChange).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('FE-FILES-LIGHTBOX-005: arrow keys page and Escape closes', () => {
|
||||
const { onIndexChange, onClose } = setup(1);
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }));
|
||||
expect(onIndexChange).toHaveBeenLastCalledWith(0);
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }));
|
||||
expect(onIndexChange).toHaveBeenLastCalledWith(2);
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-FILES-LIGHTBOX-006: arrow keys do nothing past either end, and stop after unmount', () => {
|
||||
const { onIndexChange, unmount } = setup(0, [IMG]);
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }));
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }));
|
||||
expect(onIndexChange).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
const { onClose } = setup(0);
|
||||
// Only the mounted instance's Escape closes.
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-FILES-LIGHTBOX-007: a long swipe pages, a short one does not', () => {
|
||||
const { result, onIndexChange } = setup(1);
|
||||
result.current.onTouchStart(touch(200));
|
||||
result.current.onTouchEnd(touch(150));
|
||||
expect(onIndexChange).not.toHaveBeenCalled();
|
||||
|
||||
result.current.onTouchStart(touch(200));
|
||||
result.current.onTouchEnd(touch(100));
|
||||
expect(onIndexChange).toHaveBeenLastCalledWith(2);
|
||||
|
||||
result.current.onTouchStart(touch(100));
|
||||
result.current.onTouchEnd(touch(200));
|
||||
expect(onIndexChange).toHaveBeenLastCalledWith(0);
|
||||
});
|
||||
|
||||
it('FE-FILES-LIGHTBOX-008: a touch end without a start does nothing', () => {
|
||||
const { result, onIndexChange } = setup(1);
|
||||
result.current.onTouchEnd(touch(0));
|
||||
expect(onIndexChange).not.toHaveBeenCalled();
|
||||
result.current.onTouchStart(touch(300));
|
||||
result.current.onTouchEnd(touch(100));
|
||||
// The start is used up by the first end.
|
||||
result.current.onTouchEnd(touch(0));
|
||||
expect(onIndexChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useRef, useState, type TouchEvent } from 'react';
|
||||
|
||||
import { getAuthUrl } from '../../api/authUrl';
|
||||
import type { TripFile } from '../../types';
|
||||
import { isVideo } from './FileManager.helpers';
|
||||
|
||||
/** How far a horizontal swipe has to travel before it pages. */
|
||||
const SWIPE_PX = 60;
|
||||
|
||||
export interface MediaLightboxOptions {
|
||||
files: TripFile[];
|
||||
index: number;
|
||||
onIndexChange: (index: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The media viewer behind the desktop file manager's lightbox and the phone's files tab
|
||||
* lightbox, which draw their own overlay over it: the signed address of the picture on
|
||||
* screen, paging by button, arrow keys and swipe, and Escape to close.
|
||||
*/
|
||||
export function useMediaLightbox({ files, index, onIndexChange, onClose }: MediaLightboxOptions) {
|
||||
const file = files[index] as TripFile | undefined;
|
||||
const [imgSrc, setImgSrc] = useState('');
|
||||
const touchStartRef = useRef<number | null>(null);
|
||||
const fileIsVideo = isVideo(file?.mime_type);
|
||||
const fileUrl = file?.url;
|
||||
const fileMimeType = file?.mime_type;
|
||||
|
||||
useEffect(() => {
|
||||
// Images use a one-shot signed URL; a video keeps the plain same-origin URL (cookie
|
||||
// auth) so its many Range requests all authenticate (#823). Paging is faster than the
|
||||
// token round trip, so only the mint for the file still on screen may paint.
|
||||
let cancelled = false;
|
||||
setImgSrc('');
|
||||
if (fileUrl && !isVideo(fileMimeType)) {
|
||||
void getAuthUrl(fileUrl, 'download').then((url) => {
|
||||
if (!cancelled) setImgSrc(url);
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fileUrl, fileMimeType]);
|
||||
|
||||
const hasPrev = index > 0;
|
||||
const hasNext = index < files.length - 1;
|
||||
const goPrev = () => {
|
||||
if (hasPrev) onIndexChange(index - 1);
|
||||
};
|
||||
const goNext = () => {
|
||||
if (hasNext) onIndexChange(index + 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'ArrowLeft' && index > 0) onIndexChange(index - 1);
|
||||
if (e.key === 'ArrowRight' && index < files.length - 1) onIndexChange(index + 1);
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [index, files.length, onClose, onIndexChange]);
|
||||
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
touchStartRef.current = e.touches[0].clientX;
|
||||
};
|
||||
const onTouchEnd = (e: TouchEvent) => {
|
||||
const start = touchStartRef.current;
|
||||
if (start === null) return;
|
||||
const diff = e.changedTouches[0].clientX - start;
|
||||
if (diff > SWIPE_PX) goPrev();
|
||||
else if (diff < -SWIPE_PX) goNext();
|
||||
touchStartRef.current = null;
|
||||
};
|
||||
|
||||
return { file, imgSrc, fileIsVideo, hasPrev, hasNext, goPrev, goNext, onTouchStart, onTouchEnd };
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ChevronLeft, ChevronRight, Download, ExternalLink, X } from 'lucide-react'
|
||||
import { getAuthUrl } from '../../../../api/authUrl'
|
||||
import { downloadFile, openFile } from '../../../../utils/fileDownload'
|
||||
import { lockBodyScroll } from '../../../../utils/bodyScrollLock'
|
||||
import { isVideo } from '../../../../components/Files/FileManager.helpers'
|
||||
import { useMediaLightbox } from '../../../../components/Files/useMediaLightbox'
|
||||
import VideoPlayer from '../../../../components/Journey/VideoPlayerLazy'
|
||||
import type { TranslationFn, TripFile } from '../../../../types'
|
||||
|
||||
@@ -29,39 +28,12 @@ function sheetRoot(): HTMLElement {
|
||||
* its Range requests stay cookie-authenticated (#823).
|
||||
*/
|
||||
export default function MFileLightbox({ files, index, onIndexChange, onClose, t }: MFileLightboxProps) {
|
||||
const file = files[index]
|
||||
const [imgSrc, setImgSrc] = useState('')
|
||||
const touchStartRef = useRef<number | null>(null)
|
||||
const fileIsVideo = isVideo(file?.mime_type)
|
||||
const fileUrl = file?.url
|
||||
const fileMimeType = file?.mime_type
|
||||
|
||||
useEffect(() => {
|
||||
// Swiping is faster than the resource-token round trip, so a stale token must
|
||||
// not overwrite the file the user is looking at now.
|
||||
let cancelled = false
|
||||
setImgSrc('')
|
||||
if (fileUrl && !isVideo(fileMimeType)) {
|
||||
void getAuthUrl(fileUrl, 'download').then(url => { if (!cancelled) setImgSrc(url) })
|
||||
}
|
||||
return () => { cancelled = true }
|
||||
}, [fileUrl, fileMimeType])
|
||||
|
||||
const hasPrev = index > 0
|
||||
const hasNext = index < files.length - 1
|
||||
const goPrev = () => { if (hasPrev) onIndexChange(index - 1) }
|
||||
const goNext = () => { if (hasNext) onIndexChange(index + 1) }
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
if (e.key === 'ArrowLeft') goPrev()
|
||||
if (e.key === 'ArrowRight') goNext()
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [index, files.length])
|
||||
const { file, imgSrc, fileIsVideo, hasPrev, hasNext, goPrev, goNext, onTouchStart, onTouchEnd } = useMediaLightbox({
|
||||
files,
|
||||
index,
|
||||
onIndexChange,
|
||||
onClose,
|
||||
})
|
||||
|
||||
useEffect(() => lockBodyScroll(), [])
|
||||
|
||||
@@ -71,19 +43,12 @@ export default function MFileLightbox({ files, index, onIndexChange, onClose, t
|
||||
<div
|
||||
// Backdrop, header bar and media pane are plain boxes: they only route the
|
||||
// tap-to-dismiss. Escape / arrow keys and the header buttons are the
|
||||
// keyboard equivalents, wired up in the effect above.
|
||||
// keyboard equivalents, wired up in useMediaLightbox.
|
||||
role="presentation"
|
||||
className="m-root fixed inset-0 z-[65] flex flex-col bg-black/[.92]"
|
||||
onClick={onClose}
|
||||
onTouchStart={e => { touchStartRef.current = e.touches[0].clientX }}
|
||||
onTouchEnd={e => {
|
||||
const start = touchStartRef.current
|
||||
if (start === null) return
|
||||
const diff = e.changedTouches[0].clientX - start
|
||||
if (diff > 60) goPrev()
|
||||
else if (diff < -60) goNext()
|
||||
touchStartRef.current = null
|
||||
}}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
{/* Header */}
|
||||
<div role="presentation" className="flex flex-none items-center justify-between px-4 py-[10px]" onClick={e => e.stopPropagation()}>
|
||||
|
||||
Reference in New Issue
Block a user