mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20cdc282c8 | ||
|
|
cec98d89db | ||
|
|
8fa8597e9f | ||
|
|
57f15f6f2e | ||
|
|
cd3fdfa217 | ||
|
|
f8064d4544 |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.13",
|
||||
"version": "0.1.16",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.13",
|
||||
"version": "0.1.16",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.13",
|
||||
"version": "0.1.16",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
+153
-36
@@ -1,6 +1,6 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, Animated, PanResponder, Dimensions,
|
||||
View, Text, StyleSheet, Animated, PanResponder, Pressable, Dimensions,
|
||||
} from 'react-native';
|
||||
import {
|
||||
Archive, Trash2, ShieldAlert, MailOpen, Star, Pin, FolderInput,
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from 'lucide-react-native';
|
||||
import { typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import type { SwipeAction } from '../stores/settings-store';
|
||||
import type { SwipeAction, SwipeMode } from '../stores/settings-store';
|
||||
|
||||
interface SwipeableRowProps {
|
||||
children: React.ReactNode;
|
||||
@@ -19,14 +19,24 @@ interface SwipeableRowProps {
|
||||
/** Pass the row state used to compute action labels (e.g. unread/starred toggling). */
|
||||
context: { unread: boolean; starred: boolean; pinned: boolean };
|
||||
onAction: (action: SwipeAction) => void;
|
||||
/**
|
||||
* 'instant' (default): swipe past COMMIT_THRESHOLD and release fires the action.
|
||||
* 'reveal': swipe past ACTIVATION_THRESHOLD reveals an action band that must be tapped.
|
||||
*/
|
||||
mode?: SwipeMode;
|
||||
}
|
||||
|
||||
// Distance (px) of horizontal travel needed to commit the action on release.
|
||||
// Drag less than this and the row snaps back without firing.
|
||||
// Instant mode: distance the user must drag past on release to fire the action.
|
||||
const COMMIT_THRESHOLD = 96;
|
||||
// Reveal mode: drag past this on release to snap to the open (revealed) state.
|
||||
const ACTIVATION_THRESHOLD = 32;
|
||||
// Reveal mode: width of the action band shown when revealed.
|
||||
const REVEAL_WIDTH = 88;
|
||||
|
||||
const DIRECTION_BIAS = 1.5; // dx must dominate dy by this factor
|
||||
const MIN_DX_TO_CLAIM = 6;
|
||||
const MAX_DRAG_OVERSHOOT = 240;
|
||||
const MAX_DRAG_OVERSHOOT_INSTANT = 240;
|
||||
const MAX_DRAG_OVERSHOOT_REVEAL = REVEAL_WIDTH * 1.4;
|
||||
// Distance the row flies off-screen by before the action callback fires.
|
||||
const EXIT_DISTANCE = 600;
|
||||
|
||||
@@ -55,7 +65,7 @@ function exitsRow(action: SwipeAction): boolean {
|
||||
}
|
||||
|
||||
export function SwipeableRow({
|
||||
children, leftAction, rightAction, context, onAction,
|
||||
children, leftAction, rightAction, context, onAction, mode = 'instant',
|
||||
}: SwipeableRowProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
@@ -63,8 +73,21 @@ export function SwipeableRow({
|
||||
const claimed = useRef(false);
|
||||
const widthRef = useRef(Dimensions.get('window').width);
|
||||
|
||||
const snapBack = () => {
|
||||
// Reveal-mode-only state: which side (if any) is currently sitting open.
|
||||
const openSideRef = useRef<'left' | 'right' | null>(null);
|
||||
const [openSide, setOpenSide] = useState<'left' | 'right' | null>(null);
|
||||
|
||||
const close = () => {
|
||||
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
openSideRef.current = null;
|
||||
setOpenSide(null);
|
||||
};
|
||||
|
||||
const openTo = (side: 'left' | 'right') => {
|
||||
const target = side === 'right' ? REVEAL_WIDTH : -REVEAL_WIDTH;
|
||||
Animated.spring(dx, { toValue: target, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
openSideRef.current = side;
|
||||
setOpenSide(side);
|
||||
};
|
||||
|
||||
const fly = (toValue: number, action: SwipeAction) => {
|
||||
@@ -85,7 +108,19 @@ export function SwipeableRow({
|
||||
});
|
||||
} else {
|
||||
onAction(action);
|
||||
snapBack();
|
||||
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
}
|
||||
};
|
||||
|
||||
const fireFromBandTap = (action: SwipeAction) => {
|
||||
close();
|
||||
if (action === 'none') return;
|
||||
if (exitsRow(action)) {
|
||||
// Let the row collapse a frame, then fire so the parent's list update
|
||||
// has a clean starting point.
|
||||
requestAnimationFrame(() => onAction(action));
|
||||
} else {
|
||||
onAction(action);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -95,40 +130,121 @@ export function SwipeableRow({
|
||||
onMoveShouldSetPanResponder: (_, g) => {
|
||||
if (Math.abs(g.dx) < MIN_DX_TO_CLAIM) return false;
|
||||
if (Math.abs(g.dx) < Math.abs(g.dy) * DIRECTION_BIAS) return false;
|
||||
if (g.dx > 0 && rightAction === 'none') return false;
|
||||
if (g.dx < 0 && leftAction === 'none') return false;
|
||||
// Reveal mode: if a side is already open, allow the gesture so the
|
||||
// user can drag it closed.
|
||||
if (mode === 'reveal' && openSideRef.current === null) {
|
||||
if (g.dx > 0 && rightAction === 'none') return false;
|
||||
if (g.dx < 0 && leftAction === 'none') return false;
|
||||
} else if (mode !== 'reveal') {
|
||||
if (g.dx > 0 && rightAction === 'none') return false;
|
||||
if (g.dx < 0 && leftAction === 'none') return false;
|
||||
}
|
||||
claimed.current = true;
|
||||
return true;
|
||||
},
|
||||
onPanResponderMove: (_, g) => {
|
||||
const clamped = Math.max(-MAX_DRAG_OVERSHOOT, Math.min(MAX_DRAG_OVERSHOOT, g.dx));
|
||||
const overshoot = mode === 'reveal' ? MAX_DRAG_OVERSHOOT_REVEAL : MAX_DRAG_OVERSHOOT_INSTANT;
|
||||
const base =
|
||||
mode === 'reveal'
|
||||
? openSideRef.current === 'right' ? REVEAL_WIDTH
|
||||
: openSideRef.current === 'left' ? -REVEAL_WIDTH
|
||||
: 0
|
||||
: 0;
|
||||
const next = base + g.dx;
|
||||
const clamped = Math.max(-overshoot, Math.min(overshoot, next));
|
||||
dx.setValue(clamped);
|
||||
},
|
||||
onPanResponderRelease: (_, g) => {
|
||||
claimed.current = false;
|
||||
if (mode === 'reveal') {
|
||||
const base =
|
||||
openSideRef.current === 'right' ? REVEAL_WIDTH
|
||||
: openSideRef.current === 'left' ? -REVEAL_WIDTH
|
||||
: 0;
|
||||
const finalDx = base + g.dx;
|
||||
if (finalDx > ACTIVATION_THRESHOLD && rightAction !== 'none') {
|
||||
openTo('right');
|
||||
} else if (finalDx < -ACTIVATION_THRESHOLD && leftAction !== 'none') {
|
||||
openTo('left');
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Instant mode: fire on release-past-threshold, no second tap.
|
||||
if (g.dx >= COMMIT_THRESHOLD && rightAction !== 'none') {
|
||||
fly(widthRef.current || EXIT_DISTANCE, rightAction);
|
||||
} else if (g.dx <= -COMMIT_THRESHOLD && leftAction !== 'none') {
|
||||
fly(-(widthRef.current || EXIT_DISTANCE), leftAction);
|
||||
} else {
|
||||
snapBack();
|
||||
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
}
|
||||
},
|
||||
onPanResponderTerminate: () => {
|
||||
snapBack();
|
||||
if (mode === 'reveal') close();
|
||||
else Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
claimed.current = false;
|
||||
},
|
||||
onPanResponderTerminationRequest: () => !claimed.current,
|
||||
}),
|
||||
).current;
|
||||
|
||||
const renderBand = (action: SwipeAction, side: 'left' | 'right') => {
|
||||
const onLayout = (e: { nativeEvent: { layout: { width: number } } }) => {
|
||||
widthRef.current = e.nativeEvent.layout.width;
|
||||
};
|
||||
|
||||
if (mode === 'reveal') {
|
||||
// Reveal mode: bands sit at the row edges with a fixed REVEAL_WIDTH and
|
||||
// are tap targets. The row slides over them; releasing past the activation
|
||||
// threshold snaps the row to the open position so the band is fully
|
||||
// visible and tappable.
|
||||
const renderRevealBand = (action: SwipeAction, side: 'left' | 'right') => {
|
||||
if (action === 'none') return null;
|
||||
const meta = ACTION_META[action];
|
||||
const Icon = meta.icon;
|
||||
const label = actionLabel(action, context);
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => fireFromBandTap(action)}
|
||||
style={[
|
||||
styles.bandReveal,
|
||||
side === 'left' ? { left: 0, alignItems: 'flex-start' } : { right: 0, alignItems: 'flex-end' },
|
||||
{ backgroundColor: meta.bg, width: REVEAL_WIDTH },
|
||||
]}
|
||||
>
|
||||
<View style={styles.bandInner}>
|
||||
<Icon size={20} color="#fff" />
|
||||
<Text style={styles.bandLabel}>{label}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.wrap} onLayout={onLayout} {...responder.panHandlers}>
|
||||
{renderRevealBand(rightAction, 'left')}
|
||||
{renderRevealBand(leftAction, 'right')}
|
||||
<Animated.View style={[styles.content, { transform: [{ translateX: dx }] }]}>
|
||||
{children}
|
||||
{/* When an action is revealed, an overlay absorbs taps on the row so
|
||||
a tap closes the swipe instead of opening the email. */}
|
||||
{openSide ? (
|
||||
<Pressable
|
||||
style={StyleSheet.absoluteFill}
|
||||
onPress={close}
|
||||
/>
|
||||
) : null}
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Instant mode: bands fill the gap behind the row as it's dragged.
|
||||
const renderInstantBand = (action: SwipeAction, side: 'left' | 'right') => {
|
||||
if (action === 'none') return null;
|
||||
const meta = ACTION_META[action];
|
||||
const Icon = meta.icon;
|
||||
const label = actionLabel(action, context);
|
||||
// Band stretches to fill the gap behind the row as it's dragged. We
|
||||
// animate the icon a touch to signal commit-readiness.
|
||||
const inputRange = side === 'left' ? [0, COMMIT_THRESHOLD] : [-COMMIT_THRESHOLD, 0];
|
||||
const iconScale = dx.interpolate({
|
||||
inputRange,
|
||||
@@ -138,7 +254,7 @@ export function SwipeableRow({
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.band,
|
||||
styles.bandInstant,
|
||||
{ backgroundColor: meta.bg },
|
||||
side === 'left' ? { justifyContent: 'flex-start' } : { justifyContent: 'flex-end' },
|
||||
]}
|
||||
@@ -151,12 +267,6 @@ export function SwipeableRow({
|
||||
);
|
||||
};
|
||||
|
||||
const onLayout = (e: { nativeEvent: { layout: { width: number } } }) => {
|
||||
widthRef.current = e.nativeEvent.layout.width;
|
||||
};
|
||||
|
||||
// Show the matching band based on current drag direction. Both bands occupy
|
||||
// the full row, so we fade the inactive one out to avoid bleed-through.
|
||||
const rightBandOpacity = dx.interpolate({
|
||||
inputRange: [-1, 0, 1],
|
||||
outputRange: [0, 0, 1],
|
||||
@@ -171,10 +281,10 @@ export function SwipeableRow({
|
||||
return (
|
||||
<View style={styles.wrap} onLayout={onLayout} {...responder.panHandlers}>
|
||||
<Animated.View style={[StyleSheet.absoluteFill, { opacity: rightBandOpacity }]} pointerEvents="none">
|
||||
{renderBand(rightAction, 'left')}
|
||||
{renderInstantBand(rightAction, 'left')}
|
||||
</Animated.View>
|
||||
<Animated.View style={[StyleSheet.absoluteFill, { opacity: leftBandOpacity }]} pointerEvents="none">
|
||||
{renderBand(leftAction, 'right')}
|
||||
{renderInstantBand(leftAction, 'right')}
|
||||
</Animated.View>
|
||||
<Animated.View style={[styles.content, { transform: [{ translateX: dx }] }]}>
|
||||
{children}
|
||||
@@ -185,15 +295,22 @@ export function SwipeableRow({
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
wrap: { position: 'relative', backgroundColor: c.background, overflow: 'hidden' },
|
||||
content: { backgroundColor: c.background },
|
||||
band: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
bandInner: { alignItems: 'center', gap: 4 },
|
||||
bandLabel: { ...typography.caption, color: '#fff', fontWeight: '600' },
|
||||
wrap: { position: 'relative', backgroundColor: c.background, overflow: 'hidden' },
|
||||
content: { backgroundColor: c.background },
|
||||
bandInstant: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
bandReveal: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
bandInner: { alignItems: 'center', gap: 4 },
|
||||
bandLabel: { ...typography.caption, color: '#fff', fontWeight: '600' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,23 +1,38 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { SettingsSection, SettingItem, Select, RadioGroup, ToggleSwitch } from './settings-section';
|
||||
import {
|
||||
useSettingsStore,
|
||||
type CalendarView,
|
||||
type FirstDayOfWeek,
|
||||
type TimeFormat,
|
||||
type CalendarHoverPreview,
|
||||
} from '../../stores/settings-store';
|
||||
|
||||
export function CalendarSettings() {
|
||||
const [viewMode, setViewMode] = useState('month');
|
||||
const [firstDay, setFirstDay] = useState('1');
|
||||
const [timeFormat, setTimeFormat] = useState('24h');
|
||||
const [showTimeInMonth, setShowTimeInMonth] = useState(true);
|
||||
const [showWeekNumbers, setShowWeekNumbers] = useState(false);
|
||||
const [hoverPreview, setHoverPreview] = useState('delay-500ms');
|
||||
const [birthdayCal, setBirthdayCal] = useState(true);
|
||||
const [tasksEnabled, setTasksEnabled] = useState(false);
|
||||
const [showTasksOnCal, setShowTasksOnCal] = useState(true);
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
const update = useSettingsStore((s) => s.updateSetting);
|
||||
|
||||
const viewMode = useSettingsStore((s) => s.calendarDefaultView);
|
||||
const firstDay = useSettingsStore((s) => s.calendarFirstDayOfWeek);
|
||||
const timeFormat = useSettingsStore((s) => s.calendarTimeFormat);
|
||||
const showTimeInMonth = useSettingsStore((s) => s.calendarShowTimeInMonth);
|
||||
const showWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers);
|
||||
const hoverPreview = useSettingsStore((s) => s.calendarHoverPreview);
|
||||
const birthdayCal = useSettingsStore((s) => s.showBirthdayCalendar);
|
||||
const tasksEnabled = useSettingsStore((s) => s.enableCalendarTasks);
|
||||
const showTasksOnCal = useSettingsStore((s) => s.showTasksOnCalendar);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) void hydrate();
|
||||
}, [hydrated, hydrate]);
|
||||
|
||||
return (
|
||||
<SettingsSection title="Calendar">
|
||||
<SettingItem label="Default view">
|
||||
<Select
|
||||
value={viewMode}
|
||||
onChange={setViewMode}
|
||||
onChange={(v) => update('calendarDefaultView', v as CalendarView)}
|
||||
options={[
|
||||
{ value: 'month', label: 'Month' },
|
||||
{ value: 'week', label: 'Week' },
|
||||
@@ -29,8 +44,8 @@ export function CalendarSettings() {
|
||||
|
||||
<SettingItem label="Week starts on">
|
||||
<Select
|
||||
value={firstDay}
|
||||
onChange={setFirstDay}
|
||||
value={String(firstDay)}
|
||||
onChange={(v) => update('calendarFirstDayOfWeek', Number(v) as FirstDayOfWeek)}
|
||||
options={[
|
||||
{ value: '1', label: 'Monday' },
|
||||
{ value: '0', label: 'Sunday' },
|
||||
@@ -41,7 +56,7 @@ export function CalendarSettings() {
|
||||
<SettingItem label="Time format">
|
||||
<RadioGroup
|
||||
value={timeFormat}
|
||||
onChange={setTimeFormat}
|
||||
onChange={(v) => update('calendarTimeFormat', v as TimeFormat)}
|
||||
options={[
|
||||
{ value: '12h', label: '12-hour' },
|
||||
{ value: '24h', label: '24-hour' },
|
||||
@@ -53,14 +68,20 @@ export function CalendarSettings() {
|
||||
label="Show time in month view"
|
||||
description="Show start times next to events in the month grid."
|
||||
>
|
||||
<ToggleSwitch checked={showTimeInMonth} onChange={setShowTimeInMonth} />
|
||||
<ToggleSwitch
|
||||
checked={showTimeInMonth}
|
||||
onChange={(v) => update('calendarShowTimeInMonth', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Show week numbers"
|
||||
description="Display ISO week numbers in month and week views."
|
||||
>
|
||||
<ToggleSwitch checked={showWeekNumbers} onChange={setShowWeekNumbers} />
|
||||
<ToggleSwitch
|
||||
checked={showWeekNumbers}
|
||||
onChange={(v) => update('calendarShowWeekNumbers', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
@@ -69,7 +90,7 @@ export function CalendarSettings() {
|
||||
>
|
||||
<Select
|
||||
value={hoverPreview}
|
||||
onChange={setHoverPreview}
|
||||
onChange={(v) => update('calendarHoverPreview', v as CalendarHoverPreview)}
|
||||
options={[
|
||||
{ value: 'instant', label: 'Instant' },
|
||||
{ value: 'delay-500ms', label: '500ms delay' },
|
||||
@@ -84,14 +105,20 @@ export function CalendarSettings() {
|
||||
label="Show birthday calendar"
|
||||
description="Automatically include birthdays from your contacts."
|
||||
>
|
||||
<ToggleSwitch checked={birthdayCal} onChange={setBirthdayCal} />
|
||||
<ToggleSwitch
|
||||
checked={birthdayCal}
|
||||
onChange={(v) => update('showBirthdayCalendar', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Enable tasks"
|
||||
description="Show task management alongside events."
|
||||
>
|
||||
<ToggleSwitch checked={tasksEnabled} onChange={setTasksEnabled} />
|
||||
<ToggleSwitch
|
||||
checked={tasksEnabled}
|
||||
onChange={(v) => update('enableCalendarTasks', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{tasksEnabled && (
|
||||
@@ -99,7 +126,10 @@ export function CalendarSettings() {
|
||||
label="Show tasks on calendar"
|
||||
description="Overlay tasks with due dates on the calendar."
|
||||
>
|
||||
<ToggleSwitch checked={showTasksOnCal} onChange={setShowTasksOnCal} />
|
||||
<ToggleSwitch
|
||||
checked={showTasksOnCal}
|
||||
onChange={(v) => update('showTasksOnCalendar', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import {
|
||||
Folder, FileText, FileCode, FileAudio, File, Image as ImageIcon,
|
||||
@@ -6,34 +6,25 @@ import {
|
||||
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
|
||||
type FolderLayout = 'inline' | 'sidebar';
|
||||
type ViewMode = 'list' | 'grid';
|
||||
type SortKey = 'name' | 'size' | 'modified';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
import {
|
||||
useSettingsStore,
|
||||
type FilesFolderLayout,
|
||||
type FilesViewMode,
|
||||
type FilesSortKey,
|
||||
type FilesSortDir,
|
||||
} from '../../stores/settings-store';
|
||||
|
||||
interface FilesPrefs {
|
||||
folderLayout: FolderLayout;
|
||||
defaultViewMode: ViewMode;
|
||||
defaultSortKey: SortKey;
|
||||
defaultSortDir: SortDir;
|
||||
folderLayout: FilesFolderLayout;
|
||||
defaultViewMode: FilesViewMode;
|
||||
defaultSortKey: FilesSortKey;
|
||||
defaultSortDir: FilesSortDir;
|
||||
showIcons: boolean;
|
||||
coloredIcons: boolean;
|
||||
showThumbnails: boolean;
|
||||
showHiddenFiles: boolean;
|
||||
}
|
||||
|
||||
const DEFAULTS: FilesPrefs = {
|
||||
folderLayout: 'inline',
|
||||
defaultViewMode: 'list',
|
||||
defaultSortKey: 'name',
|
||||
defaultSortDir: 'asc',
|
||||
showIcons: true,
|
||||
coloredIcons: true,
|
||||
showThumbnails: true,
|
||||
showHiddenFiles: false,
|
||||
};
|
||||
|
||||
interface SampleFile {
|
||||
name: string;
|
||||
isFolder: boolean;
|
||||
@@ -132,8 +123,33 @@ function FilesPreview({ prefs }: { prefs: FilesPrefs }) {
|
||||
export function FilesSettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const [prefs, setPrefs] = useState<FilesPrefs>(DEFAULTS);
|
||||
const update = (patch: Partial<FilesPrefs>) => setPrefs((p) => ({ ...p, ...patch }));
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
const set = useSettingsStore((s) => s.updateSetting);
|
||||
|
||||
const folderLayout = useSettingsStore((s) => s.filesFolderLayout);
|
||||
const defaultViewMode = useSettingsStore((s) => s.filesDefaultViewMode);
|
||||
const defaultSortKey = useSettingsStore((s) => s.filesDefaultSortKey);
|
||||
const defaultSortDir = useSettingsStore((s) => s.filesDefaultSortDir);
|
||||
const showIcons = useSettingsStore((s) => s.filesShowIcons);
|
||||
const coloredIcons = useSettingsStore((s) => s.filesColoredIcons);
|
||||
const showThumbnails = useSettingsStore((s) => s.filesShowThumbnails);
|
||||
const showHiddenFiles = useSettingsStore((s) => s.filesShowHiddenFiles);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) void hydrate();
|
||||
}, [hydrated, hydrate]);
|
||||
|
||||
const prefs: FilesPrefs = {
|
||||
folderLayout,
|
||||
defaultViewMode,
|
||||
defaultSortKey,
|
||||
defaultSortDir,
|
||||
showIcons,
|
||||
coloredIcons,
|
||||
showThumbnails,
|
||||
showHiddenFiles,
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -145,8 +161,8 @@ export function FilesSettings() {
|
||||
<SettingsSection title="Display" description="How files and folders are presented.">
|
||||
<SettingItem label="Folder layout" description="Choose how folders appear in the browser.">
|
||||
<RadioGroup
|
||||
value={prefs.folderLayout}
|
||||
onChange={(v) => update({ folderLayout: v as FolderLayout })}
|
||||
value={folderLayout}
|
||||
onChange={(v) => set('filesFolderLayout', v as FilesFolderLayout)}
|
||||
options={[
|
||||
{ value: 'inline', label: 'Inline' },
|
||||
{ value: 'sidebar', label: 'Sidebar' },
|
||||
@@ -156,8 +172,8 @@ export function FilesSettings() {
|
||||
|
||||
<SettingItem label="Default view" description="List or grid view.">
|
||||
<RadioGroup
|
||||
value={prefs.defaultViewMode}
|
||||
onChange={(v) => update({ defaultViewMode: v as ViewMode })}
|
||||
value={defaultViewMode}
|
||||
onChange={(v) => set('filesDefaultViewMode', v as FilesViewMode)}
|
||||
options={[
|
||||
{ value: 'list', label: 'List' },
|
||||
{ value: 'grid', label: 'Grid' },
|
||||
@@ -167,8 +183,8 @@ export function FilesSettings() {
|
||||
|
||||
<SettingItem label="Default sort" description="Primary sort key.">
|
||||
<RadioGroup
|
||||
value={prefs.defaultSortKey}
|
||||
onChange={(v) => update({ defaultSortKey: v as SortKey })}
|
||||
value={defaultSortKey}
|
||||
onChange={(v) => set('filesDefaultSortKey', v as FilesSortKey)}
|
||||
options={[
|
||||
{ value: 'name', label: 'Name' },
|
||||
{ value: 'size', label: 'Size' },
|
||||
@@ -179,8 +195,8 @@ export function FilesSettings() {
|
||||
|
||||
<SettingItem label="Sort direction">
|
||||
<RadioGroup
|
||||
value={prefs.defaultSortDir}
|
||||
onChange={(v) => update({ defaultSortDir: v as SortDir })}
|
||||
value={defaultSortDir}
|
||||
onChange={(v) => set('filesDefaultSortDir', v as FilesSortDir)}
|
||||
options={[
|
||||
{ value: 'asc', label: 'Ascending' },
|
||||
{ value: 'desc', label: 'Descending' },
|
||||
@@ -191,25 +207,28 @@ export function FilesSettings() {
|
||||
|
||||
<SettingsSection title="Icons" description="Appearance of file and folder icons.">
|
||||
<SettingItem label="Show icons" description="Display icons for files and folders.">
|
||||
<ToggleSwitch checked={prefs.showIcons} onChange={(v) => update({ showIcons: v })} />
|
||||
<ToggleSwitch checked={showIcons} onChange={(v) => set('filesShowIcons', v)} />
|
||||
</SettingItem>
|
||||
<SettingItem label="Colored icons" description="Use file-type-specific c.">
|
||||
<SettingItem label="Colored icons" description="Use file-type-specific colors.">
|
||||
<ToggleSwitch
|
||||
checked={prefs.coloredIcons}
|
||||
onChange={(v) => update({ coloredIcons: v })}
|
||||
disabled={!prefs.showIcons}
|
||||
checked={coloredIcons}
|
||||
onChange={(v) => set('filesColoredIcons', v)}
|
||||
disabled={!showIcons}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label="Show thumbnails" description="Generate previews for images.">
|
||||
<ToggleSwitch checked={prefs.showThumbnails} onChange={(v) => update({ showThumbnails: v })} />
|
||||
<ToggleSwitch
|
||||
checked={showThumbnails}
|
||||
onChange={(v) => set('filesShowThumbnails', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Behavior">
|
||||
<SettingItem label="Show hidden files" description="Include dotfiles (.name) in listings.">
|
||||
<ToggleSwitch
|
||||
checked={prefs.showHiddenFiles}
|
||||
onChange={(v) => update({ showHiddenFiles: v })}
|
||||
checked={showHiddenFiles}
|
||||
onChange={(v) => set('filesShowHiddenFiles', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { Plus, GripVertical, X, Code, Filter, AlertTriangle, RotateCcw } from 'lucide-react-native';
|
||||
import { SettingsSection, ToggleSwitch } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
|
||||
interface Rule {
|
||||
id: string;
|
||||
@@ -19,11 +20,18 @@ const SAMPLE_RULES: Rule[] = [];
|
||||
export function FilterSettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
const expandedView = useSettingsStore((s) => s.filtersExpandedView);
|
||||
const update = useSettingsStore((s) => s.updateSetting);
|
||||
const [rules, setRules] = useState<Rule[]>(SAMPLE_RULES);
|
||||
const [expandedView, setExpandedView] = useState(false);
|
||||
const [isOpaque] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) void hydrate();
|
||||
}, [hydrated, hydrate]);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setRules((rs) => rs.map((r) => r.id === id ? { ...r, enabled: !r.enabled } : r));
|
||||
};
|
||||
@@ -115,7 +123,7 @@ export function FilterSettings() {
|
||||
{!isOpaque && rules.length > 0 && (
|
||||
<View style={styles.expandedToggle}>
|
||||
<Text style={styles.expandedLabel}>Expanded view</Text>
|
||||
<ToggleSwitch checked={expandedView} onChange={setExpandedView} />
|
||||
<ToggleSwitch checked={expandedView} onChange={(v) => update('filtersExpandedView', v)} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { ArrowRight, ArrowLeft } from 'lucide-react-native';
|
||||
import { SettingsSection, SettingItem, RadioGroup } from './settings-section';
|
||||
import { SettingsSection, RadioGroup } from './settings-section';
|
||||
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSettingsStore, type SwipeAction } from '../../stores/settings-store';
|
||||
import { useSettingsStore, type SwipeAction, type SwipeMode } from '../../stores/settings-store';
|
||||
|
||||
const SWIPE_OPTIONS: { value: SwipeAction; label: string }[] = [
|
||||
{ value: 'none', label: 'None' },
|
||||
@@ -17,6 +17,11 @@ const SWIPE_OPTIONS: { value: SwipeAction; label: string }[] = [
|
||||
{ value: 'move', label: 'Move to folder' },
|
||||
];
|
||||
|
||||
const SWIPE_MODE_OPTIONS: { value: SwipeMode; label: string }[] = [
|
||||
{ value: 'instant', label: 'Instant (swipe = action)' },
|
||||
{ value: 'reveal', label: 'Reveal (swipe, then tap)' },
|
||||
];
|
||||
|
||||
export function LayoutSettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
@@ -24,6 +29,8 @@ export function LayoutSettings() {
|
||||
const setSwipeLeftAction = useSettingsStore((s) => s.setSwipeLeftAction);
|
||||
const swipeRightAction = useSettingsStore((s) => s.swipeRightAction);
|
||||
const setSwipeRightAction = useSettingsStore((s) => s.setSwipeRightAction);
|
||||
const swipeMode = useSettingsStore((s) => s.swipeMode);
|
||||
const setSwipeMode = useSettingsStore((s) => s.setSwipeMode);
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
|
||||
@@ -32,6 +39,19 @@ export function LayoutSettings() {
|
||||
return (
|
||||
<SettingsSection title="Layout" description="Tune the email list interactions for mobile.">
|
||||
<View style={{ gap: spacing.sm }}>
|
||||
<Text style={styles.rowLabel}>Swipe behavior</Text>
|
||||
<Text style={styles.rowDescription}>
|
||||
Pick instant (swipe past the threshold to fire the action) or reveal
|
||||
(swipe to expose an action band, then tap to confirm).
|
||||
</Text>
|
||||
<RadioGroup
|
||||
value={swipeMode}
|
||||
onChange={(v) => setSwipeMode(v as SwipeMode)}
|
||||
options={SWIPE_MODE_OPTIONS}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={{ gap: spacing.sm, marginTop: spacing.lg }}>
|
||||
<View style={styles.row}>
|
||||
<ArrowRight size={14} color={c.mutedForeground} />
|
||||
<Text style={styles.rowLabel}>Swipe right (left → right)</Text>
|
||||
@@ -61,10 +81,6 @@ export function LayoutSettings() {
|
||||
/>
|
||||
</View>
|
||||
|
||||
<SettingItem
|
||||
label=""
|
||||
description="Swipe past the threshold and release to fire the action; release before to cancel."
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-s
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useAuthStore } from '../../stores/auth-store';
|
||||
import { useSettingsStore, type NotificationSound } from '../../stores/settings-store';
|
||||
import {
|
||||
DEFAULT_RELAY_BASE_URL,
|
||||
getStoredRelayBaseUrl,
|
||||
@@ -21,7 +22,7 @@ import {
|
||||
teardownPushNotifications,
|
||||
} from '../../lib/push-notifications';
|
||||
|
||||
const SOUNDS = [
|
||||
const SOUNDS: { value: NotificationSound; label: string }[] = [
|
||||
{ value: 'default', label: 'Default' },
|
||||
{ value: 'chime', label: 'Chime' },
|
||||
{ value: 'ping', label: 'Ping' },
|
||||
@@ -40,18 +41,25 @@ export function NotificationSettings() {
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const username = useAuthStore((s) => s.username);
|
||||
|
||||
const [sound, setSound] = useState('default');
|
||||
const [emailEnabled, setEmailEnabled] = useState(true);
|
||||
const [emailSound, setEmailSound] = useState(true);
|
||||
const [calEnabled, setCalEnabled] = useState(true);
|
||||
const [calSound, setCalSound] = useState(true);
|
||||
const [invitationParsing, setInvitationParsing] = useState(true);
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
const update = useSettingsStore((s) => s.updateSetting);
|
||||
const sound = useSettingsStore((s) => s.notificationSoundChoice);
|
||||
const emailEnabled = useSettingsStore((s) => s.emailNotificationsEnabled);
|
||||
const emailSound = useSettingsStore((s) => s.emailNotificationSound);
|
||||
const calEnabled = useSettingsStore((s) => s.calendarNotificationsEnabled);
|
||||
const calSound = useSettingsStore((s) => s.calendarNotificationSound);
|
||||
const invitationParsing = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
|
||||
|
||||
const [relayUrl, setRelayUrl] = useState(DEFAULT_RELAY_BASE_URL);
|
||||
const [hasSaved, setHasSaved] = useState(false);
|
||||
const [pushStatus, setPushStatus] = useState<PushStatus>({ kind: 'idle' });
|
||||
const [confirmDisable, setConfirmDisable] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) void hydrate();
|
||||
}, [hydrated, hydrate]);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const stored = await getStoredRelayBaseUrl();
|
||||
@@ -190,7 +198,7 @@ export function NotificationSettings() {
|
||||
/>
|
||||
<Select
|
||||
value={sound}
|
||||
onChange={setSound}
|
||||
onChange={(v) => update('notificationSoundChoice', v as NotificationSound)}
|
||||
options={SOUNDS}
|
||||
/>
|
||||
</View>
|
||||
@@ -199,22 +207,33 @@ export function NotificationSettings() {
|
||||
|
||||
<SettingsSection title="Email" description="Notifications for incoming email.">
|
||||
<SettingItem label="Email Notifications" description="Show desktop alerts for new email.">
|
||||
<ToggleSwitch checked={emailEnabled} onChange={setEmailEnabled} />
|
||||
<ToggleSwitch checked={emailEnabled} onChange={(v) => update('emailNotificationsEnabled', v)} />
|
||||
</SettingItem>
|
||||
<SettingItem label="Email Sound" description="Play a sound when new email arrives.">
|
||||
<ToggleSwitch checked={emailSound} onChange={setEmailSound} disabled={!emailEnabled} />
|
||||
<ToggleSwitch
|
||||
checked={emailSound}
|
||||
onChange={(v) => update('emailNotificationSound', v)}
|
||||
disabled={!emailEnabled}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Calendar" description="Notifications for events and invitations.">
|
||||
<SettingItem label="Calendar Notifications" description="Show alerts for upcoming events.">
|
||||
<ToggleSwitch checked={calEnabled} onChange={setCalEnabled} />
|
||||
<ToggleSwitch checked={calEnabled} onChange={(v) => update('calendarNotificationsEnabled', v)} />
|
||||
</SettingItem>
|
||||
<SettingItem label="Calendar Sound" description="Play a sound for calendar alerts.">
|
||||
<ToggleSwitch checked={calSound} onChange={setCalSound} disabled={!calEnabled} />
|
||||
<ToggleSwitch
|
||||
checked={calSound}
|
||||
onChange={(v) => update('calendarNotificationSound', v)}
|
||||
disabled={!calEnabled}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label="Invitation Parsing" description="Automatically detect and parse event invitations.">
|
||||
<ToggleSwitch checked={invitationParsing} onChange={setInvitationParsing} />
|
||||
<ToggleSwitch
|
||||
checked={invitationParsing}
|
||||
onChange={(v) => update('calendarInvitationParsingEnabled', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { Upload, Puzzle, AlertTriangle, Lock, Trash2, ChevronDown, ChevronRight } from 'lucide-react-native';
|
||||
import { Upload, Puzzle, AlertTriangle, Lock, Trash2 } from 'lucide-react-native';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
|
||||
interface Plugin {
|
||||
id: string;
|
||||
@@ -33,21 +34,43 @@ function statusStyle(c: ThemePalette, status: Plugin['status']): { bg: string; f
|
||||
export function PluginsSettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
const enabledMap = useSettingsStore((s) => s.pluginEnabled);
|
||||
const setPluginEnabled = useSettingsStore((s) => s.setPluginEnabled);
|
||||
const [plugins, setPlugins] = useState<Plugin[]>([]);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) void hydrate();
|
||||
}, [hydrated, hydrate]);
|
||||
|
||||
const togglePlugin = (id: string) => {
|
||||
setPlugins((ps) => ps.map((p) => {
|
||||
if (p.id !== id) return p;
|
||||
const enabled = !p.enabled;
|
||||
return { ...p, enabled, status: enabled ? 'enabled' : 'disabled' };
|
||||
}));
|
||||
const current = plugins.find((p) => p.id === id);
|
||||
if (!current) return;
|
||||
const next = !current.enabled;
|
||||
setPluginEnabled(id, next);
|
||||
setPlugins((ps) => ps.map((p) =>
|
||||
p.id === id ? { ...p, enabled: next, status: next ? 'enabled' : 'disabled' } : p,
|
||||
));
|
||||
};
|
||||
|
||||
const uninstall = (id: string) => {
|
||||
setPlugins((ps) => ps.filter((p) => p.id !== id));
|
||||
};
|
||||
|
||||
// When plugins are populated from a real source later, hydrate their enable
|
||||
// state from the persisted map.
|
||||
useEffect(() => {
|
||||
if (plugins.length === 0) return;
|
||||
setPlugins((ps) => ps.map((p) => {
|
||||
const stored = enabledMap[p.id];
|
||||
if (stored === undefined || stored === p.enabled) return p;
|
||||
return { ...p, enabled: stored, status: stored ? 'enabled' : 'disabled' };
|
||||
}));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabledMap, plugins.length]);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title="Plugins"
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
|
||||
import { AlertTriangle, FolderSync, Mail, X } from 'lucide-react-native';
|
||||
import { AlertTriangle, FolderSync, X } from 'lucide-react-native';
|
||||
import { SettingsSection, SettingItem, Select, RadioGroup, ToggleSwitch } from './settings-section';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSettingsStore, type ArchiveMode, type ExternalContentPolicy } from '../../stores/settings-store';
|
||||
import {
|
||||
useSettingsStore,
|
||||
type ArchiveMode,
|
||||
type DeleteAction,
|
||||
type MailLayout,
|
||||
type MailAttachmentAction,
|
||||
type AttachmentPosition,
|
||||
} from '../../stores/settings-store';
|
||||
import { useEmailStore } from '../../stores/email-store';
|
||||
import { archiveEmails, queryEmails, getEmails } from '../../api/email';
|
||||
|
||||
type MailLayout = 'split' | 'focus';
|
||||
type DeleteAction = 'trash' | 'permanent';
|
||||
type AttachmentAction = 'preview' | 'download';
|
||||
type AttachmentPosition = 'beside-sender' | 'below-header';
|
||||
|
||||
const DEFAULT_KEYWORDS = ['attached', 'attachment', 'enclosed', 'attaching'];
|
||||
|
||||
function MailLayoutPreview({ value }: { value: MailLayout }) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
@@ -72,35 +72,29 @@ function MailLayoutPreview({ value }: { value: MailLayout }) {
|
||||
export function ReadingSettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
|
||||
const setExternalContentPolicyStore = useSettingsStore((s) => s.setExternalContentPolicy);
|
||||
const trustedSenders = useSettingsStore((s) => s.trustedSenders);
|
||||
const removeTrustedSender = useSettingsStore((s) => s.removeTrustedSender);
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
const archiveMode = useSettingsStore((s) => s.archiveMode);
|
||||
const setArchiveMode = useSettingsStore((s) => s.setArchiveMode);
|
||||
const update = useSettingsStore((s) => s.updateSetting);
|
||||
|
||||
const [markAsReadDelay, setMarkAsReadDelay] = useState('0');
|
||||
const [deleteAction, setDeleteAction] = useState<DeleteAction>('trash');
|
||||
const [permanentlyDeleteJunk, setPermanentlyDeleteJunk] = useState(false);
|
||||
const [showPreview, setShowPreview] = useState(true);
|
||||
const [mailLayout, setMailLayout] = useState<MailLayout>('split');
|
||||
const [disableThreading, setDisableThreading] = useState(false);
|
||||
const [autoSelectReplyIdentity, setAutoSelectReplyIdentity] = useState(true);
|
||||
const [plainTextMode, setPlainTextMode] = useState(false);
|
||||
const [emailsPerPage, setEmailsPerPage] = useState('25');
|
||||
const [mailAttachmentAction, setMailAttachmentAction] = useState<AttachmentAction>('preview');
|
||||
const [attachmentPosition, setAttachmentPosition] = useState<AttachmentPosition>('beside-sender');
|
||||
const [emailAlwaysLightMode, setEmailAlwaysLightMode] = useState(false);
|
||||
const archiveMode = useSettingsStore((s) => s.archiveMode);
|
||||
const markAsReadDelay = useSettingsStore((s) => s.markAsReadDelay);
|
||||
const deleteAction = useSettingsStore((s) => s.deleteAction);
|
||||
const permanentlyDeleteJunk = useSettingsStore((s) => s.permanentlyDeleteJunk);
|
||||
const showPreview = useSettingsStore((s) => s.showPreview);
|
||||
const mailLayout = useSettingsStore((s) => s.mailLayout);
|
||||
const disableThreading = useSettingsStore((s) => s.disableThreading);
|
||||
const autoSelectReplyIdentity = useSettingsStore((s) => s.autoSelectReplyIdentity);
|
||||
const plainTextMode = useSettingsStore((s) => s.plainTextMode);
|
||||
const emailsPerPage = useSettingsStore((s) => s.emailsPerPage);
|
||||
const mailAttachmentAction = useSettingsStore((s) => s.mailAttachmentAction);
|
||||
const attachmentPosition = useSettingsStore((s) => s.attachmentPosition);
|
||||
const attachmentReminderEnabled = useSettingsStore((s) => s.attachmentReminderEnabled);
|
||||
const attachmentReminderKeywords = useSettingsStore((s) => s.attachmentReminderKeywords);
|
||||
const hideInlineImageAttachments = useSettingsStore((s) => s.hideInlineImageAttachments);
|
||||
|
||||
const [newKeyword, setNewKeyword] = useState('');
|
||||
const [reorganizing, setReorganizing] = useState(false);
|
||||
const [reorganizeResult, setReorganizeResult] = useState<string | null>(null);
|
||||
const [attachmentReminderEnabled, setAttachmentReminderEnabled] = useState(true);
|
||||
const [attachmentReminderKeywords, setAttachmentReminderKeywords] = useState<string[]>(DEFAULT_KEYWORDS);
|
||||
const [newKeyword, setNewKeyword] = useState('');
|
||||
const [hideInlineImageAttachments, setHideInlineImageAttachments] = useState(true);
|
||||
const [trustedSendersAddressBook, setTrustedSendersAddressBook] = useState(false);
|
||||
const [showTrustedList, setShowTrustedList] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) void hydrate();
|
||||
@@ -165,8 +159,8 @@ export function ReadingSettings() {
|
||||
<SettingsSection title="Email Behavior" description="How your inbox behaves day to day.">
|
||||
<SettingItem label="Mark as Read" description="When to flag an email as read.">
|
||||
<Select
|
||||
value={markAsReadDelay}
|
||||
onChange={setMarkAsReadDelay}
|
||||
value={String(markAsReadDelay)}
|
||||
onChange={(v) => update('markAsReadDelay', Number(v))}
|
||||
options={[
|
||||
{ value: '0', label: 'Instant' },
|
||||
{ value: '3000', label: 'After 3s' },
|
||||
@@ -180,7 +174,7 @@ export function ReadingSettings() {
|
||||
<SettingItem label="Delete Action" description="Where deleted emails go." noBorder />
|
||||
<Select
|
||||
value={deleteAction}
|
||||
onChange={(v) => setDeleteAction(v as DeleteAction)}
|
||||
onChange={(v) => update('deleteAction', v as DeleteAction)}
|
||||
options={[
|
||||
{ value: 'trash', label: 'Move to Trash' },
|
||||
{ value: 'permanent', label: 'Permanently delete' },
|
||||
@@ -199,7 +193,7 @@ export function ReadingSettings() {
|
||||
<SettingItem label="Archive Mode" description="Organize archive into subfolders." noBorder />
|
||||
<Select
|
||||
value={archiveMode}
|
||||
onChange={(v) => setArchiveMode(v as ArchiveMode)}
|
||||
onChange={(v) => update('archiveMode', v as ArchiveMode)}
|
||||
options={[
|
||||
{ value: 'single', label: 'Single folder' },
|
||||
{ value: 'year', label: 'By year' },
|
||||
@@ -231,14 +225,14 @@ export function ReadingSettings() {
|
||||
</View>
|
||||
|
||||
<SettingItem label="Permanently Delete Junk" description="Skip the trash when deleting spam.">
|
||||
<ToggleSwitch checked={permanentlyDeleteJunk} onChange={setPermanentlyDeleteJunk} />
|
||||
<ToggleSwitch checked={permanentlyDeleteJunk} onChange={(v) => update('permanentlyDeleteJunk', v)} />
|
||||
</SettingItem>
|
||||
|
||||
<View style={styles.group}>
|
||||
<SettingItem label="Mail Layout" description="Choose between split view and focus view." noBorder />
|
||||
<RadioGroup
|
||||
value={mailLayout}
|
||||
onChange={(v) => setMailLayout(v as MailLayout)}
|
||||
onChange={(v) => update('mailLayout', v as MailLayout)}
|
||||
options={[
|
||||
{ value: 'split', label: 'Split' },
|
||||
{ value: 'focus', label: 'Focus' },
|
||||
@@ -249,25 +243,25 @@ export function ReadingSettings() {
|
||||
</View>
|
||||
|
||||
<SettingItem label="Show Preview" description="Preview text under each subject.">
|
||||
<ToggleSwitch checked={showPreview} onChange={setShowPreview} />
|
||||
<ToggleSwitch checked={showPreview} onChange={(v) => update('showPreview', v)} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label="Disable Thread Grouping" description="Show emails individually instead of threaded.">
|
||||
<ToggleSwitch checked={disableThreading} onChange={setDisableThreading} />
|
||||
<ToggleSwitch checked={disableThreading} onChange={(v) => update('disableThreading', v)} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label="Plain Text Mode" description="Compose and read in plain text only.">
|
||||
<ToggleSwitch checked={plainTextMode} onChange={setPlainTextMode} />
|
||||
<ToggleSwitch checked={plainTextMode} onChange={(v) => update('plainTextMode', v)} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label="Auto-select Reply Identity" description="Pick the best identity when replying.">
|
||||
<ToggleSwitch checked={autoSelectReplyIdentity} onChange={setAutoSelectReplyIdentity} />
|
||||
<ToggleSwitch checked={autoSelectReplyIdentity} onChange={(v) => update('autoSelectReplyIdentity', v)} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label="Attachment Reminder" description="Warn when the word 'attached' is present but no file is attached.">
|
||||
<ToggleSwitch
|
||||
checked={attachmentReminderEnabled}
|
||||
onChange={setAttachmentReminderEnabled}
|
||||
onChange={(v) => update('attachmentReminderEnabled', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
@@ -281,7 +275,10 @@ export function ReadingSettings() {
|
||||
<Text style={styles.chipText}>{kw}</Text>
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
setAttachmentReminderKeywords((list) => list.filter((k) => k !== kw))
|
||||
update(
|
||||
'attachmentReminderKeywords',
|
||||
attachmentReminderKeywords.filter((k) => k !== kw),
|
||||
)
|
||||
}
|
||||
hitSlop={6}
|
||||
>
|
||||
@@ -300,7 +297,7 @@ export function ReadingSettings() {
|
||||
onSubmitEditing={() => {
|
||||
const t = newKeyword.trim().toLowerCase();
|
||||
if (t && !attachmentReminderKeywords.includes(t)) {
|
||||
setAttachmentReminderKeywords([...attachmentReminderKeywords, t]);
|
||||
update('attachmentReminderKeywords', [...attachmentReminderKeywords, t]);
|
||||
}
|
||||
setNewKeyword('');
|
||||
}}
|
||||
@@ -310,7 +307,7 @@ export function ReadingSettings() {
|
||||
onPress={() => {
|
||||
const t = newKeyword.trim().toLowerCase();
|
||||
if (t && !attachmentReminderKeywords.includes(t)) {
|
||||
setAttachmentReminderKeywords([...attachmentReminderKeywords, t]);
|
||||
update('attachmentReminderKeywords', [...attachmentReminderKeywords, t]);
|
||||
}
|
||||
setNewKeyword('');
|
||||
}}
|
||||
@@ -323,13 +320,13 @@ export function ReadingSettings() {
|
||||
)}
|
||||
|
||||
<SettingItem label="Hide Inline Image Attachments" description="Don't list inline images in the attachment list.">
|
||||
<ToggleSwitch checked={hideInlineImageAttachments} onChange={setHideInlineImageAttachments} />
|
||||
<ToggleSwitch checked={hideInlineImageAttachments} onChange={(v) => update('hideInlineImageAttachments', v)} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label="Attachment Click Action" description="What happens when you tap an attachment.">
|
||||
<Select
|
||||
value={mailAttachmentAction}
|
||||
onChange={(v) => setMailAttachmentAction(v as AttachmentAction)}
|
||||
onChange={(v) => update('mailAttachmentAction', v as MailAttachmentAction)}
|
||||
options={[
|
||||
{ value: 'preview', label: 'Preview' },
|
||||
{ value: 'download', label: 'Download' },
|
||||
@@ -340,7 +337,7 @@ export function ReadingSettings() {
|
||||
<SettingItem label="Attachment Position" description="Where attachments appear in messages.">
|
||||
<Select
|
||||
value={attachmentPosition}
|
||||
onChange={(v) => setAttachmentPosition(v as AttachmentPosition)}
|
||||
onChange={(v) => update('attachmentPosition', v as AttachmentPosition)}
|
||||
options={[
|
||||
{ value: 'beside-sender', label: 'Beside sender' },
|
||||
{ value: 'below-header', label: 'Below header' },
|
||||
@@ -350,8 +347,8 @@ export function ReadingSettings() {
|
||||
|
||||
<SettingItem label="Emails Per Page" description="How many emails to load at a time.">
|
||||
<Select
|
||||
value={emailsPerPage}
|
||||
onChange={setEmailsPerPage}
|
||||
value={String(emailsPerPage)}
|
||||
onChange={(v) => update('emailsPerPage', Number(v))}
|
||||
options={[
|
||||
{ value: '10', label: '10' },
|
||||
{ value: '25', label: '25' },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
|
||||
import {
|
||||
Plus, Pencil, Trash2, Globe, ExternalLink, PanelRight, GripVertical,
|
||||
@@ -7,43 +7,46 @@ import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
|
||||
interface SidebarApp {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
icon: string;
|
||||
openMode: 'tab' | 'inline';
|
||||
showOnMobile: boolean;
|
||||
}
|
||||
import { useSettingsStore, type SidebarApp } from '../../stores/settings-store';
|
||||
|
||||
export function SidebarAppsSettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const [keepLoaded, setKeepLoaded] = useState(false);
|
||||
const [apps, setApps] = useState<SidebarApp[]>([]);
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
const update = useSettingsStore((s) => s.updateSetting);
|
||||
const apps = useSettingsStore((s) => s.sidebarApps);
|
||||
const keepLoaded = useSettingsStore((s) => s.keepAppsLoaded);
|
||||
const addSidebarApp = useSettingsStore((s) => s.addSidebarApp);
|
||||
const updateSidebarApp = useSettingsStore((s) => s.updateSidebarApp);
|
||||
const removeSidebarApp = useSettingsStore((s) => s.removeSidebarApp);
|
||||
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) void hydrate();
|
||||
}, [hydrated, hydrate]);
|
||||
|
||||
const addApp = (data: Omit<SidebarApp, 'id'>) => {
|
||||
setApps((list) => [...list, { ...data, id: `app-${Math.random().toString(36).slice(2)}` }]);
|
||||
addSidebarApp(data);
|
||||
setIsAdding(false);
|
||||
};
|
||||
|
||||
const updateApp = (id: string, data: Omit<SidebarApp, 'id'>) => {
|
||||
setApps((list) => list.map((a) => a.id === id ? { ...data, id } : a));
|
||||
updateSidebarApp(id, data);
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const removeApp = (id: string) => {
|
||||
setApps((list) => list.filter((a) => a.id !== id));
|
||||
removeSidebarApp(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SettingsSection title="Sidebar Apps" description="Embedded web apps accessible from the sidebar.">
|
||||
<SettingItem label="Keep apps loaded" description="Maintain app state when switching away.">
|
||||
<ToggleSwitch checked={keepLoaded} onChange={setKeepLoaded} />
|
||||
<ToggleSwitch checked={keepLoaded} onChange={(v) => update('keepAppsLoaded', v)} />
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import {
|
||||
Upload, Trash2, Eye, Lock, Unlock, Download,
|
||||
ShieldCheck, ShieldAlert, Users,
|
||||
} from 'lucide-react-native';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
|
||||
interface KeyRecord {
|
||||
id: string;
|
||||
@@ -34,12 +35,18 @@ const MOCK_CERTS: PublicCert[] = [];
|
||||
export function SmimeSettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
const update = useSettingsStore((s) => s.updateSetting);
|
||||
const defaultEncrypt = useSettingsStore((s) => s.smimeDefaultEncrypt);
|
||||
const rememberUnlocked = useSettingsStore((s) => s.smimeRememberUnlocked);
|
||||
const autoImport = useSettingsStore((s) => s.smimeAutoImport);
|
||||
const [keys, setKeys] = useState<KeyRecord[]>(MOCK_KEYS);
|
||||
const [certs, setCerts] = useState<PublicCert[]>(MOCK_CERTS);
|
||||
const [bindings, setBindings] = useState<Record<string, string>>({});
|
||||
const [defaultEncrypt, setDefaultEncrypt] = useState(false);
|
||||
const [rememberUnlocked, setRememberUnlocked] = useState(false);
|
||||
const [autoImport, setAutoImport] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) void hydrate();
|
||||
}, [hydrated, hydrate]);
|
||||
|
||||
const isExpired = (d: string) => new Date(d) < new Date();
|
||||
const formatDate = (d: string) => {
|
||||
@@ -167,21 +174,21 @@ export function SmimeSettings() {
|
||||
label="Encrypt by default"
|
||||
description="Encrypt new messages when recipient certificates are available."
|
||||
>
|
||||
<ToggleSwitch checked={defaultEncrypt} onChange={setDefaultEncrypt} />
|
||||
<ToggleSwitch checked={defaultEncrypt} onChange={(v) => update('smimeDefaultEncrypt', v)} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Remember unlocked keys"
|
||||
description="Keep private keys unlocked across the session."
|
||||
>
|
||||
<ToggleSwitch checked={rememberUnlocked} onChange={setRememberUnlocked} />
|
||||
<ToggleSwitch checked={rememberUnlocked} onChange={(v) => update('smimeRememberUnlocked', v)} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Auto-import signer certs"
|
||||
description="Save public certificates from signed messages."
|
||||
>
|
||||
<ToggleSwitch checked={autoImport} onChange={setAutoImport} />
|
||||
<ToggleSwitch checked={autoImport} onChange={(v) => update('smimeAutoImport', v)} />
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
</View>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { Upload, Check, Palette, Trash2 } from 'lucide-react-native';
|
||||
import { Upload, Check, Palette } from 'lucide-react-native';
|
||||
import { SettingsSection, SettingItem } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
|
||||
interface Theme {
|
||||
id: string | null;
|
||||
@@ -23,8 +24,15 @@ const BUILT_IN: Theme[] = [
|
||||
export function ThemesSettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
const active = useSettingsStore((s) => s.activeThemeId);
|
||||
const update = useSettingsStore((s) => s.updateSetting);
|
||||
const [themes] = useState<Theme[]>(BUILT_IN);
|
||||
const [active, setActive] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) void hydrate();
|
||||
}, [hydrated, hydrate]);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
@@ -39,7 +47,7 @@ export function ThemesSettings() {
|
||||
return (
|
||||
<Pressable
|
||||
key={theme.id ?? 'default'}
|
||||
onPress={() => setActive(theme.id)}
|
||||
onPress={() => update('activeThemeId', theme.id)}
|
||||
style={[styles.card, isActive && styles.cardActive]}
|
||||
>
|
||||
<View style={styles.preview}>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, TextInput, Pressable, ScrollView,
|
||||
KeyboardAvoidingView, Platform, ActivityIndicator, Alert, Modal,
|
||||
Keyboard, Dimensions, Platform, ActivityIndicator, Alert, Modal,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import {
|
||||
X, Send, Paperclip, ChevronDown, Bold, Italic, Underline, Strikethrough,
|
||||
@@ -209,6 +209,39 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const insets = useSafeAreaInsets();
|
||||
// Track the visible keyboard obstruction so the format bar stays above it.
|
||||
// On Android edge-to-edge, the IME-inset reported by `keyboardDidShow` is
|
||||
// measured from the top of the gesture-nav bar rather than from the true
|
||||
// screen bottom, so we derive obstruction height from `Dimensions.screen`
|
||||
// instead of trusting `endCoordinates.height` directly. The same trick
|
||||
// works on iOS for the QuickType / autofill / dictation strip.
|
||||
const [kbObstruction, setKbObstruction] = React.useState(0);
|
||||
React.useEffect(() => {
|
||||
const recompute = (endY: number) => {
|
||||
const screenH = Dimensions.get('screen').height;
|
||||
setKbObstruction(Math.max(0, screenH - endY));
|
||||
};
|
||||
const subs =
|
||||
Platform.OS === 'ios'
|
||||
? [
|
||||
Keyboard.addListener('keyboardWillChangeFrame', (e) => {
|
||||
recompute(e.endCoordinates?.screenY ?? Number.MAX_SAFE_INTEGER);
|
||||
}),
|
||||
]
|
||||
: [
|
||||
Keyboard.addListener('keyboardDidShow', (e) => {
|
||||
recompute(e.endCoordinates?.screenY ?? Number.MAX_SAFE_INTEGER);
|
||||
}),
|
||||
Keyboard.addListener('keyboardDidHide', () => setKbObstruction(0)),
|
||||
];
|
||||
return () => {
|
||||
for (const s of subs) s.remove();
|
||||
};
|
||||
}, []);
|
||||
// When the keyboard is up it covers the bottom safe area, so we don't
|
||||
// need to add it on top — pad by whichever is larger.
|
||||
const bottomPad = Math.max(kbObstruction, insets.bottom);
|
||||
const replyTo = route.params?.replyTo;
|
||||
const mode = route.params?.mode ?? 'compose';
|
||||
const prefillTo = route.params?.prefillTo;
|
||||
@@ -703,7 +736,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
: 'email_composer.new_message';
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={onClose} style={styles.headerBtn}>
|
||||
<X size={22} color={c.text} />
|
||||
@@ -741,10 +774,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<KeyboardAvoidingView
|
||||
style={styles.flex}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<View style={[styles.flex, { paddingBottom: bottomPad }]}>
|
||||
<ScrollView
|
||||
style={styles.flex}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
@@ -922,7 +952,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
<ToolbarButton onPress={() => editorRef.current?.exec('redo')}
|
||||
icon={<Redo2 size={18} color={c.textSecondary} />} />
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
|
||||
<Modal
|
||||
visible={linkPromptVisible}
|
||||
|
||||
@@ -177,6 +177,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
|
||||
const swipeLeftAction = useSettingsStore((s) => s.swipeLeftAction);
|
||||
const swipeRightAction = useSettingsStore((s) => s.swipeRightAction);
|
||||
const swipeMode = useSettingsStore((s) => s.swipeMode);
|
||||
const networkOnline = useNetworkStore((s) => s.online);
|
||||
|
||||
const archiveMailboxId = React.useMemo(
|
||||
@@ -274,6 +275,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
<SwipeableRow
|
||||
leftAction={selectionMode ? 'none' : swipeLeftAction}
|
||||
rightAction={selectionMode ? 'none' : swipeRightAction}
|
||||
mode={swipeMode}
|
||||
context={{ unread: isUnread(item), starred: isStarred(item), pinned: isPinned(item) }}
|
||||
onAction={(action) => handleSwipeAction(item.id, action)}
|
||||
>
|
||||
@@ -286,7 +288,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
/>
|
||||
</SwipeableRow>
|
||||
),
|
||||
[selectedIds, selectionMode, handleRowPress, toggleSelect, swipeLeftAction, swipeRightAction, handleSwipeAction],
|
||||
[selectedIds, selectionMode, handleRowPress, toggleSelect, swipeLeftAction, swipeRightAction, swipeMode, handleSwipeAction],
|
||||
);
|
||||
|
||||
const clearSelection = React.useCallback(() => {
|
||||
|
||||
+232
-40
@@ -7,9 +7,10 @@ export type ExternalContentPolicy = 'allow' | 'block' | 'ask';
|
||||
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||
export type FontSize = 'small' | 'medium' | 'large';
|
||||
export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
|
||||
|
||||
const STORAGE_KEY = 'webmail:settings:v1';
|
||||
|
||||
export type DeleteAction = 'trash' | 'permanent';
|
||||
export type MailLayout = 'split' | 'focus';
|
||||
export type MailAttachmentAction = 'preview' | 'download';
|
||||
export type AttachmentPosition = 'beside-sender' | 'below-header';
|
||||
export type SwipeAction =
|
||||
| 'none'
|
||||
| 'archive'
|
||||
@@ -19,13 +20,40 @@ export type SwipeAction =
|
||||
| 'star'
|
||||
| 'pin'
|
||||
| 'move';
|
||||
export type SwipeMode = 'instant' | 'reveal';
|
||||
export type ArchiveMode = 'single' | 'year' | 'month';
|
||||
export type CalendarView = 'month' | 'week' | 'day' | 'agenda';
|
||||
export type FirstDayOfWeek = 0 | 1;
|
||||
export type TimeFormat = '12h' | '24h';
|
||||
export type CalendarHoverPreview = 'instant' | 'delay-500ms' | 'delay-1s' | 'delay-2s' | 'off';
|
||||
export type FilesFolderLayout = 'inline' | 'sidebar';
|
||||
export type FilesViewMode = 'list' | 'grid';
|
||||
export type FilesSortKey = 'name' | 'size' | 'modified';
|
||||
export type FilesSortDir = 'asc' | 'desc';
|
||||
export type NotificationSound = 'default' | 'chime' | 'ping' | 'pop' | 'none';
|
||||
|
||||
const STORAGE_KEY = 'webmail:settings:v1';
|
||||
|
||||
export interface SidebarApp {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
icon: string;
|
||||
openMode: 'tab' | 'inline';
|
||||
showOnMobile: boolean;
|
||||
}
|
||||
|
||||
interface PersistedSettings {
|
||||
// Privacy & content
|
||||
externalContentPolicy: ExternalContentPolicy;
|
||||
trustedSenders: string[];
|
||||
trustedSendersAddressBook: boolean;
|
||||
senderFavicons: boolean;
|
||||
hideInlineImageAttachments: boolean;
|
||||
|
||||
// Contacts
|
||||
groupContactsByLetter: boolean;
|
||||
|
||||
// Appearance
|
||||
theme: ThemeMode;
|
||||
fontSize: FontSize;
|
||||
@@ -33,34 +61,152 @@ interface PersistedSettings {
|
||||
showToolbarLabels: boolean;
|
||||
animationsEnabled: boolean;
|
||||
emailAlwaysLightMode: boolean;
|
||||
activeThemeId: string | null;
|
||||
|
||||
// Composing
|
||||
autoSelectReplyIdentity: boolean;
|
||||
attachmentReminderEnabled: boolean;
|
||||
attachmentReminderKeywords: string[];
|
||||
plainTextMode: boolean;
|
||||
|
||||
// Reading
|
||||
markAsReadDelay: number;
|
||||
deleteAction: DeleteAction;
|
||||
permanentlyDeleteJunk: boolean;
|
||||
showPreview: boolean;
|
||||
mailLayout: MailLayout;
|
||||
emailsPerPage: number;
|
||||
disableThreading: boolean;
|
||||
mailAttachmentAction: MailAttachmentAction;
|
||||
attachmentPosition: AttachmentPosition;
|
||||
|
||||
// Layout / list interactions
|
||||
swipeLeftAction: SwipeAction;
|
||||
swipeRightAction: SwipeAction;
|
||||
swipeMode: SwipeMode;
|
||||
|
||||
// Archive
|
||||
archiveMode: ArchiveMode;
|
||||
|
||||
// Calendar
|
||||
calendarDefaultView: CalendarView;
|
||||
calendarFirstDayOfWeek: FirstDayOfWeek;
|
||||
calendarTimeFormat: TimeFormat;
|
||||
calendarShowTimeInMonth: boolean;
|
||||
calendarShowWeekNumbers: boolean;
|
||||
calendarHoverPreview: CalendarHoverPreview;
|
||||
showBirthdayCalendar: boolean;
|
||||
enableCalendarTasks: boolean;
|
||||
showTasksOnCalendar: boolean;
|
||||
|
||||
// Files
|
||||
filesFolderLayout: FilesFolderLayout;
|
||||
filesDefaultViewMode: FilesViewMode;
|
||||
filesDefaultSortKey: FilesSortKey;
|
||||
filesDefaultSortDir: FilesSortDir;
|
||||
filesShowIcons: boolean;
|
||||
filesColoredIcons: boolean;
|
||||
filesShowThumbnails: boolean;
|
||||
filesShowHiddenFiles: boolean;
|
||||
|
||||
// Notifications
|
||||
notificationSoundChoice: NotificationSound;
|
||||
emailNotificationsEnabled: boolean;
|
||||
emailNotificationSound: boolean;
|
||||
calendarNotificationsEnabled: boolean;
|
||||
calendarNotificationSound: boolean;
|
||||
calendarInvitationParsingEnabled: boolean;
|
||||
|
||||
// Sidebar apps
|
||||
sidebarApps: SidebarApp[];
|
||||
keepAppsLoaded: boolean;
|
||||
|
||||
// S/MIME defaults (key/cert lists are server-managed; only UI-level prefs persist)
|
||||
smimeDefaultEncrypt: boolean;
|
||||
smimeRememberUnlocked: boolean;
|
||||
smimeAutoImport: boolean;
|
||||
|
||||
// Filters UI state
|
||||
filtersExpandedView: boolean;
|
||||
|
||||
// Plugins (UI-level enable map)
|
||||
pluginEnabled: Record<string, boolean>;
|
||||
}
|
||||
|
||||
const DEFAULT_PERSISTED: PersistedSettings = {
|
||||
externalContentPolicy: 'ask',
|
||||
trustedSenders: [],
|
||||
trustedSendersAddressBook: false,
|
||||
senderFavicons: true,
|
||||
hideInlineImageAttachments: true,
|
||||
|
||||
groupContactsByLetter: true,
|
||||
|
||||
theme: 'system',
|
||||
fontSize: 'medium',
|
||||
density: 'regular',
|
||||
showToolbarLabels: true,
|
||||
animationsEnabled: true,
|
||||
emailAlwaysLightMode: false,
|
||||
activeThemeId: null,
|
||||
|
||||
autoSelectReplyIdentity: true,
|
||||
attachmentReminderEnabled: true,
|
||||
attachmentReminderKeywords: ['attached', 'attachment', 'attaching', 'enclosed'],
|
||||
plainTextMode: false,
|
||||
|
||||
markAsReadDelay: 0,
|
||||
deleteAction: 'trash',
|
||||
permanentlyDeleteJunk: false,
|
||||
showPreview: true,
|
||||
mailLayout: 'split',
|
||||
emailsPerPage: 25,
|
||||
disableThreading: false,
|
||||
mailAttachmentAction: 'preview',
|
||||
attachmentPosition: 'beside-sender',
|
||||
|
||||
swipeLeftAction: 'archive',
|
||||
swipeRightAction: 'read',
|
||||
swipeMode: 'instant',
|
||||
|
||||
archiveMode: 'single',
|
||||
|
||||
calendarDefaultView: 'month',
|
||||
calendarFirstDayOfWeek: 1,
|
||||
calendarTimeFormat: '24h',
|
||||
calendarShowTimeInMonth: true,
|
||||
calendarShowWeekNumbers: false,
|
||||
calendarHoverPreview: 'delay-500ms',
|
||||
showBirthdayCalendar: true,
|
||||
enableCalendarTasks: false,
|
||||
showTasksOnCalendar: true,
|
||||
|
||||
filesFolderLayout: 'inline',
|
||||
filesDefaultViewMode: 'list',
|
||||
filesDefaultSortKey: 'name',
|
||||
filesDefaultSortDir: 'asc',
|
||||
filesShowIcons: true,
|
||||
filesColoredIcons: true,
|
||||
filesShowThumbnails: true,
|
||||
filesShowHiddenFiles: false,
|
||||
|
||||
notificationSoundChoice: 'default',
|
||||
emailNotificationsEnabled: true,
|
||||
emailNotificationSound: true,
|
||||
calendarNotificationsEnabled: true,
|
||||
calendarNotificationSound: true,
|
||||
calendarInvitationParsingEnabled: true,
|
||||
|
||||
sidebarApps: [],
|
||||
keepAppsLoaded: false,
|
||||
|
||||
smimeDefaultEncrypt: false,
|
||||
smimeRememberUnlocked: false,
|
||||
smimeAutoImport: true,
|
||||
|
||||
filtersExpandedView: false,
|
||||
|
||||
pluginEnabled: {},
|
||||
};
|
||||
|
||||
export interface SettingsState extends PersistedSettings {
|
||||
@@ -71,6 +217,14 @@ export interface SettingsState extends PersistedSettings {
|
||||
|
||||
fetchIdentities: () => Promise<void>;
|
||||
hydrate: () => Promise<void>;
|
||||
|
||||
// Generic setter — preferred for new code.
|
||||
updateSetting: <K extends keyof PersistedSettings>(
|
||||
key: K,
|
||||
value: PersistedSettings[K],
|
||||
) => void;
|
||||
|
||||
// Legacy named setters — preserved so existing call sites keep working.
|
||||
setExternalContentPolicy: (policy: ExternalContentPolicy) => void;
|
||||
setSenderFavicons: (enabled: boolean) => void;
|
||||
setGroupContactsByLetter: (enabled: boolean) => void;
|
||||
@@ -85,32 +239,36 @@ export interface SettingsState extends PersistedSettings {
|
||||
setAttachmentReminderKeywords: (keywords: string[]) => void;
|
||||
setSwipeLeftAction: (action: SwipeAction) => void;
|
||||
setSwipeRightAction: (action: SwipeAction) => void;
|
||||
setSwipeMode: (mode: SwipeMode) => void;
|
||||
setArchiveMode: (mode: ArchiveMode) => void;
|
||||
|
||||
// Trusted senders
|
||||
addTrustedSender: (email: string) => void;
|
||||
removeTrustedSender: (email: string) => void;
|
||||
isSenderTrusted: (email: string) => boolean;
|
||||
|
||||
// Sidebar apps
|
||||
addSidebarApp: (app: Omit<SidebarApp, 'id'>) => void;
|
||||
updateSidebarApp: (id: string, updates: Partial<Omit<SidebarApp, 'id'>>) => void;
|
||||
removeSidebarApp: (id: string) => void;
|
||||
reorderSidebarApps: (apps: SidebarApp[]) => void;
|
||||
|
||||
// Plugins
|
||||
setPluginEnabled: (id: string, enabled: boolean) => void;
|
||||
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const PERSIST_KEYS: (keyof PersistedSettings)[] = Object.keys(
|
||||
DEFAULT_PERSISTED,
|
||||
) as (keyof PersistedSettings)[];
|
||||
|
||||
function snapshot(state: SettingsState): PersistedSettings {
|
||||
return {
|
||||
externalContentPolicy: state.externalContentPolicy,
|
||||
trustedSenders: state.trustedSenders,
|
||||
senderFavicons: state.senderFavicons,
|
||||
groupContactsByLetter: state.groupContactsByLetter,
|
||||
theme: state.theme,
|
||||
fontSize: state.fontSize,
|
||||
density: state.density,
|
||||
showToolbarLabels: state.showToolbarLabels,
|
||||
animationsEnabled: state.animationsEnabled,
|
||||
emailAlwaysLightMode: state.emailAlwaysLightMode,
|
||||
autoSelectReplyIdentity: state.autoSelectReplyIdentity,
|
||||
attachmentReminderEnabled: state.attachmentReminderEnabled,
|
||||
attachmentReminderKeywords: state.attachmentReminderKeywords,
|
||||
swipeLeftAction: state.swipeLeftAction,
|
||||
swipeRightAction: state.swipeRightAction,
|
||||
archiveMode: state.archiveMode,
|
||||
};
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const k of PERSIST_KEYS) {
|
||||
out[k] = state[k];
|
||||
}
|
||||
return out as unknown as PersistedSettings;
|
||||
}
|
||||
|
||||
function persist(state: PersistedSettings): void {
|
||||
@@ -119,6 +277,24 @@ function persist(state: PersistedSettings): void {
|
||||
});
|
||||
}
|
||||
|
||||
function mergeWithDefaults(parsed: Partial<PersistedSettings>): PersistedSettings {
|
||||
const out: Record<string, unknown> = { ...DEFAULT_PERSISTED };
|
||||
for (const k of PERSIST_KEYS) {
|
||||
const v = parsed[k];
|
||||
if (v === undefined || v === null) continue;
|
||||
const def = DEFAULT_PERSISTED[k];
|
||||
// Type-tolerant merge: only adopt when the basic shape matches the default.
|
||||
if (Array.isArray(def)) {
|
||||
if (Array.isArray(v)) out[k] = v;
|
||||
} else if (typeof def === 'object') {
|
||||
if (typeof v === 'object' && !Array.isArray(v)) out[k] = v;
|
||||
} else if (typeof def === typeof v) {
|
||||
out[k] = v;
|
||||
}
|
||||
}
|
||||
return out as unknown as PersistedSettings;
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
...DEFAULT_PERSISTED,
|
||||
identities: [],
|
||||
@@ -142,25 +318,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
const raw = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as Partial<PersistedSettings>;
|
||||
set({
|
||||
externalContentPolicy: parsed.externalContentPolicy ?? DEFAULT_PERSISTED.externalContentPolicy,
|
||||
trustedSenders: Array.isArray(parsed.trustedSenders) ? parsed.trustedSenders : [],
|
||||
senderFavicons: typeof parsed.senderFavicons === 'boolean' ? parsed.senderFavicons : DEFAULT_PERSISTED.senderFavicons,
|
||||
groupContactsByLetter: typeof parsed.groupContactsByLetter === 'boolean' ? parsed.groupContactsByLetter : DEFAULT_PERSISTED.groupContactsByLetter,
|
||||
theme: parsed.theme ?? DEFAULT_PERSISTED.theme,
|
||||
fontSize: parsed.fontSize ?? DEFAULT_PERSISTED.fontSize,
|
||||
density: parsed.density ?? DEFAULT_PERSISTED.density,
|
||||
showToolbarLabels: typeof parsed.showToolbarLabels === 'boolean' ? parsed.showToolbarLabels : DEFAULT_PERSISTED.showToolbarLabels,
|
||||
animationsEnabled: typeof parsed.animationsEnabled === 'boolean' ? parsed.animationsEnabled : DEFAULT_PERSISTED.animationsEnabled,
|
||||
emailAlwaysLightMode: typeof parsed.emailAlwaysLightMode === 'boolean' ? parsed.emailAlwaysLightMode : DEFAULT_PERSISTED.emailAlwaysLightMode,
|
||||
autoSelectReplyIdentity: typeof parsed.autoSelectReplyIdentity === 'boolean' ? parsed.autoSelectReplyIdentity : DEFAULT_PERSISTED.autoSelectReplyIdentity,
|
||||
attachmentReminderEnabled: typeof parsed.attachmentReminderEnabled === 'boolean' ? parsed.attachmentReminderEnabled : DEFAULT_PERSISTED.attachmentReminderEnabled,
|
||||
attachmentReminderKeywords: Array.isArray(parsed.attachmentReminderKeywords) ? parsed.attachmentReminderKeywords : DEFAULT_PERSISTED.attachmentReminderKeywords,
|
||||
swipeLeftAction: parsed.swipeLeftAction ?? DEFAULT_PERSISTED.swipeLeftAction,
|
||||
swipeRightAction: parsed.swipeRightAction ?? DEFAULT_PERSISTED.swipeRightAction,
|
||||
archiveMode: parsed.archiveMode ?? DEFAULT_PERSISTED.archiveMode,
|
||||
hydrated: true,
|
||||
});
|
||||
set({ ...mergeWithDefaults(parsed), hydrated: true });
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -169,6 +327,11 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
set({ hydrated: true });
|
||||
},
|
||||
|
||||
updateSetting: (key, value) => {
|
||||
set({ [key]: value } as Partial<SettingsState>);
|
||||
persist(snapshot(get()));
|
||||
},
|
||||
|
||||
setExternalContentPolicy: (policy) => { set({ externalContentPolicy: policy }); persist(snapshot(get())); },
|
||||
setSenderFavicons: (enabled) => { set({ senderFavicons: enabled }); persist(snapshot(get())); },
|
||||
setGroupContactsByLetter: (enabled) => { set({ groupContactsByLetter: enabled }); persist(snapshot(get())); },
|
||||
@@ -183,6 +346,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
setAttachmentReminderKeywords: (keywords) => { set({ attachmentReminderKeywords: keywords }); persist(snapshot(get())); },
|
||||
setSwipeLeftAction: (action) => { set({ swipeLeftAction: action }); persist(snapshot(get())); },
|
||||
setSwipeRightAction: (action) => { set({ swipeRightAction: action }); persist(snapshot(get())); },
|
||||
setSwipeMode: (mode) => { set({ swipeMode: mode }); persist(snapshot(get())); },
|
||||
setArchiveMode: (mode) => { set({ archiveMode: mode }); persist(snapshot(get())); },
|
||||
|
||||
addTrustedSender: (email) => {
|
||||
@@ -205,6 +369,34 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
return get().trustedSenders.includes(normalized);
|
||||
},
|
||||
|
||||
addSidebarApp: (app) => {
|
||||
const id = `app-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
set({ sidebarApps: [...get().sidebarApps, { ...app, id }] });
|
||||
persist(snapshot(get()));
|
||||
},
|
||||
|
||||
updateSidebarApp: (id, updates) => {
|
||||
set({
|
||||
sidebarApps: get().sidebarApps.map((a) => (a.id === id ? { ...a, ...updates } : a)),
|
||||
});
|
||||
persist(snapshot(get()));
|
||||
},
|
||||
|
||||
removeSidebarApp: (id) => {
|
||||
set({ sidebarApps: get().sidebarApps.filter((a) => a.id !== id) });
|
||||
persist(snapshot(get()));
|
||||
},
|
||||
|
||||
reorderSidebarApps: (apps) => {
|
||||
set({ sidebarApps: apps });
|
||||
persist(snapshot(get()));
|
||||
},
|
||||
|
||||
setPluginEnabled: (id, enabled) => {
|
||||
set({ pluginEnabled: { ...get().pluginEnabled, [id]: enabled } });
|
||||
persist(snapshot(get()));
|
||||
},
|
||||
|
||||
reset: () => set({
|
||||
identities: [],
|
||||
loading: false,
|
||||
|
||||
Reference in New Issue
Block a user