mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
feat: files screen with search, sort, move/duplicate, multi-file upload progress, batch download, thumbnails and localization
This commit is contained in:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
+695
-107
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user