mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-04 16:06:41 +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;
|
onShare: () => void;
|
||||||
/** Save a copy; adds a Download button to the header. */
|
/** Save a copy; adds a Download button to the header. */
|
||||||
onDownload?: () => void;
|
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.
|
* Used by the reader's attachment chips and the Files tab.
|
||||||
*/
|
*/
|
||||||
export function AttachmentPreviewModal({
|
export function AttachmentPreviewModal({
|
||||||
item, loading, title, onClose, onOpenExternal, onShare, onDownload,
|
item, loading, title, onClose, onOpenExternal, onShare, onDownload, docked = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
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
|
// 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.
|
// external viewer instead. Keyed by URI so the next item starts clean.
|
||||||
const [failedImageUri, setFailedImageUri] = React.useState<string | null>(null);
|
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>(() => {
|
const emlEmail = React.useMemo<Email | null>(() => {
|
||||||
if (!item?.eml) return null;
|
if (!item?.eml) return null;
|
||||||
@@ -104,13 +109,19 @@ export function AttachmentPreviewModal({
|
|||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.flex}
|
style={styles.flex}
|
||||||
contentContainerStyle={styles.imageScroll}
|
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}
|
maximumZoomScale={4}
|
||||||
minimumZoomScale={1}
|
minimumZoomScale={1}
|
||||||
bouncesZoom
|
bouncesZoom
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: item.fileUri }}
|
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"
|
resizeMode="contain"
|
||||||
accessibilityLabel={item.name}
|
accessibilityLabel={item.name}
|
||||||
onError={() => setFailedImageUri(item.fileUri ?? null)}
|
onError={() => setFailedImageUri(item.fileUri ?? null)}
|
||||||
@@ -160,9 +171,8 @@ export function AttachmentPreviewModal({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const content = (
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!item || loading} animationType="slide" onRequestClose={onClose}>
|
<SafeAreaView style={styles.container} edges={docked ? ['top', 'right'] : ['top', 'bottom', 'left', 'right']}>
|
||||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onClose}
|
onPress={onClose}
|
||||||
@@ -210,6 +220,11 @@ export function AttachmentPreviewModal({
|
|||||||
</View>
|
</View>
|
||||||
{renderBody()}
|
{renderBody()}
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
if (docked) return item || loading ? content : null;
|
||||||
|
return (
|
||||||
|
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!item || loading} animationType="slide" onRequestClose={onClose}>
|
||||||
|
{content}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ interface Props {
|
|||||||
/** The file to show; null closes the preview. */
|
/** The file to show; null closes the preview. */
|
||||||
file: PreviewableFileNode | null;
|
file: PreviewableFileNode | null;
|
||||||
onClose: () => void;
|
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
|
* 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.
|
* 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 t = useLocaleStore((s) => s.t);
|
||||||
const [item, setItem] = React.useState<PreviewItem | null>(null);
|
const [item, setItem] = React.useState<PreviewItem | null>(null);
|
||||||
const [loading, setLoading] = React.useState(false);
|
const [loading, setLoading] = React.useState(false);
|
||||||
@@ -123,6 +125,7 @@ export function FilePreviewModal({ file, onClose }: Props) {
|
|||||||
item={item}
|
item={item}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
title={name}
|
title={name}
|
||||||
|
docked={docked}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onOpenExternal={() => act(
|
onOpenExternal={() => act(
|
||||||
(local) => shareLocalFile(local, type, name, { keep: true }),
|
(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,
|
TouchableOpacity,
|
||||||
TouchableWithoutFeedback,
|
TouchableWithoutFeedback,
|
||||||
View,
|
View,
|
||||||
|
useWindowDimensions,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import {
|
import {
|
||||||
ChevronLeft, FileText, Folder, FolderPlus, HardDrive, FileImage, FileVideo,
|
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 { downloadAttachment, shareAttachment } from '../lib/email-export';
|
||||||
import { secureFetch } from '../lib/client-cert';
|
import { secureFetch } from '../lib/client-cert';
|
||||||
import { getUniqueName } from '../lib/filenode-name';
|
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 { useBackWhileFocused } from '../lib/use-back-while-focused';
|
||||||
import { filesBackStep } from '../lib/files-back';
|
import { filesBackStep } from '../lib/files-back';
|
||||||
import type { FileNode } from '../api/types';
|
import type { FileNode } from '../api/types';
|
||||||
@@ -66,7 +69,6 @@ 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 { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
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.
|
// A file row carries the display name alongside the rest of the node.
|
||||||
interface FileRow extends FileNode {
|
interface FileRow extends FileNode {
|
||||||
@@ -206,6 +208,11 @@ export default function FilesScreen() {
|
|||||||
|
|
||||||
const [viewOverride, setViewOverride] = useState<FilesViewMode | null>(null);
|
const [viewOverride, setViewOverride] = useState<FilesViewMode | null>(null);
|
||||||
const viewMode: FilesViewMode = viewOverride ?? defaultViewMode;
|
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 uploading = uploadProgress != null;
|
||||||
const selectionMode = selection.size > 0;
|
const selectionMode = selection.size > 0;
|
||||||
@@ -987,12 +994,14 @@ export default function FilesScreen() {
|
|||||||
body = (
|
body = (
|
||||||
<FlatList
|
<FlatList
|
||||||
data={visibleFiles}
|
data={visibleFiles}
|
||||||
key="grid"
|
// A new column count needs a fresh list.
|
||||||
numColumns={3}
|
key={`grid-${grid.columns}`}
|
||||||
|
numColumns={grid.columns}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
columnWrapperStyle={styles.gridRow}
|
columnWrapperStyle={styles.gridRow}
|
||||||
contentContainerStyle={styles.gridContent}
|
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"
|
keyboardShouldPersistTaps="handled"
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={c.primary} />
|
<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 (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={[styles.container, previewDocked && styles.splitRow]}>
|
||||||
{renderHeader()}
|
<View style={styles.listColumn}>
|
||||||
{body}
|
{renderHeader()}
|
||||||
|
{body}
|
||||||
|
</View>
|
||||||
|
{previewDocked && (
|
||||||
|
<View style={styles.previewPane}>
|
||||||
|
<FilePreviewModal docked file={previewTarget} onClose={() => setPreviewTarget(null)} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<PromptModal
|
<PromptModal
|
||||||
visible={newFolderOpen}
|
visible={newFolderOpen}
|
||||||
@@ -1108,7 +1126,7 @@ export default function FilesScreen() {
|
|||||||
onChangeDir={(d) => setSetting('filesDefaultSortDir', d)}
|
onChangeDir={(d) => setSetting('filesDefaultSortDir', d)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FilePreviewModal file={previewTarget} onClose={() => setPreviewTarget(null)} />
|
{!isSplit && <FilePreviewModal file={previewTarget} onClose={() => setPreviewTarget(null)} />}
|
||||||
|
|
||||||
<ShareSheet
|
<ShareSheet
|
||||||
node={shareTarget}
|
node={shareTarget}
|
||||||
@@ -1442,6 +1460,9 @@ function makeStyles(c: ThemePalette) {
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: c.background,
|
backgroundColor: c.background,
|
||||||
},
|
},
|
||||||
|
splitRow: { flexDirection: 'row' },
|
||||||
|
listColumn: { flex: 1 },
|
||||||
|
previewPane: { width: '45%', borderLeftWidth: 1, borderLeftColor: c.border },
|
||||||
header: {
|
header: {
|
||||||
paddingTop: 60,
|
paddingTop: 60,
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
@@ -1611,7 +1632,6 @@ function makeStyles(c: ThemePalette) {
|
|||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
},
|
},
|
||||||
gridItem: {
|
gridItem: {
|
||||||
flex: 1,
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: spacing.xs,
|
gap: spacing.xs,
|
||||||
paddingVertical: spacing.md,
|
paddingVertical: spacing.md,
|
||||||
|
|||||||
Reference in New Issue
Block a user