diff --git a/src/components/email/AttachmentPreviewModal.tsx b/src/components/email/AttachmentPreviewModal.tsx index 9ca523d7..0ce3c36b 100644 --- a/src/components/email/AttachmentPreviewModal.tsx +++ b/src/components/email/AttachmentPreviewModal.tsx @@ -57,6 +57,8 @@ interface Props { onDownload?: () => void; /** Render in place (a split view's side pane) instead of a full-screen modal. */ docked?: boolean; + /** Extra header buttons, after the title (Files: previous / next file). */ + headerActions?: React.ReactNode; } interface ViewerWebViewProps { @@ -148,7 +150,7 @@ function PdfPagesView({ fileUri, viewerUri, background, onFailed }: { * place that opens a file. */ export function AttachmentPreviewModal({ - item, loading, title, onClose, onOpenExternal, onShare, onDownload, docked = false, + item, loading, title, onClose, onOpenExternal, onShare, onDownload, docked = false, headerActions, }: Props) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); @@ -393,6 +395,7 @@ export function AttachmentPreviewModal({ {item?.name ?? title ?? ''} + {headerActions} {item && !showsFallback ? ( ; @@ -16,6 +20,13 @@ interface Props { onClose: () => void; /** Show it in a side pane instead of a full-screen modal. */ docked?: boolean; + /** + * Step to the folder's previous / next previewable file; the buttons show + * when either is given and the missing one is disabled (webmail + * previewNeighbours). + */ + onPrevious?: () => void; + onNext?: () => void; } /** @@ -23,7 +34,9 @@ interface Props { * FileNode, downloaded with the same authenticated download as "Download" * (token refresh, client certificate). */ -export function FilePreviewModal({ file, onClose, docked = false }: Props) { +export function FilePreviewModal({ file, onClose, docked = false, onPrevious, onNext }: Props) { + const c = useColors(); + const t = useLocaleStore((s) => s.t); const preview = useFilePreview({ onClose }); const { open, reset } = preview; @@ -39,5 +52,37 @@ export function FilePreviewModal({ file, onClose, docked = false }: Props) { return reset; }, [blobId, accountId, name, type, size, open, reset]); - return ; + const headerActions = onPrevious || onNext ? ( + <> + + + + + + + + ) : undefined; + + return ; } + +const styles = StyleSheet.create({ + // The preview header's own button size. + button: { width: 40, height: 40, alignItems: 'center', justifyContent: 'center' }, +}); diff --git a/src/lib/__tests__/files-preview-neighbours.test.ts b/src/lib/__tests__/files-preview-neighbours.test.ts new file mode 100644 index 00000000..1b84c9c6 --- /dev/null +++ b/src/lib/__tests__/files-preview-neighbours.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { previewNeighbours } from '../files-preview-neighbours'; + +// The Files preview had no way to the folder's next file (file 10 "next/ +// previous in preview"); the webmail's ←/→ step through the folder's other +// previewable files. + +const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, rel), 'utf8'); + +describe('previous / next in the Files preview', () => { + const files = [{ id: 'a' }, { id: 'b' }, { id: 'c' }]; + + it('finds the neighbours of the open file', () => { + expect(previewNeighbours(files, { id: 'b' })).toEqual({ previous: { id: 'a' }, next: { id: 'c' } }); + expect(previewNeighbours(files, { id: 'a' })).toEqual({ previous: undefined, next: { id: 'b' } }); + expect(previewNeighbours(files, { id: 'c' })).toEqual({ previous: { id: 'b' }, next: undefined }); + expect(previewNeighbours(files, { id: 'gone' })).toEqual({}); + expect(previewNeighbours(files, null)).toEqual({}); + }); + + // No RN renderer in the test env: check the wiring instead. + it('puts the buttons in the preview header, fed from the listed files it can show', () => { + const modal = read('../../components/email/AttachmentPreviewModal.tsx'); + expect(modal).toMatch(/\s*\{headerActions\}/); + const preview = read('../../components/files/FilePreviewModal.tsx'); + expect(preview).toMatch(/accessibilityLabel=\{t\('files\.previous_file', 'Previous file'\)\}/); + expect(preview).toMatch(/accessibilityLabel=\{t\('files\.next_file', 'Next file'\)\}/); + expect(preview).toMatch(/headerActions=\{headerActions\}/); + const screen = read('../../screens/FilesScreen.tsx'); + expect(screen).toMatch(/visibleFiles\.filter\(\(r\) => !isFolder\(r\) && !isSymlink\(r\) && canPreviewInApp\(r\)\)/); + expect(screen.match(/onPrevious=\{showPrevious\}/g)).toHaveLength(2); + expect(screen.match(/onNext=\{showNext\}/g)).toHaveLength(2); + }); +}); diff --git a/src/lib/files-preview-neighbours.ts b/src/lib/files-preview-neighbours.ts new file mode 100644 index 00000000..5f409efe --- /dev/null +++ b/src/lib/files-preview-neighbours.ts @@ -0,0 +1,14 @@ +/** + * The previous and next of `current` among `files`, by id, for the Files + * preview's previous / next buttons (webmail previewNeighbours); undefined + * where there is none, and both when `current` isn't among them. + */ +export function previewNeighbours( + files: T[], + current: { id: string } | null, +): { previous?: T; next?: T } { + if (!current) return {}; + const i = files.findIndex((f) => f.id === current.id); + if (i === -1) return {}; + return { previous: files[i - 1], next: files[i + 1] }; +} diff --git a/src/screens/FilesScreen.tsx b/src/screens/FilesScreen.tsx index 1be3045b..a8d806ba 100644 --- a/src/screens/FilesScreen.tsx +++ b/src/screens/FilesScreen.tsx @@ -84,6 +84,7 @@ import { useLocaleStore, type TranslateFn } from '../stores/locale-store'; import Dialog from '../components/Dialog'; import ShareSheet from '../components/files/ShareSheet'; import { FilePreviewModal, canPreviewInApp } from '../components/files/FilePreviewModal'; +import { previewNeighbours } from '../lib/files-preview-neighbours'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; // A file row carries the display name alongside the rest of the node. @@ -464,12 +465,17 @@ export default function FilesScreen() { } }, [t]); + // Every way into the preview: a tap, the row's actions, previous / next. + const openPreview = useCallback((row: FileRow) => { + setPreviewTarget(row); + }, []); + // Tap on a file: the in-app preview for the types it shows (lib/file-preview), // the other app for the rest. const openFile = useCallback((row: FileRow) => { - if (canPreviewInApp(row)) setPreviewTarget(row); + if (canPreviewInApp(row)) openPreview(row); else void openFileWith(row); - }, [openFileWith]); + }, [openFileWith, openPreview]); const downloadFile = useCallback(async (row: FileRow) => { if (!row.blobId) return; @@ -552,7 +558,7 @@ export default function FilesScreen() { // In the trash a file can be looked at; a folder is restored before // it is opened. Everything else is in the row's actions. if (row.trash) { - if (!isFolder(row) && !isSymlink(row) && canPreviewInApp(row)) setPreviewTarget(row); + if (!isFolder(row) && !isSymlink(row) && canPreviewInApp(row)) openPreview(row); else setActionsTarget(row); return; } @@ -566,7 +572,7 @@ export default function FilesScreen() { if (isSymlink(row)) return; openFile(row); }, - [selectionMode, toggleSelect, openFile], + [selectionMode, toggleSelect, openFile, openPreview], ); const handleRowLongPress = useCallback((row: FileRow) => { @@ -1296,6 +1302,16 @@ export default function FilesScreen() { ); } + // The preview steps through the folder's other files it can show, in the + // order of the list (webmail previewNeighbours). + const previewable = useMemo( + () => visibleFiles.filter((r) => !isFolder(r) && !isSymlink(r) && canPreviewInApp(r)), + [visibleFiles], + ); + const { previous: previousFile, next: nextFile } = previewNeighbours(previewable, previewTarget); + const showPrevious = previousFile ? () => openPreview(previousFile) : undefined; + const showNext = nextFile ? () => openPreview(nextFile) : undefined; + // On a wide window an open file shows beside the list instead of covering it. const previewDocked = isSplit && previewTarget != null; return ( @@ -1306,7 +1322,13 @@ export default function FilesScreen() { {previewDocked && ( - setPreviewTarget(null)} /> + setPreviewTarget(null)} + onPrevious={showPrevious} + onNext={showNext} + /> )} @@ -1348,7 +1370,7 @@ export default function FilesScreen() { onClose={() => setActionsTarget(null)} onPreview={(r) => { setActionsTarget(null); - setPreviewTarget(r); + openPreview(r); }} onOpenWith={(r) => { setActionsTarget(null); @@ -1404,7 +1426,14 @@ export default function FilesScreen() { onChangeDir={(d) => setSetting('filesDefaultSortDir', d)} /> - {!isSplit && setPreviewTarget(null)} />} + {!isSplit && ( + setPreviewTarget(null)} + onPrevious={showPrevious} + onNext={showNext} + /> + )}