Compare commits

..
11 Commits
28 changed files with 1648 additions and 457 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.12
0.1.16
+2
View File
@@ -450,6 +450,8 @@
"send": "Send",
"cancel": "Cancel",
"attach": "Attach",
"attach_photos": "Photos & Videos",
"attach_files": "Files",
"discard": "Discard",
"discard_draft_title": "Discard draft?",
"discard_draft_confirm": "You have unsaved changes. Do you want to discard this draft?",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.12",
"version": "0.1.16",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.12",
"version": "0.1.16",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.12",
"version": "0.1.16",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
+7
View File
@@ -1,5 +1,12 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('expo-file-system', () => ({
File: class {
constructor(public uri: string) {}
async bytes() { return new Uint8Array(); }
},
}));
vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
+11 -6
View File
@@ -1,3 +1,4 @@
import { File } from 'expo-file-system';
import { jmapClient } from './jmap-client';
export async function uploadBlob(
@@ -7,11 +8,14 @@ export async function uploadBlob(
const session = jmapClient.currentSession;
if (!session) throw new Error('Not connected');
const uploadUrl = session.uploadUrl.replace('{accountId}', jmapClient.accountId);
const uploadUrl = session.uploadUrl.replace(
'{accountId}',
encodeURIComponent(jmapClient.accountId),
);
// Fetch local file as blob
const fileResponse = await fetch(uri);
const blob = await fileResponse.blob();
// Read the source via the file-system API so this works for both `file://`
// (image picker, iOS document picker) and `content://` (Android SAF) URIs.
const bytes = await new File(uri).bytes();
const response = await fetch(uploadUrl, {
method: 'POST',
@@ -19,11 +23,12 @@ export async function uploadBlob(
'Content-Type': type,
Authorization: (jmapClient as any).authHeader,
},
body: blob,
body: bytes as unknown as BodyInit,
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.status}`);
const detail = await response.text().catch(() => '');
throw new Error(`Upload failed: ${response.status}${detail ? ` ${detail}` : ''}`);
}
return response.json();
+139 -1
View File
@@ -1,6 +1,6 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { Email, EmailAddress, Mailbox, Thread } from './types';
import type { Email, EmailAddress, JMAPMethodCall, Mailbox, Thread } from './types';
import { toWildcardQuery } from '../lib/search-utils';
const EMAIL_LIST_PROPERTIES = [
@@ -111,6 +111,144 @@ export async function moveEmail(
]);
}
// Restore each email's mailboxIds to the snapshot supplied. Used by undo to
// reverse a move/archive/spam in one round-trip. JMAP "mailboxIds" replaces
// the entire map, so we don't need to compute a diff against the current state.
export async function restoreEmailMailboxes(
items: Array<{ id: string; mailboxIds: Record<string, boolean> }>,
): Promise<void> {
if (items.length === 0) return;
const accountId = jmapClient.accountId;
const update: Record<string, { mailboxIds: Record<string, true> }> = {};
for (const item of items) {
const onlyTrue: Record<string, true> = {};
for (const [id, present] of Object.entries(item.mailboxIds)) {
if (present) onlyTrue[id] = true;
}
update[item.id] = { mailboxIds: onlyTrue };
}
await jmapClient.request([
['Email/set', { accountId, update }, '0'],
]);
}
// Archive one or more emails into the archive mailbox, optionally auto-sorting
// into year or year/month subfolders. Mirrors the webmail implementation in
// lib/jmap/client.ts so behavior stays in sync between platforms.
export async function archiveEmails(
emails: Array<{ id: string; receivedAt: string }>,
archiveMailboxId: string,
mode: 'single' | 'year' | 'month',
existingMailboxes: Mailbox[],
): Promise<void> {
if (emails.length === 0) return;
const accountId = jmapClient.accountId;
if (mode === 'single') {
const updates = Object.fromEntries(
emails.map((e) => [e.id, { mailboxIds: { [archiveMailboxId]: true } }]),
);
await jmapClient.request([
['Email/set', { accountId, update: updates }, '0'],
]);
return;
}
type Dest = { year: string; month?: string };
const destFor = new Map<string, Dest>();
for (const e of emails) {
const d = new Date(e.receivedAt);
const year = d.getFullYear().toString();
const month = (d.getMonth() + 1).toString().padStart(2, '0');
destFor.set(e.id, mode === 'year' ? { year } : { year, month });
}
// Resolve each destination folder to either an existing id or a creation-id reference ("#<cid>").
const yearIdFor = new Map<string, string>();
const monthIdFor = new Map<string, string>();
const createEntries: Record<string, Record<string, unknown>> = {};
const findExisting = (name: string, parentId: string) =>
existingMailboxes.find(
(m) => m.name === name && m.parentId === parentId,
);
for (const dest of destFor.values()) {
if (!yearIdFor.has(dest.year)) {
const existing = findExisting(dest.year, archiveMailboxId);
if (existing) {
yearIdFor.set(dest.year, existing.id);
} else {
const cid = `year-${dest.year}`;
createEntries[cid] = { name: dest.year, parentId: archiveMailboxId };
yearIdFor.set(dest.year, `#${cid}`);
}
}
if (mode === 'month' && dest.month) {
const monthKey = `${dest.year}/${dest.month}`;
if (!monthIdFor.has(monthKey)) {
const yearRef = yearIdFor.get(dest.year)!;
const existingMonth = yearRef.startsWith('#')
? undefined
: findExisting(dest.month, yearRef);
if (existingMonth) {
monthIdFor.set(monthKey, existingMonth.id);
} else {
const cid = `month-${dest.year}-${dest.month}`;
createEntries[cid] = { name: dest.month, parentId: yearRef };
monthIdFor.set(monthKey, `#${cid}`);
}
}
}
}
const updates: Record<string, { mailboxIds: Record<string, true> }> = {};
for (const [emailId, dest] of destFor.entries()) {
const destId = mode === 'month' && dest.month
? monthIdFor.get(`${dest.year}/${dest.month}`)!
: yearIdFor.get(dest.year)!;
updates[emailId] = { mailboxIds: { [destId]: true } };
}
const methodCalls: JMAPMethodCall[] = [];
const hasCreates = Object.keys(createEntries).length > 0;
if (hasCreates) {
methodCalls.push(['Mailbox/set', { accountId, create: createEntries }, '0']);
}
methodCalls.push(['Email/set', { accountId, update: updates }, String(methodCalls.length)]);
const response = await jmapClient.request(methodCalls);
if (hasCreates) {
const mailboxResult = response.methodResponses?.[0]?.[1];
const notCreated = mailboxResult?.notCreated as
| Record<string, { type?: string; properties?: string[]; description?: string }>
| undefined;
const failures = notCreated ? Object.entries(notCreated) : [];
if (failures.length > 0) {
const [cid, err] = failures[0];
const parts = [err.type || 'unknown'];
if (err.properties?.length) parts.push(`properties=[${err.properties.join(', ')}]`);
if (err.description) parts.push(err.description);
throw new Error(`Failed to create archive folder '${cid}': ${parts.join(' – ')}`);
}
}
const emailIdx = hasCreates ? 1 : 0;
const emailResult = response.methodResponses?.[emailIdx]?.[1];
const notUpdated = emailResult?.notUpdated as
| Record<string, { type?: string; description?: string }>
| undefined;
const emailFailures = notUpdated ? Object.entries(notUpdated) : [];
if (emailFailures.length > 0) {
const [id, err] = emailFailures[0];
throw new Error(
`Failed to archive ${emailFailures.length} email(s), first: ${id} – ${err.type || 'unknown'}${err.description ? ` (${err.description})` : ''}`,
);
}
}
export async function deleteEmail(
emailId: string,
trashMailboxId: string,
+199
View File
@@ -0,0 +1,199 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import {
Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Folder,
X, Check, type LucideIcon,
} from 'lucide-react-native';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree';
import { useSheetDrag } from '../lib/use-sheet-drag';
import type { Mailbox } from '../api/types';
function moveTargetIcon(role: string | null | undefined, name: string): LucideIcon {
const lower = name.toLowerCase();
if (role === 'inbox' || lower.includes('inbox')) return Inbox;
if (role === 'sent' || lower.includes('sent')) return Send;
if (role === 'drafts' || lower.includes('draft')) return FileIcon;
if (role === 'trash' || lower.includes('trash') || lower.includes('deleted')) return Trash2;
if (role === 'junk' || role === 'spam' || lower.includes('junk') || lower.includes('spam')) return Ban;
if (role === 'archive' || lower.includes('archive')) return Archive;
return Folder;
}
interface MoveSheetProps {
visible: boolean;
onClose: () => void;
mailboxes: Mailbox[];
/** Folder the email is currently in - shown with a check, not selectable. */
currentMailboxId: string | null;
onPick: (id: string) => void;
}
export function MoveSheet({
visible, onClose, mailboxes, currentMailboxId, onPick,
}: MoveSheetProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(500)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
React.useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
]).start();
} else {
Animated.parallel([
Animated.timing(slideY, { toValue: 500, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
]).start();
}
}, [visible, slideY, overlayOpacity]);
const visibleNodes = React.useMemo(() => {
const tree = buildMailboxTree(mailboxes);
const expanded = new Set<string>();
const collect = (nodes: MailboxNode[]) => {
for (const n of nodes) {
if (n.children.length > 0) {
expanded.add(n.id);
collect(n.children);
}
}
};
collect(tree);
return flattenVisible(tree, expanded);
}, [mailboxes]);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
]}
>
<View {...dragHandlers}>
<View style={styles.sheetHandleHit}>
<View style={styles.sheetHandle} />
</View>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>Move to folder</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
<X size={18} color={c.textSecondary} />
</Pressable>
</View>
</View>
<ScrollView>
{visibleNodes.map((node) => {
const Icon = moveTargetIcon(node.role, node.name);
const isCurrent = node.id === currentMailboxId;
const canTarget = node.myRights?.mayAddItems !== false && !isCurrent;
return (
<Pressable
key={node.id}
onPress={canTarget ? () => onPick(node.id) : undefined}
disabled={!canTarget}
style={({ pressed }) => [
styles.moveRow,
pressed && canTarget && styles.moveRowPressed,
{ paddingLeft: spacing.lg + node.depth * 16 },
]}
>
<Icon size={16} color={canTarget ? c.textSecondary : c.textMuted} />
<Text
style={[styles.moveRowLabel, !canTarget && styles.moveRowLabelDisabled]}
numberOfLines={1}
>
{node.name}
</Text>
{isCurrent && <Check size={14} color={c.textMuted} />}
</Pressable>
);
})}
</ScrollView>
</Animated.View>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
sheetOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.5)',
},
sheetOverlayPress: { flex: 1 },
sheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
maxHeight: '75%',
backgroundColor: c.popover,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
borderTopWidth: 1,
borderColor: c.border,
paddingTop: spacing.sm,
},
sheetHandleHit: {
alignItems: 'center',
paddingTop: spacing.xs,
paddingBottom: spacing.sm,
},
sheetHandle: {
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: c.border,
},
sheetHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingBottom: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.border,
},
sheetTitle: {
...typography.bodySemibold,
color: c.text,
},
sheetClose: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.xs,
},
moveRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingRight: spacing.lg,
paddingVertical: spacing.sm + 2,
minHeight: 44,
},
moveRowPressed: { backgroundColor: c.surfaceHover },
moveRowLabel: {
...typography.body,
color: c.text,
flex: 1,
},
moveRowLabelDisabled: {
color: c.textMuted,
},
});
}
+216 -79
View File
@@ -3,12 +3,12 @@ import {
View, Text, StyleSheet, Animated, PanResponder, Pressable, Dimensions,
} from 'react-native';
import {
Archive, Trash2, ShieldAlert, MailOpen, Mail, Star,
Archive, Trash2, ShieldAlert, MailOpen, Star, Pin, FolderInput,
type LucideIcon,
} 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;
@@ -17,15 +17,28 @@ interface SwipeableRowProps {
/** Left-to-right swipe action (revealed under a rightward drag, sits at left edge). */
rightAction: SwipeAction;
/** Pass the row state used to compute action labels (e.g. unread/starred toggling). */
context: { unread: boolean; starred: boolean };
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;
}
const REVEAL_WIDTH = 88; // px shown when fully revealed
const ACTIVATION_THRESHOLD = 32; // px past which release snaps open instead of closing
// 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 = REVEAL_WIDTH * 1.4;
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;
const ACTION_META: Record<Exclude<SwipeAction, 'none'>, { icon: LucideIcon; bg: string; defaultLabel: string }> = {
archive: { icon: Archive, bg: '#1d4ed8', defaultLabel: 'Archive' },
@@ -33,23 +46,34 @@ const ACTION_META: Record<Exclude<SwipeAction, 'none'>, { icon: LucideIcon; bg:
spam: { icon: ShieldAlert, bg: '#a16207', defaultLabel: 'Spam' },
read: { icon: MailOpen, bg: '#0f766e', defaultLabel: 'Read' },
star: { icon: Star, bg: '#a16207', defaultLabel: 'Star' },
pin: { icon: Pin, bg: '#7c3aed', defaultLabel: 'Pin' },
move: { icon: FolderInput, bg: '#475569', defaultLabel: 'Move' },
};
function actionLabel(action: SwipeAction, context: { unread: boolean; starred: boolean }): string {
function actionLabel(action: SwipeAction, context: { unread: boolean; starred: boolean; pinned: boolean }): string {
if (action === 'read') return context.unread ? 'Read' : 'Unread';
if (action === 'star') return context.starred ? 'Unstar' : 'Star';
if (action === 'pin') return context.pinned ? 'Unpin' : 'Pin';
if (action === 'none') return '';
return ACTION_META[action].defaultLabel;
}
// Actions that visibly remove the row from the list (so the row should fly off
// instead of snapping back). Toggle-style actions stay in place.
function exitsRow(action: SwipeAction): boolean {
return action === 'archive' || action === 'delete' || action === 'spam' || action === 'move';
}
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]);
const dx = useRef(new Animated.Value(0)).current;
const claimed = useRef(false);
const widthRef = useRef(Dimensions.get('window').width);
// 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);
@@ -59,16 +83,45 @@ export function SwipeableRow({
setOpenSide(null);
};
const openRight = () => {
Animated.spring(dx, { toValue: REVEAL_WIDTH, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
openSideRef.current = 'right';
setOpenSide('right');
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 openLeft = () => {
Animated.spring(dx, { toValue: -REVEAL_WIDTH, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
openSideRef.current = 'left';
setOpenSide('left');
const fly = (toValue: number, action: SwipeAction) => {
// For destructive/move actions: race the row off-screen and fire the
// action - the parent will remove the row from the list. For toggle
// actions: fire immediately and snap back so the same row can update in
// place.
if (exitsRow(action)) {
Animated.timing(dx, {
toValue,
duration: 180,
useNativeDriver: true,
}).start(() => {
onAction(action);
// The row is about to be removed; reset translation in case it isn't
// (e.g. the action failed silently) so we don't leave it off-screen.
dx.setValue(0);
});
} else {
onAction(action);
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);
}
};
const responder = useRef(
@@ -77,8 +130,12 @@ 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 a side is already open, allow the gesture so the user can drag it closed.
if (openSideRef.current === null) {
// 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;
}
@@ -86,77 +143,151 @@ export function SwipeableRow({
return true;
},
onPanResponderMove: (_, g) => {
const base = openSideRef.current === 'right' ? REVEAL_WIDTH : openSideRef.current === 'left' ? -REVEAL_WIDTH : 0;
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(-MAX_DRAG_OVERSHOOT, Math.min(MAX_DRAG_OVERSHOOT, next));
const clamped = Math.max(-overshoot, Math.min(overshoot, next));
dx.setValue(clamped);
},
onPanResponderRelease: (_, g) => {
const base = openSideRef.current === 'right' ? REVEAL_WIDTH : openSideRef.current === 'left' ? -REVEAL_WIDTH : 0;
const finalDx = base + g.dx;
if (finalDx > ACTIVATION_THRESHOLD && rightAction !== 'none') {
openRight();
} else if (finalDx < -ACTIVATION_THRESHOLD && leftAction !== 'none') {
openLeft();
} else {
close();
}
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 {
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
}
},
onPanResponderTerminate: () => {
close();
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 fireAction = (action: SwipeAction) => {
close();
if (action !== 'none') onAction(action);
};
const renderBand = (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={() => fireAction(action)}
style={[
styles.band,
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>
);
};
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);
const inputRange = side === 'left' ? [0, COMMIT_THRESHOLD] : [-COMMIT_THRESHOLD, 0];
const iconScale = dx.interpolate({
inputRange,
outputRange: side === 'left' ? [0.85, 1.15] : [1.15, 0.85],
extrapolate: 'clamp',
});
return (
<View
style={[
styles.bandInstant,
{ backgroundColor: meta.bg },
side === 'left' ? { justifyContent: 'flex-start' } : { justifyContent: 'flex-end' },
]}
>
<Animated.View style={[styles.bandInner, { transform: [{ scale: iconScale }] }]}>
<Icon size={22} color="#fff" />
<Text style={styles.bandLabel}>{label}</Text>
</Animated.View>
</View>
);
};
const rightBandOpacity = dx.interpolate({
inputRange: [-1, 0, 1],
outputRange: [0, 0, 1],
extrapolate: 'clamp',
});
const leftBandOpacity = dx.interpolate({
inputRange: [-1, 0, 1],
outputRange: [1, 0, 0],
extrapolate: 'clamp',
});
return (
<View style={styles.wrap} onLayout={onLayout} {...responder.panHandlers}>
{renderBand(rightAction, 'left')}
{renderBand(leftAction, 'right')}
<Animated.View style={[StyleSheet.absoluteFill, { opacity: rightBandOpacity }]} pointerEvents="none">
{renderInstantBand(rightAction, 'left')}
</Animated.View>
<Animated.View style={[StyleSheet.absoluteFill, { opacity: leftBandOpacity }]} pointerEvents="none">
{renderInstantBand(leftAction, 'right')}
</Animated.View>
<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>
);
@@ -164,16 +295,22 @@ export function SwipeableRow({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
wrap: { position: 'relative', backgroundColor: c.background },
content: { backgroundColor: c.background },
band: {
position: 'absolute',
top: 0,
bottom: 0,
justifyContent: 'center',
paddingHorizontal: 16,
},
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' },
});
}
+120
View File
@@ -0,0 +1,120 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useEmailStore } from '../stores/email-store';
const VISIBLE_MS = 5000;
export function UndoSnackbar() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const entry = useEmailStore((s) => s.pendingUndo);
const undoLast = useEmailStore((s) => s.undoLast);
const clearUndo = useEmailStore((s) => s.clearUndo);
const slideY = React.useRef(new Animated.Value(120)).current;
const opacity = React.useRef(new Animated.Value(0)).current;
// Remember the last non-null entry so the bar's text/handlers stay valid
// while it animates out after pendingUndo flips to null.
const [shown, setShown] = React.useState(entry);
React.useEffect(() => {
if (entry) setShown(entry);
}, [entry]);
// Drive the auto-dismiss off the entry's createdAt so re-renders during
// animation don't reset the timer, and so consecutive actions reset it.
React.useEffect(() => {
if (!entry) {
Animated.parallel([
Animated.timing(slideY, { toValue: 120, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(opacity, { toValue: 0, duration: 180, useNativeDriver: true }),
]).start(({ finished }) => {
if (finished) setShown(null);
});
return;
}
Animated.parallel([
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(opacity, { toValue: 1, duration: 220, useNativeDriver: true }),
]).start();
const elapsed = Date.now() - entry.createdAt;
const remaining = Math.max(0, VISIBLE_MS - elapsed);
const id = setTimeout(() => {
// Only clear if the entry is still the same one we scheduled for.
if (useEmailStore.getState().pendingUndo === entry) {
clearUndo();
}
}, remaining);
return () => clearTimeout(id);
}, [entry, slideY, opacity, clearUndo]);
if (!shown) return null;
return (
<Animated.View
pointerEvents="box-none"
style={[
styles.wrap,
{ bottom: insets.bottom + spacing.md, opacity, transform: [{ translateY: slideY }] },
]}
>
<View style={styles.bar}>
<Text style={styles.label} numberOfLines={1}>{shown.label}</Text>
<Pressable
onPress={() => { void undoLast(); }}
hitSlop={8}
style={({ pressed }) => [styles.undoBtn, pressed && styles.undoBtnPressed]}
>
<Text style={styles.undoText}>UNDO</Text>
</Pressable>
</View>
</Animated.View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
wrap: {
position: 'absolute',
left: spacing.md,
right: spacing.md,
},
bar: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.sm + 2,
paddingLeft: spacing.lg,
paddingRight: spacing.sm,
borderRadius: radius.md,
backgroundColor: c.text,
shadowColor: '#000',
shadowOpacity: 0.3,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
elevation: 8,
},
label: {
...typography.body,
color: c.background,
flex: 1,
},
undoBtn: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.sm,
},
undoBtnPressed: {
backgroundColor: 'rgba(255,255,255,0.1)',
},
undoText: {
...typography.bodySemibold,
color: c.primary,
letterSpacing: 0.5,
},
});
}
+50 -20
View File
@@ -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>
+58 -39
View File
@@ -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>
+11 -3
View File
@@ -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>
+24 -6
View File
@@ -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' },
@@ -13,6 +13,13 @@ const SWIPE_OPTIONS: { value: SwipeAction; label: string }[] = [
{ value: 'spam', label: 'Spam' },
{ value: 'read', label: 'Read/Unread' },
{ value: 'star', label: 'Star' },
{ value: 'pin', label: 'Pin' },
{ 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() {
@@ -22,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);
@@ -30,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>
@@ -59,10 +81,6 @@ export function LayoutSettings() {
/>
</View>
<SettingItem
label=""
description="Drag past about half a row to commit; lift earlier 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>
+30 -7
View File
@@ -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"
+135 -56
View File
@@ -1,18 +1,19 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
import { AlertTriangle, FolderSync, Mail, X } from 'lucide-react-native';
import { ActivityIndicator, View, Text, StyleSheet, Pressable, TextInput } from '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 ExternalContentPolicy } from '../../stores/settings-store';
type MailLayout = 'split' | 'focus';
type DeleteAction = 'trash' | 'permanent';
type ArchiveMode = 'single' | 'year' | 'month';
type AttachmentAction = 'preview' | 'download';
type AttachmentPosition = 'beside-sender' | 'below-header';
const DEFAULT_KEYWORDS = ['attached', 'attachment', 'enclosed', 'attaching'];
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';
function MailLayoutPreview({ value }: { value: MailLayout }) {
const c = useColors();
@@ -71,43 +72,95 @@ 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 update = useSettingsStore((s) => s.updateSetting);
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 [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, setArchiveMode] = useState<ArchiveMode>('single');
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);
const [reorganizing, setReorganizing] = useState(false);
const [reorganizeResult, setReorganizeResult] = useState<string | null>(null);
useEffect(() => {
if (!hydrated) void hydrate();
}, [hydrated, hydrate]);
const handleReorganizeArchive = async () => {
const { mailboxes, fetchMailboxes } = useEmailStore.getState();
const archiveMailbox = mailboxes.find(
(m) => m.role === 'archive' || m.name.toLowerCase() === 'archive',
);
if (!archiveMailbox) {
setReorganizeResult('No archive folder found.');
return;
}
setReorganizing(true);
setReorganizeResult(null);
try {
// Drain the archive in pages so we don't OOM on huge mailboxes.
const PAGE = 100;
let position = 0;
let total = 0;
let moved = 0;
while (true) {
const { ids, total: pageTotal } = await queryEmails(archiveMailbox.id, {
position,
limit: PAGE,
});
if (position === 0) total = pageTotal;
if (ids.length === 0) break;
const list = await getEmails(ids);
const refreshed = useEmailStore.getState().mailboxes;
await archiveEmails(
list.map((e) => ({ id: e.id, receivedAt: e.receivedAt })),
archiveMailbox.id,
archiveMode,
refreshed,
);
moved += list.length;
// Newly-created year/month folders need to be visible to the next batch
// so we don't try to create the same folder twice.
await fetchMailboxes();
// Items just got moved out of the root archive view; the next page
// starts again at position 0 of the now-shorter list.
if (ids.length < PAGE) break;
}
setReorganizeResult(`Moved ${moved} of ${total} emails.`);
} catch (err) {
setReorganizeResult(err instanceof Error ? err.message : 'Reorganize failed.');
} finally {
setReorganizing(false);
}
};
return (
<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' },
@@ -121,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' },
@@ -140,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' },
@@ -148,23 +201,38 @@ export function ReadingSettings() {
]}
/>
{archiveMode !== 'single' && (
<Pressable style={styles.inlineBtn}>
<FolderSync size={14} color={c.text} />
<Text style={styles.inlineBtnText}>Reorganize existing archive</Text>
</Pressable>
<>
<Pressable
style={[styles.inlineBtn, reorganizing && styles.inlineBtnDisabled]}
onPress={reorganizing ? undefined : handleReorganizeArchive}
disabled={reorganizing}
>
{reorganizing ? (
<ActivityIndicator size="small" color={c.text} />
) : (
<FolderSync size={14} color={c.text} />
)}
<Text style={styles.inlineBtnText}>
{reorganizing ? 'Reorganizing…' : 'Reorganize existing archive'}
</Text>
</Pressable>
{reorganizeResult && (
<Text style={styles.reorganizeResult}>{reorganizeResult}</Text>
)}
</>
)}
<View style={styles.divider} />
</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' },
@@ -175,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>
@@ -207,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}
>
@@ -226,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('');
}}
@@ -236,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('');
}}
@@ -249,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' },
@@ -266,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' },
@@ -276,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' },
@@ -327,6 +398,14 @@ function makeStyles(c: ThemePalette) {
...typography.body,
color: c.text,
},
inlineBtnDisabled: {
opacity: 0.6,
},
reorganizeResult: {
...typography.caption,
color: c.mutedForeground,
marginTop: spacing.xs,
},
subLabel: {
...typography.bodyMedium,
color: c.text,
+19 -16
View File
@@ -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>
+16 -9
View File
@@ -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>
+12 -4
View File
@@ -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}>
+14 -4
View File
@@ -1,3 +1,4 @@
import { Platform } from 'react-native';
import { File, Paths } from 'expo-file-system';
import * as Sharing from 'expo-sharing';
import { jmapClient } from '../api/jmap-client';
@@ -18,6 +19,15 @@ function safeAttachmentName(name: string | undefined, type: string | undefined):
return `attachment.${fallbackExt}`;
}
// On Android, sharing a `file://` URI from the cache directory throws a
// FileUriExposedException on modern OS versions; the system requires a
// FileProvider-backed `content://` URI instead. expo-file-system exposes that
// via `file.contentUri`, which is the only field iOS doesn't expose.
function shareableUri(file: { uri: string; contentUri?: string }): string {
if (Platform.OS === 'android' && file.contentUri) return file.contentUri;
return file.uri;
}
export async function shareAttachment(
blobId: string,
name: string | undefined,
@@ -26,15 +36,15 @@ export async function shareAttachment(
const filename = safeAttachmentName(name, type);
const mimeType = type || 'application/octet-stream';
const dest = new File(Paths.cache, filename);
if (dest.exists) dest.delete();
const url = getDownloadUrl(blobId, filename, mimeType);
const downloaded = await File.downloadFileAsync(url, dest, {
headers: { Authorization: authHeader() },
idempotent: true,
});
if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device');
}
await Sharing.shareAsync(downloaded.uri, {
await Sharing.shareAsync(shareableUri(downloaded), {
mimeType,
dialogTitle: filename,
});
@@ -54,15 +64,15 @@ function safeFilename(subject: string | undefined): string {
export async function shareEmailEml(blobId: string, subject?: string): Promise<void> {
const dest = new File(Paths.cache, safeFilename(subject));
if (dest.exists) dest.delete();
const url = getDownloadUrl(blobId, dest.name, RFC822);
const downloaded = await File.downloadFileAsync(url, dest, {
headers: { Authorization: authHeader() },
idempotent: true,
});
if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device');
}
await Sharing.shareAsync(downloaded.uri, {
await Sharing.shareAsync(shareableUri(downloaded), {
mimeType: RFC822,
dialogTitle: 'Share email',
UTI: 'public.email-message',
+19 -3
View File
@@ -144,7 +144,11 @@ async function pollVerificationCode(
relayBaseUrl: string,
subscriptionId: string,
): Promise<string> {
const timeoutAt = Date.now() + 20_000;
// Stalwart per-account rate-limits PushVerification posts (default 60s).
// If there are leftover unverified subscriptions on the account, our new
// one queues up behind them - so we wait long enough to clear at least one
// verify window even in the unlucky case.
const timeoutAt = Date.now() + 75_000;
let delay = 400;
while (Date.now() < timeoutAt) {
const res = await fetch(
@@ -203,10 +207,10 @@ export async function setupPushNotifications(
// Reuse the previous JMAP subscription when the server still has it, but
// push the expiry forward so it doesn't time out before the next app start.
const existingSubs = await listPushSubscriptions().catch(() => []);
const storedServerId = await AsyncStorage.getItem(SUBSCRIPTION_ID_KEY);
if (storedServerId) {
const existing = await listPushSubscriptions().catch(() => []);
const match = existing.find((s) => s.id === storedServerId);
const match = existingSubs.find((s) => s.id === storedServerId);
if (match) {
const refreshed = await refreshSubscriptionExpires(match);
if (refreshed) {
@@ -219,6 +223,18 @@ export async function setupPushNotifications(
await AsyncStorage.removeItem(SUBSCRIPTION_ID_KEY);
}
// Reap any leftover Stalwart subscriptions still bound to this device.
// These pile up when a previous enable attempt failed mid-flow (verify
// race, network blip, app killed). Stalwart per-account rate-limits
// PushVerification posts, so leaving stragglers around blocks the new
// one's verification - the symptom is a confusing "code does not match".
const stragglers = existingSubs.filter(
(s) => s.deviceClientId === deviceClientId && s.id !== storedServerId,
);
for (const s of stragglers) {
await destroyPushSubscription(s.id).catch(() => undefined);
}
const serverAssignedId = await createPushSubscription({
deviceClientId,
url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`),
+87 -9
View File
@@ -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,
@@ -13,6 +13,7 @@ import {
} from 'lucide-react-native';
import * as ImagePicker from 'expo-image-picker';
import * as FileSystem from 'expo-file-system/legacy';
import { File as FsFile } from 'expo-file-system';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { Button } from '../components';
@@ -208,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;
@@ -486,7 +520,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
}
};
const pickAttachment = async () => {
const pickPhotoAttachments = async () => {
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!perm.granted) {
Alert.alert(
@@ -516,6 +550,53 @@ export default function ComposeScreen({ route, navigation }: Props) {
}
};
const pickFileAttachment = async () => {
// pickFileAsync's return type is the lower-level FileSystemFile, not the
// public `File` wrapper. Both expose the fields we need (uri/name/size/type).
type PickedFile = { uri: string; name?: string; size?: number; type?: string };
let picked: PickedFile | undefined;
try {
const result = await FsFile.pickFileAsync();
picked = (Array.isArray(result) ? result[0] : result) as PickedFile;
} catch (e) {
// The picker rejects with PickerCancelledException when the user backs
// out — treat that as a no-op rather than surfacing an error.
const message = e instanceof Error ? e.message : String(e);
if (/cancel/i.test(message)) return;
Alert.alert(t('email_composer.attach', 'Attach'), message);
return;
}
if (!picked) return;
const type = picked.type || 'application/octet-stream';
const name = picked.name || `attachment-${Date.now()}`;
const entry = addUploadEntry({
name,
type,
size: picked.size ?? 0,
uri: picked.uri,
inline: false,
});
void startUpload(entry);
};
const pickAttachment = () => {
Alert.alert(
t('email_composer.attach', 'Attach'),
undefined,
[
{
text: t('email_composer.attach_photos', 'Photos & Videos'),
onPress: () => { void pickPhotoAttachments(); },
},
{
text: t('email_composer.attach_files', 'Files'),
onPress: () => { void pickFileAttachment(); },
},
{ text: t('email_composer.cancel', 'Cancel'), style: 'cancel' },
],
);
};
const insertInlineImage = async () => {
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!perm.granted) {
@@ -655,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} />
@@ -693,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"
@@ -874,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}
+40 -4
View File
@@ -11,6 +11,8 @@ import { useColors } from '../theme/colors';
import SidebarDrawer from '../components/SidebarDrawer';
import SenderAvatar from '../components/SenderAvatar';
import { SwipeableRow } from '../components/SwipeableRow';
import { MoveSheet } from '../components/MoveSheet';
import { UndoSnackbar } from '../components/UndoSnackbar';
import { OfflineBanner } from '../components/OfflineBanner';
import { useNetworkStore } from '../stores/network-store';
import { useEmailStore, type EmailFilters } from '../stores/email-store';
@@ -33,6 +35,10 @@ function isStarred(email: Email): boolean {
return !!email.keywords?.$flagged;
}
function isPinned(email: Email): boolean {
return !!email.keywords?.$important;
}
function formatRelativeTime(date: Date): string {
const now = Date.now();
const diffMs = now - date.getTime();
@@ -164,11 +170,14 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
const markRead = useEmailStore((s) => s.markRead);
const markUnread = useEmailStore((s) => s.markUnread);
const toggleStar = useEmailStore((s) => s.toggleStar);
const togglePin = useEmailStore((s) => s.togglePin);
const deleteEmailAction = useEmailStore((s) => s.deleteEmail);
const moveToMailboxAction = useEmailStore((s) => s.moveToMailbox);
const archiveEmailAction = useEmailStore((s) => s.archiveEmail);
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(
@@ -184,6 +193,9 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
[mailboxes],
);
// Move-to-folder picker triggered by the swipe action.
const [pendingMoveId, setPendingMoveId] = React.useState<string | null>(null);
// Selection state
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set());
const selectionMode = selectedIds.size > 0;
@@ -225,7 +237,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
switch (action) {
case 'archive':
if (archiveMailboxId && currentMailboxId !== archiveMailboxId) {
void moveToMailboxAction(id, currentMailboxId, archiveMailboxId);
void archiveEmailAction(id);
}
break;
case 'delete':
@@ -245,10 +257,17 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
case 'star':
void toggleStar(id, !isStarred(email));
break;
case 'pin':
void togglePin(id, !isPinned(email));
break;
case 'move':
setPendingMoveId(id);
break;
}
}, [
currentMailboxId, archiveMailboxId, trashMailboxId, junkMailboxId,
moveToMailboxAction, deleteEmailAction, markRead, markUnread, toggleStar,
moveToMailboxAction, archiveEmailAction, deleteEmailAction,
markRead, markUnread, toggleStar, togglePin,
]);
const renderEmailRow = React.useCallback(
@@ -256,7 +275,8 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<SwipeableRow
leftAction={selectionMode ? 'none' : swipeLeftAction}
rightAction={selectionMode ? 'none' : swipeRightAction}
context={{ unread: isUnread(item), starred: isStarred(item) }}
mode={swipeMode}
context={{ unread: isUnread(item), starred: isStarred(item), pinned: isPinned(item) }}
onAction={(action) => handleSwipeAction(item.id, action)}
>
<EmailRow
@@ -268,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(() => {
@@ -809,6 +829,22 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
/>
);
})()}
<MoveSheet
visible={pendingMoveId !== null}
onClose={() => setPendingMoveId(null)}
mailboxes={mailboxes}
currentMailboxId={currentMailboxId}
onPick={(toId) => {
const id = pendingMoveId;
setPendingMoveId(null);
if (id && currentMailboxId && toId !== currentMailboxId) {
void moveToMailboxAction(id, currentMailboxId, toId);
}
}}
/>
<UndoSnackbar />
</SafeAreaView>
);
}
+5 -138
View File
@@ -9,33 +9,21 @@ import {
ArrowLeft, Star, Trash2, MoreVertical, Reply, ReplyAll, Forward,
ChevronLeft, ChevronRight, Paperclip, Archive, Mail, MailOpen,
FolderInput, ShieldAlert, ShieldCheck, X, Check,
Inbox, Send, File as FileIcon, Ban, Folder, Code, Download, Tag,
Code, Download, Tag,
} from 'lucide-react-native';
import type { LucideIcon } from 'lucide-react-native';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import EmailBodyView from '../components/EmailBodyView';
import SenderAvatar from '../components/SenderAvatar';
import { MoveSheet } from '../components/MoveSheet';
import { useEmailStore } from '../stores/email-store';
import { setEmailKeywords } from '../api/email';
import { shareEmailEml, shareAttachment } from '../lib/email-export';
import { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store';
import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree';
import { useSheetDrag } from '../lib/use-sheet-drag';
import type { Email, Mailbox } from '../api/types';
import type { RootStackParamList } from '../navigation/types';
function moveTargetIcon(role: string | null | undefined, name: string): LucideIcon {
const lower = name.toLowerCase();
if (role === 'inbox' || lower.includes('inbox')) return Inbox;
if (role === 'sent' || lower.includes('sent')) return Send;
if (role === 'drafts' || lower.includes('draft')) return FileIcon;
if (role === 'trash' || lower.includes('trash') || lower.includes('deleted')) return Trash2;
if (role === 'junk' || role === 'spam' || lower.includes('junk') || lower.includes('spam')) return Ban;
if (role === 'archive' || lower.includes('archive')) return Archive;
return Folder;
}
type Props = NativeStackScreenProps<RootStackParamList, 'EmailThread'>;
function formatHeaderDate(iso: string): string {
@@ -71,6 +59,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
const markRead = useEmailStore((s) => s.markRead);
const deleteEmail = useEmailStore((s) => s.deleteEmail);
const moveToMailbox = useEmailStore((s) => s.moveToMailbox);
const archiveEmailAction = useEmailStore((s) => s.archiveEmail);
const mailboxes = useEmailStore((s) => s.mailboxes);
const currentMailboxId = useEmailStore((s) => s.currentMailboxId);
const emails = useEmailStore((s) => s.emails);
@@ -200,7 +189,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
const onArchive = () => {
if (!email || !currentMailboxId || !archiveMailbox) return;
if (currentMailboxId === archiveMailbox.id) return;
void moveToMailbox(email.id, currentMailboxId, archiveMailbox.id);
void archiveEmailAction(email.id);
navigation.goBack();
};
@@ -492,7 +481,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
}}
/>
<MoveMenuSheet
<MoveSheet
visible={moveMenuOpen}
onClose={() => setMoveMenuOpen(false)}
mailboxes={mailboxes}
@@ -725,107 +714,6 @@ function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }:
);
}
interface MoveMenuSheetProps {
visible: boolean;
onClose: () => void;
mailboxes: Mailbox[];
currentMailboxId: string | null;
onPick: (id: string) => void;
}
function MoveMenuSheet({ visible, onClose, mailboxes, currentMailboxId, onPick }: MoveMenuSheetProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(500)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
React.useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
]).start();
} else {
Animated.parallel([
Animated.timing(slideY, { toValue: 500, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
]).start();
}
}, [visible, slideY, overlayOpacity]);
const visibleNodes = React.useMemo(() => {
const tree = buildMailboxTree(mailboxes);
const expanded = new Set<string>();
const collect = (nodes: MailboxNode[]) => {
for (const n of nodes) {
if (n.children.length > 0) {
expanded.add(n.id);
collect(n.children);
}
}
};
collect(tree);
return flattenVisible(tree, expanded);
}, [mailboxes]);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
styles.sheetTall,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
]}
>
<View {...dragHandlers}>
<View style={styles.sheetHandleHit}>
<View style={styles.sheetHandle} />
</View>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>Move to folder</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
<X size={18} color={c.textSecondary} />
</Pressable>
</View>
</View>
<ScrollView>
{visibleNodes.map((node) => {
const Icon = moveTargetIcon(node.role, node.name);
const isCurrent = node.id === currentMailboxId;
const canTarget = node.myRights?.mayAddItems !== false && !isCurrent;
return (
<Pressable
key={node.id}
onPress={canTarget ? () => onPick(node.id) : undefined}
disabled={!canTarget}
style={({ pressed }) => [
styles.moveRow,
pressed && canTarget && styles.moveRowPressed,
{ paddingLeft: spacing.lg + node.depth * 16 },
]}
>
<Icon size={16} color={canTarget ? c.textSecondary : c.textMuted} />
<Text
style={[styles.moveRowLabel, !canTarget && styles.moveRowLabelDisabled]}
numberOfLines={1}
>
{node.name}
</Text>
{isCurrent && <Check size={14} color={c.textMuted} />}
</Pressable>
);
})}
</ScrollView>
</Animated.View>
</Modal>
);
}
function MoreMenuItem({
icon, label, onPress, trailing,
}: { icon: React.ReactNode; label: string; onPress?: () => void; trailing?: React.ReactNode }) {
@@ -1106,9 +994,6 @@ function makeStyles(c: ThemePalette) {
borderColor: c.border,
paddingTop: spacing.sm,
},
sheetTall: {
maxHeight: '75%',
},
sheetHandleHit: {
alignItems: 'center',
paddingTop: spacing.xs,
@@ -1162,23 +1047,5 @@ function makeStyles(c: ThemePalette) {
flex: 1,
},
// Move folder row
moveRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingRight: spacing.lg,
paddingVertical: spacing.sm + 2,
minHeight: 44,
},
moveRowPressed: { backgroundColor: c.surfaceHover },
moveRowLabel: {
...typography.body,
color: c.text,
flex: 1,
},
moveRowLabelDisabled: {
color: c.textMuted,
},
});
}
+9
View File
@@ -7,10 +7,19 @@ vi.mock('../../api/email', () => ({
getFullEmail: vi.fn(),
setEmailKeywords: vi.fn(),
moveEmail: vi.fn(),
archiveEmails: vi.fn(),
restoreEmailMailboxes: vi.fn(),
deleteEmail: vi.fn(),
searchEmails: vi.fn(),
}));
// settings-store transitively pulls in jmap-client / expo-secure-store, which
// trip on react-native's Flow-typed entrypoint under vitest. The store only
// reads archiveMode in archiveEmail, so a minimal stub is enough.
vi.mock('../settings-store', () => ({
useSettingsStore: { getState: () => ({ archiveMode: 'single' }) },
}));
import * as emailApi from '../../api/email';
import { useEmailStore } from '../email-store';
+145
View File
@@ -9,10 +9,13 @@ import {
getFullEmail,
setEmailKeywords,
moveEmail,
archiveEmails as apiArchiveEmails,
deleteEmail as apiDeleteEmail,
restoreEmailMailboxes,
searchEmails as apiSearchEmails,
} from '../api/email';
import { toWildcardQuery } from '../lib/search-utils';
import { useSettingsStore } from './settings-store';
export interface EmailFilters {
from?: string;
@@ -25,6 +28,19 @@ export interface EmailFilters {
isUnread?: boolean;
}
// Snapshot of an action that can still be reversed via the undo snackbar.
// We store the full email object so undo can re-insert it into the visible list
// optimistically without waiting for a refetch.
export interface UndoEntry {
kind: 'archive' | 'delete' | 'move' | 'spam';
/** Human-readable label shown in the snackbar (e.g. "Email archived"). */
label: string;
/** Time the entry was created - the snackbar uses this to drive its timer. */
createdAt: number;
/** Each item is one email's pre-action mailboxIds, used to restore it. */
items: Array<{ email: Email; originalMailboxIds: Record<string, boolean> }>;
}
export interface EmailState {
mailboxes: Mailbox[];
currentMailboxId: string | null;
@@ -34,6 +50,7 @@ export interface EmailState {
error: string | null;
searchQuery: string;
filters: EmailFilters;
pendingUndo: UndoEntry | null;
fetchMailboxes: () => Promise<void>;
selectMailbox: (mailboxId: string) => Promise<void>;
@@ -43,8 +60,12 @@ export interface EmailState {
markRead: (emailId: string) => Promise<void>;
markUnread: (emailId: string) => Promise<void>;
toggleStar: (emailId: string, starred: boolean) => Promise<void>;
togglePin: (emailId: string, pinned: boolean) => Promise<void>;
moveToMailbox: (emailId: string, fromMailboxId: string, toMailboxId: string) => Promise<void>;
archiveEmail: (emailId: string) => Promise<void>;
deleteEmail: (emailId: string, trashMailboxId: string, currentMailboxId: string) => Promise<void>;
undoLast: () => Promise<void>;
clearUndo: () => void;
searchEmails: (query: string) => Promise<Email[]>;
setSearchQuery: (query: string) => void;
setFilters: (filters: EmailFilters) => void;
@@ -102,6 +123,7 @@ export const useEmailStore = create<EmailState>()(
error: null,
searchQuery: '',
filters: {},
pendingUndo: null,
fetchMailboxes: async () => {
try {
@@ -115,6 +137,8 @@ export const useEmailStore = create<EmailState>()(
selectMailbox: async (mailboxId) => {
// Reset search/filters when switching mailbox - matches webmail behavior.
// Switching mailboxes invalidates any pending undo: the snackbar would be
// stale and the restored email would re-appear in a different view.
set({
currentMailboxId: mailboxId,
loading: true,
@@ -123,6 +147,7 @@ export const useEmailStore = create<EmailState>()(
totalEmails: 0,
searchQuery: '',
filters: {},
pendingUndo: null,
});
try {
const filter = buildJmapFilter(mailboxId, '', {});
@@ -232,16 +257,136 @@ export const useEmailStore = create<EmailState>()(
});
},
togglePin: async (emailId, pinned) => {
const email = get().emails.find((e) => e.id === emailId);
if (!email) return;
const keywords = { ...email.keywords };
if (pinned) {
keywords.$important = true;
} else {
delete keywords.$important;
}
await setEmailKeywords(emailId, keywords);
set({
emails: get().emails.map((e) =>
e.id === emailId ? { ...e, keywords } : e,
),
});
},
moveToMailbox: async (emailId, fromMailboxId, toMailboxId) => {
const email = get().emails.find((e) => e.id === emailId);
const original = email ? { ...email.mailboxIds } : null;
await moveEmail(emailId, fromMailboxId, toMailboxId);
set({ emails: get().emails.filter((e) => e.id !== emailId) });
if (email && original) {
const targetName = get().mailboxes.find((m) => m.id === toMailboxId)?.name;
set({
pendingUndo: {
kind: 'move',
label: targetName ? `Email moved to ${targetName}` : 'Email moved',
createdAt: Date.now(),
items: [{ email, originalMailboxIds: original }],
},
});
}
},
archiveEmail: async (emailId) => {
const { emails, mailboxes } = get();
const email = emails.find((e) => e.id === emailId);
if (!email) return;
const archiveMailbox = mailboxes.find(
(m) => m.role === 'archive' || m.name.toLowerCase() === 'archive',
);
if (!archiveMailbox) return;
if (email.mailboxIds?.[archiveMailbox.id]) return;
const mode = useSettingsStore.getState().archiveMode;
const original = { ...email.mailboxIds };
await apiArchiveEmails(
[{ id: email.id, receivedAt: email.receivedAt }],
archiveMailbox.id,
mode,
mailboxes,
);
set({
emails: get().emails.filter((e) => e.id !== emailId),
pendingUndo: {
kind: 'archive',
label: 'Email archived',
createdAt: Date.now(),
items: [{ email, originalMailboxIds: original }],
},
});
// Auto-sort modes may have created new year/month folders - refresh the
// mailbox list so the sidebar picks them up on the next render.
if (mode !== 'single') {
void get().fetchMailboxes();
}
},
deleteEmail: async (emailId, trashMailboxId, currentMailboxId) => {
const email = get().emails.find((e) => e.id === emailId);
const original = email ? { ...email.mailboxIds } : null;
const isPermanent = currentMailboxId === trashMailboxId;
await apiDeleteEmail(emailId, trashMailboxId, currentMailboxId);
set({ emails: get().emails.filter((e) => e.id !== emailId) });
// Permanent destroy can't be undone - skip the snackbar so we don't
// promise an undo we can't deliver.
if (email && original && !isPermanent) {
set({
pendingUndo: {
kind: 'delete',
label: 'Email moved to Trash',
createdAt: Date.now(),
items: [{ email, originalMailboxIds: original }],
},
});
}
},
undoLast: async () => {
const entry = get().pendingUndo;
if (!entry) return;
set({ pendingUndo: null });
try {
await restoreEmailMailboxes(
entry.items.map((it) => ({ id: it.email.id, mailboxIds: it.originalMailboxIds })),
);
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Undo failed' });
return;
}
// Re-insert each restored email into the visible list if its original
// mailboxIds include the current view. Server is the source of truth for
// ordering, but local re-insertion gives the user instant feedback.
const { currentMailboxId, emails } = get();
if (currentMailboxId) {
const restored = entry.items
.filter((it) => it.originalMailboxIds[currentMailboxId])
.map((it) => ({ ...it.email, mailboxIds: it.originalMailboxIds }));
if (restored.length > 0) {
const merged = [...restored, ...emails].sort(
(a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime(),
);
set({ emails: merged });
}
}
},
clearUndo: () => set({ pendingUndo: null }),
searchEmails: async (query) => {
const ids = await apiSearchEmails(query);
if (ids.length === 0) return [];
+244 -36
View File
@@ -7,16 +7,53 @@ 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';
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'
| 'delete'
| 'spam'
| 'read'
| '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 type SwipeAction = 'none' | 'archive' | 'delete' | 'spam' | 'read' | 'star';
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;
@@ -24,31 +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 {
@@ -59,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;
@@ -73,30 +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,
};
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 {
@@ -105,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: [],
@@ -128,24 +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,
hydrated: true,
});
set({ ...mergeWithDefaults(parsed), hydrated: true });
return;
}
} catch (err) {
@@ -154,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())); },
@@ -168,6 +346,8 @@ 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) => {
const normalized = email.toLowerCase().trim();
@@ -189,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,