mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
feat: step to the previous or next file in the files preview
This commit is contained in:
@@ -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({
|
||||
<X size={22} color={c.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.title} numberOfLines={1}>{item?.name ?? title ?? ''}</Text>
|
||||
{headerActions}
|
||||
{item && !showsFallback ? (
|
||||
<Pressable
|
||||
onPress={onOpenExternal}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import React from 'react';
|
||||
import { Pressable, StyleSheet } from 'react-native';
|
||||
import type { FileNode } from '../../api/types';
|
||||
import { AttachmentPreviewModal } from '../email/AttachmentPreviewModal';
|
||||
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'>;
|
||||
|
||||
@@ -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 <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);
|
||||
});
|
||||
});
|
||||
@@ -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] };
|
||||
}
|
||||
@@ -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() {
|
||||
</View>
|
||||
{previewDocked && (
|
||||
<View style={styles.previewPane}>
|
||||
<FilePreviewModal docked file={previewTarget} onClose={() => setPreviewTarget(null)} />
|
||||
<FilePreviewModal
|
||||
docked
|
||||
file={previewTarget}
|
||||
onClose={() => setPreviewTarget(null)}
|
||||
onPrevious={showPrevious}
|
||||
onNext={showNext}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -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 && <FilePreviewModal file={previewTarget} onClose={() => setPreviewTarget(null)} />}
|
||||
{!isSplit && (
|
||||
<FilePreviewModal
|
||||
file={previewTarget}
|
||||
onClose={() => setPreviewTarget(null)}
|
||||
onPrevious={showPrevious}
|
||||
onNext={showNext}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ShareSheet
|
||||
node={shareTarget}
|
||||
|
||||
Reference in New Issue
Block a user