feat: step to the previous or next file in the files preview

This commit is contained in:
Linus Rath
2026-10-31 05:24:31 +01:00
parent ecefd1cbcc
commit e3fc67e771
5 changed files with 137 additions and 10 deletions
@@ -57,6 +57,8 @@ interface Props {
onDownload?: () => void; onDownload?: () => void;
/** Render in place (a split view's side pane) instead of a full-screen modal. */ /** Render in place (a split view's side pane) instead of a full-screen modal. */
docked?: boolean; docked?: boolean;
/** Extra header buttons, after the title (Files: previous / next file). */
headerActions?: React.ReactNode;
} }
interface ViewerWebViewProps { interface ViewerWebViewProps {
@@ -148,7 +150,7 @@ function PdfPagesView({ fileUri, viewerUri, background, onFailed }: {
* place that opens a file. * place that opens a file.
*/ */
export function AttachmentPreviewModal({ export function AttachmentPreviewModal({
item, loading, title, onClose, onOpenExternal, onShare, onDownload, docked = false, item, loading, title, onClose, onOpenExternal, onShare, onDownload, docked = false, headerActions,
}: Props) { }: Props) {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -393,6 +395,7 @@ export function AttachmentPreviewModal({
<X size={22} color={c.text} /> <X size={22} color={c.text} />
</Pressable> </Pressable>
<Text style={styles.title} numberOfLines={1}>{item?.name ?? title ?? ''}</Text> <Text style={styles.title} numberOfLines={1}>{item?.name ?? title ?? ''}</Text>
{headerActions}
{item && !showsFallback ? ( {item && !showsFallback ? (
<Pressable <Pressable
onPress={onOpenExternal} onPress={onOpenExternal}
+47 -2
View File
@@ -1,7 +1,11 @@
import React from 'react'; import React from 'react';
import { Pressable, StyleSheet } from 'react-native';
import type { FileNode } from '../../api/types'; import type { FileNode } from '../../api/types';
import { AttachmentPreviewModal } from '../email/AttachmentPreviewModal'; import { AttachmentPreviewModal } from '../email/AttachmentPreviewModal';
import { previewKindOf, useFilePreview } from '../email/use-file-preview'; import { previewKindOf, useFilePreview } from '../email/use-file-preview';
import { ChevronLeft, ChevronRight } from '../icons';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
export type PreviewableFileNode = Pick<FileNode, 'name' | 'type' | 'size' | 'blobId' | 'accountId'>; export type PreviewableFileNode = Pick<FileNode, 'name' | 'type' | 'size' | 'blobId' | 'accountId'>;
@@ -16,6 +20,13 @@ interface Props {
onClose: () => void; onClose: () => void;
/** Show it in a side pane instead of a full-screen modal. */ /** Show it in a side pane instead of a full-screen modal. */
docked?: boolean; 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" * FileNode, downloaded with the same authenticated download as "Download"
* (token refresh, client certificate). * (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 preview = useFilePreview({ onClose });
const { open, reset } = preview; const { open, reset } = preview;
@@ -39,5 +52,37 @@ export function FilePreviewModal({ file, onClose, docked = false }: Props) {
return reset; return reset;
}, [blobId, accountId, name, type, size, open, reset]); }, [blobId, accountId, name, type, size, open, reset]);
return <AttachmentPreviewModal {...preview.modalProps} docked={docked} />; const headerActions = onPrevious || onNext ? (
<>
<Pressable
onPress={onPrevious}
disabled={!onPrevious}
style={styles.button}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('files.previous_file', 'Previous file')}
accessibilityState={{ disabled: !onPrevious }}
>
<ChevronLeft size={22} color={onPrevious ? c.text : c.textMuted} />
</Pressable>
<Pressable
onPress={onNext}
disabled={!onNext}
style={styles.button}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('files.next_file', 'Next file')}
accessibilityState={{ disabled: !onNext }}
>
<ChevronRight size={22} color={onNext ? c.text : c.textMuted} />
</Pressable>
</>
) : undefined;
return <AttachmentPreviewModal {...preview.modalProps} docked={docked} headerActions={headerActions} />;
} }
const styles = StyleSheet.create({
// The preview header's own button size.
button: { width: 40, height: 40, alignItems: 'center', justifyContent: 'center' },
});
@@ -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(/<Text style=\{styles\.title\}[^\n]*<\/Text>\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);
});
});
+14
View File
@@ -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<T extends { id: string }>(
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] };
}
+36 -7
View File
@@ -84,6 +84,7 @@ import { useLocaleStore, type TranslateFn } from '../stores/locale-store';
import Dialog from '../components/Dialog'; import Dialog from '../components/Dialog';
import ShareSheet from '../components/files/ShareSheet'; import ShareSheet from '../components/files/ShareSheet';
import { FilePreviewModal, canPreviewInApp } from '../components/files/FilePreviewModal'; import { FilePreviewModal, canPreviewInApp } from '../components/files/FilePreviewModal';
import { previewNeighbours } from '../lib/files-preview-neighbours';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
// A file row carries the display name alongside the rest of the node. // A file row carries the display name alongside the rest of the node.
@@ -464,12 +465,17 @@ export default function FilesScreen() {
} }
}, [t]); }, [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), // Tap on a file: the in-app preview for the types it shows (lib/file-preview),
// the other app for the rest. // the other app for the rest.
const openFile = useCallback((row: FileRow) => { const openFile = useCallback((row: FileRow) => {
if (canPreviewInApp(row)) setPreviewTarget(row); if (canPreviewInApp(row)) openPreview(row);
else void openFileWith(row); else void openFileWith(row);
}, [openFileWith]); }, [openFileWith, openPreview]);
const downloadFile = useCallback(async (row: FileRow) => { const downloadFile = useCallback(async (row: FileRow) => {
if (!row.blobId) return; 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 // 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. // it is opened. Everything else is in the row's actions.
if (row.trash) { if (row.trash) {
if (!isFolder(row) && !isSymlink(row) && canPreviewInApp(row)) setPreviewTarget(row); if (!isFolder(row) && !isSymlink(row) && canPreviewInApp(row)) openPreview(row);
else setActionsTarget(row); else setActionsTarget(row);
return; return;
} }
@@ -566,7 +572,7 @@ export default function FilesScreen() {
if (isSymlink(row)) return; if (isSymlink(row)) return;
openFile(row); openFile(row);
}, },
[selectionMode, toggleSelect, openFile], [selectionMode, toggleSelect, openFile, openPreview],
); );
const handleRowLongPress = useCallback((row: FileRow) => { 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. // On a wide window an open file shows beside the list instead of covering it.
const previewDocked = isSplit && previewTarget != null; const previewDocked = isSplit && previewTarget != null;
return ( return (
@@ -1306,7 +1322,13 @@ export default function FilesScreen() {
</View> </View>
{previewDocked && ( {previewDocked && (
<View style={styles.previewPane}> <View style={styles.previewPane}>
<FilePreviewModal docked file={previewTarget} onClose={() => setPreviewTarget(null)} /> <FilePreviewModal
docked
file={previewTarget}
onClose={() => setPreviewTarget(null)}
onPrevious={showPrevious}
onNext={showNext}
/>
</View> </View>
)} )}
@@ -1348,7 +1370,7 @@ export default function FilesScreen() {
onClose={() => setActionsTarget(null)} onClose={() => setActionsTarget(null)}
onPreview={(r) => { onPreview={(r) => {
setActionsTarget(null); setActionsTarget(null);
setPreviewTarget(r); openPreview(r);
}} }}
onOpenWith={(r) => { onOpenWith={(r) => {
setActionsTarget(null); setActionsTarget(null);
@@ -1404,7 +1426,14 @@ export default function FilesScreen() {
onChangeDir={(d) => setSetting('filesDefaultSortDir', d)} onChangeDir={(d) => setSetting('filesDefaultSortDir', d)}
/> />
{!isSplit && <FilePreviewModal file={previewTarget} onClose={() => setPreviewTarget(null)} />} {!isSplit && (
<FilePreviewModal
file={previewTarget}
onClose={() => setPreviewTarget(null)}
onPrevious={showPrevious}
onNext={showNext}
/>
)}
<ShareSheet <ShareSheet
node={shareTarget} node={shareTarget}