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}
+ />
+ )}