feat: files screen with search, sort, move/duplicate, multi-file upload progress, batch download, thumbnails and localization

This commit is contained in:
Linus Rath
2026-08-29 17:59:44 +02:00
parent a0ce9258cd
commit c0e1d6b0a8
5 changed files with 789 additions and 166 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwa
## What's missing or rough
- iOS builds, but push notifications and client certificates are Android-only so far
- Filters & rules, S/MIME, plugins, themes, file storage - UI stubs only
- S/MIME, plugins, themes - UI stubs only (filters & rules, the vacation responder and file storage are real implementations)
- Calendar editing is partial; contacts editing is basic
- No Play Store distribution yet (sideload APK from Releases); iOS ships via TestFlight
+27 -5
View File
@@ -303,9 +303,7 @@
"move": "Move",
"print": "Print",
"view_source": "View source",
"fullscreen": "Fullscreen",
"exit_fullscreen": "Exit fullscreen",
"export_email": "Export as .eml",
"forward_as_attachment": "Forward as attachment",
@@ -1666,7 +1664,7 @@
"disable_tooltip": "Disable encryption",
"enabled_success": "Encryption at rest enabled"
},
"public_keys":{
"public_keys": {
"name_label": "Public Key name",
"name_placeholder": "e.g. My Key",
"key_label": "Public Key (S/MIME or PGP ASCII-armored)",
@@ -1843,7 +1841,7 @@
},
"refresh_cache": {
"label": "Refresh cached data",
"description": "Reload contacts, calendars, and folders from the server. Keeps your accounts and sessions \u2014 fixes a stale or wrong view without signing out.",
"description": "Reload contacts, calendars, and folders from the server. Keeps your accounts and sessions — fixes a stale or wrong view without signing out.",
"button": "Refresh"
},
"reset_settings": {
@@ -3340,7 +3338,31 @@
"disabled_description": "Large file uploads via WebDAV can cause Stalwart/RocksDB instability, including out-of-memory crashes and unrecoverable disk usage. Deleted files may not be immediately purged from blob storage. This feature is not recommended for production environments.",
"stability_warning": "Large file uploads can cause server instability. Deleted files may not be immediately purged from storage. Use with caution.",
"migration_title": "Updating your files…",
"migration_description": "Organising folders and files into their proper structure. This happens only once."
"migration_description": "Organising folders and files into their proper structure. This happens only once.",
"move_to": "Move to…",
"sharing_access": "Sharing & access",
"shared_with": "Shared with",
"share_add_people": "Add people",
"share_search_placeholder": "Search by name or email",
"share_no_users": "No other users found",
"share_no_matches": "No matches",
"share_folder_hint": "Sharing a folder shares everything inside it.",
"share_error": "Failed to update sharing",
"share_remove": "Remove access",
"share_empty": "Not shared with anyone yet",
"share_role_viewer": "Viewer",
"share_role_editor": "Editor",
"share_role_manager": "Manager",
"share_role_custom": "Custom",
"done": "Done",
"invalid_name": "Invalid name",
"invalid_name_slash": "Names cannot contain \"/\".",
"upload_unavailable": "The file picker is not installed in this build. Rebuild the app to enable uploads.",
"selected_count": "{count} selected",
"folder_empty": "This folder is empty",
"delete_item_title": "Delete this item?",
"delete_items_title": "Delete {count} items?",
"delete_cascade_hint": "Folders are deleted along with everything inside them. This can't be undone."
},
"smime": {
"your_certificates": "Your Certificates",
+21 -17
View File
@@ -17,6 +17,7 @@ import { getPrincipals, isFolder, ownPrincipalId, setFileNodeShare } from '../..
import type { FileNode, FileNodeRights, Principal } from '../../api/types';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
type RolePreset = 'read' | 'readWrite' | 'manager';
@@ -38,10 +39,10 @@ const FILE_PRESETS: Record<RolePreset, FileNodeRights> = {
},
};
const PRESET_LABELS: Record<RolePreset, string> = {
read: 'Viewer',
readWrite: 'Editor',
manager: 'Manager',
const PRESET_LABEL_KEYS: Record<RolePreset, [string, string]> = {
read: ['files.share_role_viewer', 'Viewer'],
readWrite: ['files.share_role_editor', 'Editor'],
manager: ['files.share_role_manager', 'Manager'],
};
const PRESET_ORDER: RolePreset[] = ['read', 'readWrite', 'manager'];
@@ -65,6 +66,7 @@ interface ShareSheetProps {
export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const [principals, setPrincipals] = useState<Principal[]>([]);
const [loadingPrincipals, setLoadingPrincipals] = useState(false);
@@ -121,12 +123,12 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps
});
onChanged();
} catch (e) {
Alert.alert('Sharing failed', e instanceof Error ? e.message : String(e));
Alert.alert(t('files.share_error', 'Failed to update sharing'), e instanceof Error ? e.message : String(e));
} finally {
setSavingId(null);
}
},
[node, savingId, onChanged],
[node, savingId, onChanged, t],
);
if (!node) return null;
@@ -157,12 +159,12 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps
<View style={styles.titleRow}>
<Icon size={18} color={c.textMuted} />
<Text style={styles.title} numberOfLines={1}>
Share “{node.name}”
{t('files.share', 'Share')} “{node.name}”
</Text>
</View>
{isFolder(node) ? (
<Text style={styles.subtitle}>
Sharing a folder shares everything inside it.
{t('files.share_folder_hint', 'Sharing a folder shares everything inside it.')}
</Text>
) : null}
@@ -172,7 +174,7 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps
>
{sharedEntries.length > 0 ? (
<View style={styles.section}>
<Text style={styles.sectionLabel}>Shared with</Text>
<Text style={styles.sectionLabel}>{t('files.shared_with', 'Shared with')}</Text>
{sharedEntries.map(([principalId, rights]) => {
const preset = detectPreset(rights);
const busy = savingId === principalId;
@@ -190,12 +192,12 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps
<Text
style={[styles.chipText, preset === p && styles.chipTextActive]}
>
{PRESET_LABELS[p]}
{t(PRESET_LABEL_KEYS[p][0], PRESET_LABEL_KEYS[p][1])}
</Text>
</Pressable>
))}
{preset === 'custom' ? (
<Text style={styles.customLabel}>Custom</Text>
<Text style={styles.customLabel}>{t('files.share_role_custom', 'Custom')}</Text>
) : null}
</View>
{busy ? (
@@ -204,7 +206,7 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps
<Pressable
onPress={() => void applyShare(principalId, null)}
hitSlop={8}
accessibilityLabel="Remove access"
accessibilityLabel={t('files.share_remove', 'Remove access')}
>
<Trash2 size={18} color={c.error} />
</Pressable>
@@ -216,16 +218,16 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps
) : (
<View style={styles.emptyRow}>
<Users size={18} color={c.textMuted} />
<Text style={styles.emptyText}>Not shared with anyone yet</Text>
<Text style={styles.emptyText}>{t('files.share_empty', 'Not shared with anyone yet')}</Text>
</View>
)}
<View style={styles.section}>
<Text style={styles.sectionLabel}>Add people</Text>
<Text style={styles.sectionLabel}>{t('files.share_add_people', 'Add people')}</Text>
<TextInput
value={search}
onChangeText={setSearch}
placeholder="Search by name or email"
placeholder={t('files.share_search_placeholder', 'Search by name or email')}
placeholderTextColor={c.textMuted}
style={styles.input}
autoCapitalize="none"
@@ -239,7 +241,9 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps
/>
) : candidates.length === 0 ? (
<Text style={styles.noMatches}>
{principals.length === 0 ? 'No other users found' : 'No matches'}
{principals.length === 0
? t('files.share_no_users', 'No other users found')
: t('files.share_no_matches', 'No matches')}
</Text>
) : (
candidates.map((p) => (
@@ -265,7 +269,7 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps
</ScrollView>
<Pressable onPress={onClose} style={styles.doneBtn}>
<Text style={styles.doneText}>Done</Text>
<Text style={styles.doneText}>{t('files.done', 'Done')}</Text>
</Pressable>
</View>
</TouchableWithoutFeedback>
+45 -36
View File
@@ -8,14 +8,13 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke
import { useColors } from '../../theme/colors';
import {
useSettingsStore,
type FilesFolderLayout,
type FilesViewMode,
type FilesSortKey,
type FilesSortDir,
} from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store';
interface FilesPrefs {
folderLayout: FilesFolderLayout;
defaultViewMode: FilesViewMode;
defaultSortKey: FilesSortKey;
defaultSortDir: FilesSortDir;
@@ -69,6 +68,7 @@ function getIcon(c: ThemePalette, file: SampleFile, colored: boolean, sz: number
function FilesPreview({ prefs }: { prefs: FilesPrefs }) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const files = SAMPLE.filter((f) => {
if (!prefs.showHiddenFiles && f.hidden) return false;
return true;
@@ -99,9 +99,9 @@ function FilesPreview({ prefs }: { prefs: FilesPrefs }) {
return (
<View style={styles.previewBox}>
<View style={styles.listHeader}>
<Text style={[styles.listHeaderText, { flex: 1 }]}>Name</Text>
<Text style={[styles.listHeaderText, { width: 60, textAlign: 'right' }]}>Size</Text>
<Text style={[styles.listHeaderText, { width: 60, textAlign: 'right' }]}>Modified</Text>
<Text style={[styles.listHeaderText, { flex: 1 }]}>{t('files.name', 'Name')}</Text>
<Text style={[styles.listHeaderText, { width: 60, textAlign: 'right' }]}>{t('files.size', 'Size')}</Text>
<Text style={[styles.listHeaderText, { width: 60, textAlign: 'right' }]}>{t('files.modified', 'Modified')}</Text>
</View>
{files.map((f) => (
<View key={f.name} style={[styles.listRow, f.hidden && { opacity: 0.5 }]}>
@@ -126,8 +126,8 @@ export function FilesSettings() {
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
const set = useSettingsStore((s) => s.updateSetting);
const t = useLocaleStore((s) => s.t);
const folderLayout = useSettingsStore((s) => s.filesFolderLayout);
const defaultViewMode = useSettingsStore((s) => s.filesDefaultViewMode);
const defaultSortKey = useSettingsStore((s) => s.filesDefaultSortKey);
const defaultSortDir = useSettingsStore((s) => s.filesDefaultSortDir);
@@ -141,7 +141,6 @@ export function FilesSettings() {
}, [hydrated, hydrate]);
const prefs: FilesPrefs = {
folderLayout,
defaultViewMode,
defaultSortKey,
defaultSortDir,
@@ -154,69 +153,76 @@ export function FilesSettings() {
return (
<View style={styles.container}>
<View>
<Text style={styles.previewLabel}>Preview</Text>
<Text style={styles.previewLabel}>{t('files.preview', 'Preview')}</Text>
<FilesPreview prefs={prefs} />
</View>
<SettingsSection title="Display" description="How files and folders are presented.">
<SettingItem label="Folder layout" description="Choose how folders appear in the browser.">
<RadioGroup
value={folderLayout}
onChange={(v) => set('filesFolderLayout', v as FilesFolderLayout)}
options={[
{ value: 'inline', label: 'Inline' },
{ value: 'sidebar', label: 'Sidebar' },
]}
/>
</SettingItem>
<SettingItem label="Default view" description="List or grid view.">
<SettingsSection title={t('files.settings_display', 'Display')}>
<SettingItem
label={t('files.settings_default_view', 'Default View')}
description={t('files.settings_default_view_desc', 'Choose between grid and list layout')}
>
<RadioGroup
value={defaultViewMode}
onChange={(v) => set('filesDefaultViewMode', v as FilesViewMode)}
options={[
{ value: 'list', label: 'List' },
{ value: 'grid', label: 'Grid' },
{ value: 'list', label: t('files.list_view', 'List') },
{ value: 'grid', label: t('files.grid_view', 'Grid') },
]}
/>
</SettingItem>
<SettingItem label="Default sort" description="Primary sort key.">
<SettingItem
label={t('files.settings_default_sort', 'Default Sort')}
description={t('files.settings_default_sort_desc', 'Choose the default sorting for files')}
>
<RadioGroup
value={defaultSortKey}
onChange={(v) => set('filesDefaultSortKey', v as FilesSortKey)}
options={[
{ value: 'name', label: 'Name' },
{ value: 'size', label: 'Size' },
{ value: 'modified', label: 'Modified' },
{ value: 'name', label: t('files.name', 'Name') },
{ value: 'size', label: t('files.size', 'Size') },
{ value: 'modified', label: t('files.modified', 'Modified') },
]}
/>
</SettingItem>
<SettingItem label="Sort direction">
<SettingItem
label={t('files.settings_sort_direction', 'Sort Direction')}
description={t('files.settings_sort_direction_desc', 'Choose ascending or descending order')}
>
<RadioGroup
value={defaultSortDir}
onChange={(v) => set('filesDefaultSortDir', v as FilesSortDir)}
options={[
{ value: 'asc', label: 'Ascending' },
{ value: 'desc', label: 'Descending' },
{ value: 'asc', label: t('files.settings_ascending', 'Ascending') },
{ value: 'desc', label: t('files.settings_descending', 'Descending') },
]}
/>
</SettingItem>
</SettingsSection>
<SettingsSection title="Icons" description="Appearance of file and folder icons.">
<SettingItem label="Show icons" description="Display icons for files and folders.">
<SettingsSection title={t('files.settings_icons', 'Icons')}>
<SettingItem
label={t('files.settings_show_icons', 'Show File Icons')}
description={t('files.settings_show_icons_desc', 'Display icons next to files and folders')}
>
<ToggleSwitch checked={showIcons} onChange={(v) => set('filesShowIcons', v)} />
</SettingItem>
<SettingItem label="Colored icons" description="Use file-type-specific colors.">
<SettingItem
label={t('files.settings_colored_icons', 'Colored Icons')}
description={t('files.settings_colored_icons_desc', 'Use colorful icons instead of monochrome')}
>
<ToggleSwitch
checked={coloredIcons}
onChange={(v) => set('filesColoredIcons', v)}
disabled={!showIcons}
/>
</SettingItem>
<SettingItem label="Show thumbnails" description="Generate previews for images.">
<SettingItem
label={t('files.settings_show_thumbnails', 'Show Thumbnails')}
description={t('files.settings_show_thumbnails_desc', 'Display image previews instead of icons for image files')}
>
<ToggleSwitch
checked={showThumbnails}
onChange={(v) => set('filesShowThumbnails', v)}
@@ -224,8 +230,11 @@ export function FilesSettings() {
</SettingItem>
</SettingsSection>
<SettingsSection title="Behavior">
<SettingItem label="Show hidden files" description="Include dotfiles (.name) in listings.">
<SettingsSection title={t('files.settings_behavior', 'Behavior')}>
<SettingItem
label={t('files.settings_show_hidden', 'Show Hidden Files')}
description={t('files.settings_show_hidden_desc', 'Display files and folders that start with a dot')}
>
<ToggleSwitch
checked={showHiddenFiles}
onChange={(v) => set('filesShowHiddenFiles', v)}
File diff suppressed because it is too large Load Diff