mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-04 08:07:29 +00:00
feat: fit the files grid to the width and preview files beside the list on tablets
This commit is contained in:
@@ -39,6 +39,8 @@ interface Props {
|
||||
onShare: () => void;
|
||||
/** Save a copy; adds a Download button to the header. */
|
||||
onDownload?: () => void;
|
||||
/** Render in place (a split view's side pane) instead of a full-screen modal. */
|
||||
docked?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,7 +50,7 @@ interface Props {
|
||||
* Used by the reader's attachment chips and the Files tab.
|
||||
*/
|
||||
export function AttachmentPreviewModal({
|
||||
item, loading, title, onClose, onOpenExternal, onShare, onDownload,
|
||||
item, loading, title, onClose, onOpenExternal, onShare, onDownload, docked = false,
|
||||
}: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
@@ -57,6 +59,9 @@ export function AttachmentPreviewModal({
|
||||
// An image the platform can't decode would stay blank; fall back to the
|
||||
// external viewer instead. Keyed by URI so the next item starts clean.
|
||||
const [failedImageUri, setFailedImageUri] = React.useState<string | null>(null);
|
||||
// The image fits the area it is shown in (a pane, a landscape window),
|
||||
// not a box derived from the window width that ran past the screen.
|
||||
const [imageArea, setImageArea] = React.useState<{ width: number; height: number } | null>(null);
|
||||
|
||||
const emlEmail = React.useMemo<Email | null>(() => {
|
||||
if (!item?.eml) return null;
|
||||
@@ -104,13 +109,19 @@ export function AttachmentPreviewModal({
|
||||
<ScrollView
|
||||
style={styles.flex}
|
||||
contentContainerStyle={styles.imageScroll}
|
||||
onLayout={(e) => {
|
||||
const { width: w, height: h } = e.nativeEvent.layout;
|
||||
setImageArea((prev) => (prev && prev.width === w && prev.height === h ? prev : { width: w, height: h }));
|
||||
}}
|
||||
maximumZoomScale={4}
|
||||
minimumZoomScale={1}
|
||||
bouncesZoom
|
||||
>
|
||||
<Image
|
||||
source={{ uri: item.fileUri }}
|
||||
style={{ width: width - spacing.lg * 2, height: width * 1.2 }}
|
||||
style={imageArea
|
||||
? { width: imageArea.width - spacing.lg * 2, height: imageArea.height - spacing.lg * 2 }
|
||||
: { width: width - spacing.lg * 2, height: width * 1.2 }}
|
||||
resizeMode="contain"
|
||||
accessibilityLabel={item.name}
|
||||
onError={() => setFailedImageUri(item.fileUri ?? null)}
|
||||
@@ -160,9 +171,8 @@ export function AttachmentPreviewModal({
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!item || loading} animationType="slide" onRequestClose={onClose}>
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
const content = (
|
||||
<SafeAreaView style={styles.container} edges={docked ? ['top', 'right'] : ['top', 'bottom', 'left', 'right']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
@@ -210,6 +220,11 @@ export function AttachmentPreviewModal({
|
||||
</View>
|
||||
{renderBody()}
|
||||
</SafeAreaView>
|
||||
);
|
||||
if (docked) return item || loading ? content : null;
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!item || loading} animationType="slide" onRequestClose={onClose}>
|
||||
{content}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ interface Props {
|
||||
/** The file to show; null closes the preview. */
|
||||
file: PreviewableFileNode | null;
|
||||
onClose: () => void;
|
||||
/** Show it in a side pane instead of a full-screen modal. */
|
||||
docked?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,7 +37,7 @@ interface Props {
|
||||
* of fetching it again. The copy is deleted when the preview closes, unless
|
||||
* another app got it, in which case the stale-export sweep removes it.
|
||||
*/
|
||||
export function FilePreviewModal({ file, onClose }: Props) {
|
||||
export function FilePreviewModal({ file, onClose, docked = false }: Props) {
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const [item, setItem] = React.useState<PreviewItem | null>(null);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
@@ -123,6 +125,7 @@ export function FilePreviewModal({ file, onClose }: Props) {
|
||||
item={item}
|
||||
loading={loading}
|
||||
title={name}
|
||||
docked={docked}
|
||||
onClose={onClose}
|
||||
onOpenExternal={() => act(
|
||||
(local) => shareLocalFile(local, type, name, { keep: true }),
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { filesGridLayout } from '../files-grid';
|
||||
|
||||
describe('filesGridLayout', () => {
|
||||
it('keeps three columns on a phone', () => {
|
||||
expect(filesGridLayout(411).columns).toBe(3);
|
||||
});
|
||||
|
||||
it('adds columns on a wide window instead of growing the cells', () => {
|
||||
const { columns, cellWidth } = filesGridLayout(1200);
|
||||
expect(columns).toBeGreaterThan(3);
|
||||
expect(cellWidth).toBeLessThan(160);
|
||||
expect(cellWidth).toBeGreaterThanOrEqual(112);
|
||||
});
|
||||
|
||||
it('fits the cells and gaps into the width', () => {
|
||||
for (const width of [360, 411, 800, 1200, 2000]) {
|
||||
const { columns, cellWidth } = filesGridLayout(width);
|
||||
expect(cellWidth * columns + 8 * (columns - 1) + 16).toBeLessThanOrEqual(width);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Columns and cell width of the Files grid for a list `width` wide: as many
|
||||
* cells of at least `minCell` as fit, never fewer than three. Cells get a
|
||||
* fixed width, so a short last row keeps the columns of the rows above
|
||||
* instead of stretching its cells across the row.
|
||||
*/
|
||||
export function filesGridLayout(
|
||||
width: number,
|
||||
{ minCell = 112, padding = 8, gap = 8 }: { minCell?: number; padding?: number; gap?: number } = {},
|
||||
): { columns: number; cellWidth: number } {
|
||||
const inner = Math.max(0, width - padding * 2);
|
||||
const columns = Math.max(3, Math.floor((inner + gap) / (minCell + gap)));
|
||||
const cellWidth = Math.max(0, Math.floor((inner - gap * (columns - 1)) / columns));
|
||||
return { columns, cellWidth };
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
TouchableOpacity,
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
useWindowDimensions,
|
||||
} from 'react-native';
|
||||
import {
|
||||
ChevronLeft, FileText, Folder, FolderPlus, HardDrive, FileImage, FileVideo,
|
||||
@@ -52,6 +53,8 @@ import { jmapClient } from '../api/jmap-client';
|
||||
import { downloadAttachment, shareAttachment } from '../lib/email-export';
|
||||
import { secureFetch } from '../lib/client-cert';
|
||||
import { getUniqueName } from '../lib/filenode-name';
|
||||
import { filesGridLayout } from '../lib/files-grid';
|
||||
import { useLayout, useSheetInsets } from '../lib/use-layout';
|
||||
import { useBackWhileFocused } from '../lib/use-back-while-focused';
|
||||
import { filesBackStep } from '../lib/files-back';
|
||||
import type { FileNode } from '../api/types';
|
||||
@@ -66,7 +69,6 @@ import Dialog from '../components/Dialog';
|
||||
import ShareSheet from '../components/files/ShareSheet';
|
||||
import { FilePreviewModal, canPreviewInApp } from '../components/files/FilePreviewModal';
|
||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||
import { useSheetInsets } from '../lib/use-layout';
|
||||
|
||||
// A file row carries the display name alongside the rest of the node.
|
||||
interface FileRow extends FileNode {
|
||||
@@ -206,6 +208,11 @@ export default function FilesScreen() {
|
||||
|
||||
const [viewOverride, setViewOverride] = useState<FilesViewMode | null>(null);
|
||||
const viewMode: FilesViewMode = viewOverride ?? defaultViewMode;
|
||||
const { isSplit } = useLayout();
|
||||
// The grid's columns follow its measured width (rotation, tablet panes).
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
const [gridWidth, setGridWidth] = React.useState(0);
|
||||
const grid = filesGridLayout(gridWidth || windowWidth);
|
||||
|
||||
const uploading = uploadProgress != null;
|
||||
const selectionMode = selection.size > 0;
|
||||
@@ -987,12 +994,14 @@ export default function FilesScreen() {
|
||||
body = (
|
||||
<FlatList
|
||||
data={visibleFiles}
|
||||
key="grid"
|
||||
numColumns={3}
|
||||
// A new column count needs a fresh list.
|
||||
key={`grid-${grid.columns}`}
|
||||
numColumns={grid.columns}
|
||||
keyExtractor={(item) => item.id}
|
||||
columnWrapperStyle={styles.gridRow}
|
||||
contentContainerStyle={styles.gridContent}
|
||||
renderItem={({ item }) => renderGridCell(item)}
|
||||
onLayout={(e) => setGridWidth(Math.round(e.nativeEvent.layout.width))}
|
||||
renderItem={({ item }) => <View style={{ width: grid.cellWidth }}>{renderGridCell(item)}</View>}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={c.primary} />
|
||||
@@ -1014,10 +1023,19 @@ export default function FilesScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
// On a wide window an open file shows beside the list instead of covering it.
|
||||
const previewDocked = isSplit && previewTarget != null;
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{renderHeader()}
|
||||
{body}
|
||||
<View style={[styles.container, previewDocked && styles.splitRow]}>
|
||||
<View style={styles.listColumn}>
|
||||
{renderHeader()}
|
||||
{body}
|
||||
</View>
|
||||
{previewDocked && (
|
||||
<View style={styles.previewPane}>
|
||||
<FilePreviewModal docked file={previewTarget} onClose={() => setPreviewTarget(null)} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<PromptModal
|
||||
visible={newFolderOpen}
|
||||
@@ -1108,7 +1126,7 @@ export default function FilesScreen() {
|
||||
onChangeDir={(d) => setSetting('filesDefaultSortDir', d)}
|
||||
/>
|
||||
|
||||
<FilePreviewModal file={previewTarget} onClose={() => setPreviewTarget(null)} />
|
||||
{!isSplit && <FilePreviewModal file={previewTarget} onClose={() => setPreviewTarget(null)} />}
|
||||
|
||||
<ShareSheet
|
||||
node={shareTarget}
|
||||
@@ -1442,6 +1460,9 @@ function makeStyles(c: ThemePalette) {
|
||||
flex: 1,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
splitRow: { flexDirection: 'row' },
|
||||
listColumn: { flex: 1 },
|
||||
previewPane: { width: '45%', borderLeftWidth: 1, borderLeftColor: c.border },
|
||||
header: {
|
||||
paddingTop: 60,
|
||||
paddingHorizontal: spacing.md,
|
||||
@@ -1611,7 +1632,6 @@ function makeStyles(c: ThemePalette) {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
gridItem: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingVertical: spacing.md,
|
||||
|
||||
Reference in New Issue
Block a user