mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
fix: give the Files recent and starred menus a Cancel and let Back close them
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
// An Android alert shows at most three buttons and drops the rest. The Files
|
||||
// tab's recent-file menu was an Alert with Open, Show in folder, Remove from
|
||||
// recent and Cancel: Cancel fell off, and since an Android alert also ignores
|
||||
// Back and outside taps, the menu could only be left by picking an action.
|
||||
// Menus with more than three choices are sheets.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const SRC = path.resolve(__dirname, '../..');
|
||||
|
||||
function sourceFiles(dir: string): string[] {
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) return e.name === '__tests__' ? [] : sourceFiles(p);
|
||||
return /\.tsx?$/.test(e.name) ? [p] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** Each Alert.alert(...) call with its arguments. */
|
||||
function alertCalls(source: string): { call: string; line: number }[] {
|
||||
const out: { call: string; line: number }[] = [];
|
||||
let at = source.indexOf('Alert.alert(');
|
||||
while (at >= 0) {
|
||||
let depth = 0;
|
||||
let i = at + 'Alert.alert'.length;
|
||||
for (; i < source.length; i++) {
|
||||
if (source[i] === '(') depth++;
|
||||
else if (source[i] === ')' && --depth === 0) break;
|
||||
}
|
||||
out.push({ call: source.slice(at, i + 1), line: source.slice(0, at).split('\n').length });
|
||||
at = source.indexOf('Alert.alert(', i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buttonCount(call: string): number {
|
||||
return (call.match(/\btext:/g) ?? []).length;
|
||||
}
|
||||
|
||||
describe('alerts', () => {
|
||||
it('have at most three buttons, all of which Android shows', () => {
|
||||
const offenders = sourceFiles(SRC).flatMap((file) =>
|
||||
alertCalls(fs.readFileSync(file, 'utf8'))
|
||||
.filter(({ call }) => buttonCount(call) > 3)
|
||||
.map(({ line }) => `${path.relative(SRC, file)}:${line}`));
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it('counts the buttons of a call', () => {
|
||||
const [call] = alertCalls("Alert.alert(name, undefined, [{ text: t('a', 'A'), onPress: () => f(1) }, { text: 'B' }]);");
|
||||
expect(buttonCount(call.call)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Files recent and starred menus', () => {
|
||||
const source = fs.readFileSync(path.join(SRC, 'screens/FilesScreen.tsx'), 'utf8');
|
||||
|
||||
it('open a sheet with a Cancel row that Back and an outside tap close', () => {
|
||||
const body = source.slice(source.indexOf('function PlaceActionsSheet'), source.indexOf('interface FolderPickerSheetProps'));
|
||||
expect(body).toContain('onRequestClose={onClose}');
|
||||
expect(body).toContain('onPress={onClose}');
|
||||
expect(body).toContain("t('files.cancel', 'Cancel')");
|
||||
const recent = source.slice(source.indexOf('const recentActions'), source.indexOf('const starredActions'));
|
||||
expect(recent).toContain('setPlaceMenu(');
|
||||
expect(recent).not.toContain('Alert.alert');
|
||||
});
|
||||
});
|
||||
+94
-21
@@ -22,6 +22,7 @@ import {
|
||||
FileAudio, FileArchive, FileSpreadsheet, FileCode2, LayoutGrid, List as ListIcon,
|
||||
MoreVertical, Pencil, Share2, Trash2, Upload, Users, X, Download, Search,
|
||||
ArrowUpDown, ArrowUp, ArrowDown, FolderInput, Copy, Check, Eye, ExternalLink, FileSymlink, RotateCcw, Star, Clock,
|
||||
FolderOpen, type AppIcon,
|
||||
} from '../components/icons';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { File, Paths } from 'expo-file-system';
|
||||
@@ -255,6 +256,8 @@ export default function FilesScreen() {
|
||||
const [renameTarget, setRenameTarget] = useState<FileRow | null>(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [actionsTarget, setActionsTarget] = useState<FileRow | null>(null);
|
||||
// The menu of a starred folder or a recent file.
|
||||
const [placeMenu, setPlaceMenu] = useState<PlaceMenu | null>(null);
|
||||
const [shareTarget, setShareTarget] = useState<FileRow | null>(null);
|
||||
const [moveTarget, setMoveTarget] = useState<FileRow | null>(null);
|
||||
const [previewTarget, setPreviewTarget] = useState<FileRow | null>(null);
|
||||
@@ -1324,29 +1327,41 @@ export default function FilesScreen() {
|
||||
openFolderStack(place.stack);
|
||||
if (!reveal && !isSymlink(place.node)) openFile({ ...place.node, displayName: place.node.name });
|
||||
};
|
||||
// A sheet, not an Alert: an Android alert shows at most three buttons, so
|
||||
// the recent file's Cancel was dropped, and it ignores Back and outside
|
||||
// taps. Same items as the webmail's sidebar menu (files-sidebar.tsx).
|
||||
const recentActions = (id: string, name: string) => {
|
||||
Alert.alert(name, undefined, [
|
||||
{ text: t('files.open', 'Open'), onPress: () => openRecent(id) },
|
||||
{ text: t('files.show_in_folder', 'Show in folder'), onPress: () => openRecent(id, true) },
|
||||
{
|
||||
text: t('files.remove_from_recent', 'Remove from recent'),
|
||||
onPress: () => {
|
||||
if (activeAccountId) useFilesPlacesStore.getState().removeRecentFiles(activeAccountId, [id]);
|
||||
setPlaceMenu({
|
||||
title: name,
|
||||
actions: [
|
||||
{ key: 'open', label: t('files.open', 'Open'), icon: Eye, onPress: () => openRecent(id) },
|
||||
{ key: 'reveal', label: t('files.show_in_folder', 'Show in folder'), icon: FolderOpen, onPress: () => openRecent(id, true) },
|
||||
{
|
||||
key: 'remove',
|
||||
label: t('files.remove_from_recent', 'Remove from recent'),
|
||||
icon: X,
|
||||
onPress: () => {
|
||||
if (activeAccountId) useFilesPlacesStore.getState().removeRecentFiles(activeAccountId, [id]);
|
||||
},
|
||||
},
|
||||
},
|
||||
{ text: t('files.cancel', 'Cancel'), style: 'cancel' },
|
||||
]);
|
||||
],
|
||||
});
|
||||
};
|
||||
const starredActions = (folderPath: string, name: string) => {
|
||||
Alert.alert(name, undefined, [
|
||||
{
|
||||
text: t('files.unstar', 'Remove star'),
|
||||
onPress: () => {
|
||||
if (activeAccountId) useFilesPlacesStore.getState().toggleFavorite(activeAccountId, folderPath);
|
||||
const starredActions = (folderPath: string, name: string, stack: FolderStep[]) => {
|
||||
setPlaceMenu({
|
||||
title: name,
|
||||
actions: [
|
||||
{ key: 'open', label: t('files.open', 'Open'), icon: FolderOpen, onPress: () => openFolderStack(stack) },
|
||||
{
|
||||
key: 'unstar',
|
||||
label: t('files.unstar', 'Remove star'),
|
||||
icon: Star,
|
||||
onPress: () => {
|
||||
if (activeAccountId) useFilesPlacesStore.getState().toggleFavorite(activeAccountId, folderPath);
|
||||
},
|
||||
},
|
||||
},
|
||||
{ text: t('files.cancel', 'Cancel'), style: 'cancel' },
|
||||
]);
|
||||
],
|
||||
});
|
||||
};
|
||||
const placesHeader = showPlaces && (starredFolders.length > 0 || recentFiles.length > 0) ? (
|
||||
<View style={styles.places}>
|
||||
@@ -1359,7 +1374,7 @@ export default function FilesScreen() {
|
||||
<Pressable
|
||||
key={folderPath}
|
||||
onPress={() => openFolderStack(stack)}
|
||||
onLongPress={() => starredActions(folderPath, name)}
|
||||
onLongPress={() => starredActions(folderPath, name, stack)}
|
||||
delayLongPress={300}
|
||||
style={({ pressed }) => [styles.fileRow, pressed && { backgroundColor: c.surfaceHover }]}
|
||||
accessibilityRole="button"
|
||||
@@ -1367,7 +1382,7 @@ export default function FilesScreen() {
|
||||
<Star size={20} color={c.starred} fill={c.starred} />
|
||||
<Text style={[styles.fileName, styles.placeName]} numberOfLines={1}>{name}</Text>
|
||||
<Pressable
|
||||
onPress={() => starredActions(folderPath, name)}
|
||||
onPress={() => starredActions(folderPath, name, stack)}
|
||||
hitSlop={8}
|
||||
style={styles.rowMore}
|
||||
accessibilityRole="button"
|
||||
@@ -1656,6 +1671,8 @@ export default function FilesScreen() {
|
||||
onPick={(destinationId) => moveTarget && void performMove(moveTarget, destinationId)}
|
||||
/>
|
||||
|
||||
<PlaceActionsSheet menu={placeMenu} t={t} onClose={() => setPlaceMenu(null)} />
|
||||
|
||||
<SortSheet
|
||||
visible={sortSheetOpen}
|
||||
sortKey={sortKey}
|
||||
@@ -1950,6 +1967,62 @@ function ActionsSheet({
|
||||
);
|
||||
}
|
||||
|
||||
interface PlaceAction {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: AppIcon;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
interface PlaceMenu {
|
||||
title: string;
|
||||
actions: PlaceAction[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The actions of a starred folder or a recent file, with a Cancel row. Back
|
||||
* and a tap outside close it too.
|
||||
*/
|
||||
function PlaceActionsSheet({ menu, t, onClose }: { menu: PlaceMenu | null; t: Translate; onClose: () => void }) {
|
||||
// Centred on a wide window, clear of side insets in landscape.
|
||||
const sheetInsets = useSheetInsets();
|
||||
const bottomInset = useSheetBottomInset(!!menu);
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeSheetStyles(c), [c]);
|
||||
if (!menu) return null;
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
|
||||
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
|
||||
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
|
||||
<TouchableWithoutFeedback accessible={false}>
|
||||
<View style={[styles.sheet, { paddingBottom: spacing.xxl + bottomInset }]}>
|
||||
<Text style={styles.sheetTitle} numberOfLines={1}>{menu.title}</Text>
|
||||
{menu.actions.map((a) => {
|
||||
const Icon = a.icon;
|
||||
return (
|
||||
<Pressable
|
||||
key={a.key}
|
||||
style={styles.action}
|
||||
accessibilityRole="button"
|
||||
onPress={() => { onClose(); a.onPress(); }}
|
||||
>
|
||||
<Icon size={18} color={c.text} />
|
||||
<Text style={styles.actionLabel}>{a.label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
<View style={styles.sheetDivider} />
|
||||
<Pressable style={styles.action} accessibilityRole="button" onPress={onClose}>
|
||||
<Text style={styles.actionLabel}>{t('files.cancel', 'Cancel')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface FolderPickerSheetProps {
|
||||
target: FileRow | null;
|
||||
allNodes: FileNode[];
|
||||
|
||||
Reference in New Issue
Block a user