chore: add Android release CI and sync local work

CI:
- .github/workflows/release-android.yml builds APK on release publish
  and via workflow_dispatch, attaches it to the release.
- android/app/build.gradle: optional release signingConfig driven by
  Gradle properties (ANDROID_KEYSTORE_BASE64 secret etc.); falls back
  to the debug keystore when no secrets are set.
- .gitignore: untrack /android so the runner can build directly;
  ignore build outputs (build/, .cxx/), Metro/Expo dev logs, scratch.

Track android/ sources including BulwarkFcmPackage.kt and
google-services.json (Firebase Android keys are public per Google's
docs and intended to ship in the APK).

JS sync: push notifications + background task, vacation responder
(api + store), contact activity component, search utilities, settings
polish, calendar/contacts/email store and screen tweaks.

Tooling: scripts/poll-reload.mjs — Windows polling-watcher that hits
Metro /reload on file change (workaround for unreliable fs.watch
without Watchman).
This commit is contained in:
Linus Rath
2026-04-26 04:12:08 +02:00
parent 2fe15e6d70
commit 365ffefd33
102 changed files with 7914 additions and 214 deletions
+20 -1
View File
@@ -1,6 +1,7 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { Email, EmailAddress, Mailbox, Thread } from './types';
import { toWildcardQuery } from '../lib/search-utils';
const EMAIL_LIST_PROPERTIES = [
'id', 'threadId', 'mailboxIds', 'keywords', 'size',
@@ -131,7 +132,7 @@ export async function searchEmails(
limit = 30,
): Promise<string[]> {
const accountId = jmapClient.accountId;
const filter: Record<string, unknown> = { text: query };
const filter: Record<string, unknown> = { text: toWildcardQuery(query) };
if (mailboxId) filter.inMailbox = mailboxId;
const res = await jmapClient.request([
@@ -145,6 +146,24 @@ export async function searchEmails(
return res.methodResponses[0][1].ids;
}
// Cross-mailbox query with an arbitrary JMAP filter (used by contact activity
// to search "from OR to <addresses>" without picking a specific mailbox).
export async function queryEmailsByFilter(
filter: Record<string, unknown>,
limit = 5,
): Promise<string[]> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request([
['Email/query', {
accountId,
filter,
sort: [{ property: 'receivedAt', isAscending: false }],
limit,
}, '0'],
]);
return res.methodResponses[0][1].ids;
}
export async function sendEmail(
email: {
from: EmailAddress[];
+84 -1
View File
@@ -1,9 +1,92 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { StateChange } from './types';
import type { PushSubscription, StateChange } from './types';
export type StateChangeHandler = (change: StateChange) => void;
// ─── PushSubscription (RFC 8620 §7.2) ───────────────────
export async function listPushSubscriptions(): Promise<PushSubscription[]> {
const res = await jmapClient.request(
[['PushSubscription/get', { ids: null }, '0']],
[CAPABILITIES.CORE],
);
const [, body] = res.methodResponses[0] ?? [];
return (body?.list as PushSubscription[]) ?? [];
}
/**
* Create a PushSubscription pointing the JMAP server at the given relay URL.
* Returns the server-assigned id (which the client also registers with the
* relay so the relay can route incoming pushes to an Expo token).
*/
export async function createPushSubscription(params: {
deviceClientId: string;
url: string;
types: string[];
}): Promise<string> {
const res = await jmapClient.request(
[
[
'PushSubscription/set',
{
create: {
new: {
deviceClientId: params.deviceClientId,
url: params.url,
types: params.types,
},
},
},
'0',
],
],
[CAPABILITIES.CORE],
);
const [, body] = res.methodResponses[0] ?? [];
const created = body?.created?.new as { id?: string } | undefined;
if (!created?.id) {
const notCreated = body?.notCreated?.new;
throw new Error(
`PushSubscription/set create failed: ${JSON.stringify(notCreated ?? body)}`,
);
}
return created.id;
}
/**
* Send the verification code back to the server — the call that flips the
* subscription from pending to active (RFC 8620 §7.2.2).
*/
export async function verifyPushSubscription(
id: string,
verificationCode: string,
): Promise<void> {
const res = await jmapClient.request(
[
[
'PushSubscription/set',
{ update: { [id]: { verificationCode } } },
'0',
],
],
[CAPABILITIES.CORE],
);
const [, body] = res.methodResponses[0] ?? [];
if (body?.notUpdated?.[id]) {
throw new Error(
`PushSubscription verification failed: ${JSON.stringify(body.notUpdated[id])}`,
);
}
}
export async function destroyPushSubscription(id: string): Promise<void> {
await jmapClient.request(
[['PushSubscription/set', { destroy: [id] }, '0']],
[CAPABILITIES.CORE],
);
}
/**
* Connect to the JMAP EventSource (SSE) endpoint for real-time push updates.
* Returns a cleanup function to close the connection.
+12
View File
@@ -418,6 +418,18 @@ export interface StateChange {
changed: Record<string, Record<string, string>>;
}
// RFC 8620 §7.2. A PushSubscription is session-scoped (not per-account) and
// tells the server where to deliver StateChange events for the listed types.
export interface PushSubscription {
id: string;
deviceClientId: string;
url: string;
keys?: { p256dh: string; auth: string } | null;
verificationCode?: string | null;
expires?: string | null;
types?: string[] | null;
}
// ─── Capability URNs ────────────────────────────────────
export const CAPABILITIES = {
+67
View File
@@ -0,0 +1,67 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
export interface VacationResponse {
id: string;
isEnabled: boolean;
fromDate: string | null;
toDate: string | null;
subject: string;
textBody: string;
htmlBody: string | null;
}
const VACATION_USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL, CAPABILITIES.VACATION];
const DEFAULT: VacationResponse = {
id: 'singleton',
isEnabled: false,
fromDate: null,
toDate: null,
subject: '',
textBody: '',
htmlBody: null,
};
export async function getVacationResponse(): Promise<VacationResponse> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['VacationResponse/get', { accountId, ids: ['singleton'] }, '0']],
VACATION_USING,
);
const resp = res.methodResponses?.[0];
if (resp && resp[0] === 'VacationResponse/get') {
const list = (resp[1] as { list?: VacationResponse[] }).list ?? [];
return list[0] ?? DEFAULT;
}
throw new Error('Unexpected response from VacationResponse/get');
}
export async function setVacationResponse(
updates: Partial<Omit<VacationResponse, 'id'>>,
): Promise<void> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['VacationResponse/set', {
accountId,
update: { singleton: updates },
}, '0']],
VACATION_USING,
);
const resp = res.methodResponses?.[0];
if (resp && resp[0] === 'VacationResponse/set') {
const result = resp[1] as { notUpdated?: Record<string, { description?: string }> };
if (result.notUpdated?.singleton) {
throw new Error(result.notUpdated.singleton.description ?? 'Failed to update vacation responder');
}
return;
}
throw new Error('Unexpected response from VacationResponse/set');
}
export function isVacationSupported(): boolean {
const session = jmapClient.currentSession;
if (!session) return false;
const caps = session.capabilities ?? {};
return CAPABILITIES.VACATION in caps;
}
+11 -3
View File
@@ -54,9 +54,17 @@ export function AgendaView({
for (let i = 0; i < daysAhead; i++) {
const day = new Date(start);
day.setDate(start.getDate() + i);
const dayEvents = eventsOnDayFromIndex(index, day).slice().sort(
(a, b) => getEventStartDate(a).getTime() - getEventStartDate(b).getTime(),
);
const dayEvents = eventsOnDayFromIndex(index, day).slice().sort((a, b) => {
// All-day events first within a day, then by start time, then title
// (stable tie-break) — mirrors the comparator the webmail uses for
// packing week segments and keeps multi-event days predictable.
if (a.showWithoutTime !== b.showWithoutTime) {
return a.showWithoutTime ? -1 : 1;
}
const diff = getEventStartDate(a).getTime() - getEventStartDate(b).getTime();
if (diff !== 0) return diff;
return (a.title || '').localeCompare(b.title || '');
});
if (dayEvents.length === 0 && !isToday(day)) continue;
result.push({
title: formatDayHeader(day),
+68 -23
View File
@@ -11,9 +11,14 @@ import type { Calendar, CalendarEvent } from '../../api/types';
import { colors, radius, spacing, typography } from '../../theme/tokens';
import {
buildEventDayIndex,
buildTimedFullDayWeekSegments,
buildWeekSegmentsRaw,
eventsOnDayFromIndex,
getEventColor,
isTimedEventFullDayOnDate,
layoutOverlappingEvents,
packWeekSegments,
type CalendarWeekSegment,
type EventDayIndex,
} from '../../lib/calendar-utils';
@@ -56,25 +61,38 @@ export function WeekView({
return Array.from({ length: 7 }, (_, i) => addDays(start, i));
}, [selectedDate, weekStartsOn]);
const allDayByDay = React.useMemo(() => {
return weekDays.map((day) =>
eventsOnDayFromIndex(index, day).filter((e) => e.showWithoutTime),
// Multi-day all-day events render as one bar across the days they span.
// Timed events that consume the entire day get promoted to this strip too.
const allDaySegments = React.useMemo<CalendarWeekSegment[]>(() => {
const explicit = buildWeekSegmentsRaw(
events.filter((e) => e.showWithoutTime),
weekDays,
);
}, [weekDays, index]);
const timedFull = buildTimedFullDayWeekSegments(
events.filter((e) => !e.showWithoutTime),
weekDays,
);
return packWeekSegments([...explicit, ...timedFull]);
}, [events, weekDays]);
const allDayRowCount = React.useMemo(() => {
return allDayByDay.reduce((max, list) => Math.max(max, list.length), 0);
}, [allDayByDay]);
return allDaySegments.reduce((max, s) => Math.max(max, s.row + 1), 0);
}, [allDaySegments]);
const allDayStripHeight =
allDayRowCount > 0
? allDayRowCount * (ALL_DAY_CHIP_HEIGHT + ALL_DAY_GAP) + spacing.xs
: 0;
// Timed grid excludes events that fill the full day on that day — they're
// already promoted to the all-day strip above.
const layoutsByDay = React.useMemo(() => {
return weekDays.map((day) =>
layoutOverlappingEvents(eventsOnDayFromIndex(index, day), day),
);
return weekDays.map((day) => {
const dayEvents = eventsOnDayFromIndex(index, day).filter(
(e) => !e.showWithoutTime && !isTimedEventFullDayOnDate(e, day),
);
return layoutOverlappingEvents(dayEvents, day);
});
}, [weekDays, index]);
const [nowMinutes, setNowMinutes] = React.useState(() => {
@@ -148,25 +166,45 @@ export function WeekView({
<Text style={styles.allDayLabel}>all-day</Text>
</View>
<View style={styles.allDayGrid}>
{allDayByDay.map((dayEvents, idx) => (
<View key={idx} style={styles.allDayCol}>
{dayEvents.map((event) => (
{/* Empty per-day columns to draw vertical separators */}
{weekDays.map((day) => (
<View key={day.toISOString()} style={styles.allDayCol} />
))}
{/* Segments overlay — multi-day events span their date range */}
<View style={styles.allDayOverlay} pointerEvents="box-none">
{allDaySegments.map((segment) => {
const color = getEventColor(segment.event, calendars);
const leftPct = (segment.startIndex / weekDays.length) * 100;
const widthPct = (segment.span / weekDays.length) * 100;
return (
<Pressable
key={event.id}
key={`${segment.event.id}:${segment.startIndex}:${segment.row}`}
onPress={() => onSelectEvent?.(segment.event)}
style={[
styles.allDayChip,
{ backgroundColor: getEventColor(event, calendars) + '33' },
{ borderLeftColor: getEventColor(event, calendars) },
{
position: 'absolute',
left: `${leftPct}%`,
width: `${widthPct}%`,
top: segment.row * (ALL_DAY_CHIP_HEIGHT + ALL_DAY_GAP),
backgroundColor: color + '33',
borderLeftColor: color,
borderTopLeftRadius: segment.continuesBefore ? 0 : radius.xs,
borderBottomLeftRadius: segment.continuesBefore ? 0 : radius.xs,
borderTopRightRadius: segment.continuesAfter ? 0 : radius.xs,
borderBottomRightRadius: segment.continuesAfter ? 0 : radius.xs,
marginHorizontal: 1,
},
]}
onPress={() => onSelectEvent?.(event)}
>
<Text style={styles.allDayChipText} numberOfLines={1}>
{event.title || 'Untitled'}
{segment.continuesBefore ? '… ' : ''}
{segment.event.title || 'Untitled'}
</Text>
</Pressable>
))}
</View>
))}
);
})}
</View>
</View>
</View>
)}
@@ -316,11 +354,18 @@ const styles = StyleSheet.create({
},
allDayLabelWrap: { alignItems: 'flex-end', justifyContent: 'flex-start', paddingRight: 4 },
allDayLabel: { ...typography.small, color: colors.textMuted },
allDayGrid: { flex: 1, flexDirection: 'row' },
allDayGrid: { flex: 1, flexDirection: 'row', position: 'relative' },
allDayCol: {
flex: 1,
paddingHorizontal: 1,
gap: ALL_DAY_GAP,
borderLeftWidth: 1,
borderLeftColor: colors.borderLight,
},
allDayOverlay: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
allDayChip: {
height: ALL_DAY_CHIP_HEIGHT,
+258
View File
@@ -0,0 +1,258 @@
import React from 'react';
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Mail as MailIcon, CalendarDays } from 'lucide-react-native';
import { format, isAfter, parseISO } from 'date-fns';
import type { ContactCard, Email, CalendarEvent } from '../../api/types';
import type { RootStackParamList } from '../../navigation/types';
import { getEmails, queryEmailsByFilter } from '../../api/email';
import { useCalendarStore } from '../../stores/calendar-store';
import SenderAvatar from '../SenderAvatar';
import { colors, radius, spacing, typography } from '../../theme/tokens';
const EMAIL_LIMIT = 5;
const EVENT_LIMIT = 5;
type Nav = NativeStackNavigationProp<RootStackParamList>;
function getContactEmails(contact: ContactCard): string[] {
if (!contact.emails) return [];
const seen = new Set<string>();
const out: string[] = [];
for (const e of Object.values(contact.emails)) {
const addr = e.address?.trim().toLowerCase();
if (addr && !seen.has(addr)) {
seen.add(addr);
out.push(addr);
}
}
return out;
}
function buildEmailFilter(addresses: string[]): Record<string, unknown> {
const conditions: Record<string, unknown>[] = [];
for (const addr of addresses) {
conditions.push({ from: addr });
conditions.push({ to: addr });
}
if (conditions.length === 1) return conditions[0];
return { operator: 'OR', conditions };
}
function eventInvolvesContact(event: CalendarEvent, addresses: Set<string>): boolean {
if (event.participants) {
for (const p of Object.values(event.participants)) {
const addr = p.email?.trim().toLowerCase();
if (addr && addresses.has(addr)) return true;
if (p.sendTo) {
for (const target of Object.values(p.sendTo)) {
const m = typeof target === 'string' ? target.match(/mailto:(.+)/i) : null;
if (m && addresses.has(m[1].trim().toLowerCase())) return true;
}
}
}
}
return false;
}
function formatRelativeDate(iso: string): string {
try {
const d = parseISO(iso);
const now = new Date();
return d.getFullYear() === now.getFullYear()
? format(d, 'MMM d')
: format(d, 'MMM d, yyyy');
} catch {
return '';
}
}
function formatEventTime(event: CalendarEvent): string {
if (event.showWithoutTime) return 'All day';
try {
return format(parseISO(event.start), 'HH:mm');
} catch {
return '';
}
}
interface Props {
contact: ContactCard;
}
export function ContactActivity({ contact }: Props) {
const navigation = useNavigation<Nav>();
const calendarEvents = useCalendarStore((s) => s.events);
const addresses = React.useMemo(() => getContactEmails(contact), [contact]);
const addressKey = addresses.join(',');
const [emails, setEmails] = React.useState<Email[] | null>(null);
const [emailsLoading, setEmailsLoading] = React.useState(false);
const [emailsError, setEmailsError] = React.useState(false);
React.useEffect(() => {
if (addresses.length === 0) {
setEmails([]);
return;
}
let cancelled = false;
setEmailsLoading(true);
setEmailsError(false);
(async () => {
try {
const ids = await queryEmailsByFilter(buildEmailFilter(addresses), EMAIL_LIMIT);
const fetched = ids.length > 0 ? await getEmails(ids) : [];
if (cancelled) return;
setEmails(fetched);
} catch {
if (cancelled) return;
setEmailsError(true);
setEmails([]);
} finally {
if (!cancelled) setEmailsLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [addressKey]); // eslint-disable-line react-hooks/exhaustive-deps
const upcomingEvents = React.useMemo(() => {
if (addresses.length === 0) return [];
const addrSet = new Set(addresses);
const now = new Date();
return calendarEvents
.filter((e) => eventInvolvesContact(e, addrSet))
.filter((e) => {
try {
return isAfter(parseISO(e.utcStart || e.start), now);
} catch {
return false;
}
})
.sort((a, b) => (a.utcStart || a.start).localeCompare(b.utcStart || b.start))
.slice(0, EVENT_LIMIT);
}, [calendarEvents, addresses]);
if (addresses.length === 0) return null;
return (
<View style={styles.wrap}>
<View style={styles.section}>
<View style={styles.sectionHeader}>
<MailIcon size={14} color={colors.primary} />
<Text style={styles.sectionTitle}>Recent emails</Text>
</View>
{emailsLoading ? (
<View style={styles.loading}>
<ActivityIndicator size="small" color={colors.primary} />
</View>
) : emailsError ? (
<Text style={styles.emptyText}>Couldn't load emails.</Text>
) : !emails || emails.length === 0 ? (
<Text style={styles.emptyText}>No emails with this contact.</Text>
) : (
emails.map((email) => {
const sender = email.from?.[0];
const senderName = sender?.name || sender?.email || 'Unknown';
return (
<Pressable
key={email.id}
onPress={() =>
navigation.navigate('EmailThread', {
emailId: email.id,
threadId: email.threadId,
subject: email.subject ?? undefined,
})
}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
<SenderAvatar name={senderName} email={sender?.email} size={32} />
<View style={styles.rowContent}>
<View style={styles.rowTop}>
<Text style={styles.rowSender} numberOfLines={1}>{senderName}</Text>
<Text style={styles.rowDate}>{formatRelativeDate(email.receivedAt)}</Text>
</View>
<Text style={styles.rowSubject} numberOfLines={1}>
{email.subject || '(no subject)'}
</Text>
{email.preview ? (
<Text style={styles.rowPreview} numberOfLines={1}>{email.preview}</Text>
) : null}
</View>
</Pressable>
);
})
)}
</View>
<View style={styles.section}>
<View style={styles.sectionHeader}>
<CalendarDays size={14} color={colors.primary} />
<Text style={styles.sectionTitle}>Upcoming events</Text>
</View>
{upcomingEvents.length === 0 ? (
<Text style={styles.emptyText}>No upcoming events.</Text>
) : (
upcomingEvents.map((event) => (
<View key={event.id} style={styles.eventRow}>
<Text style={styles.eventTime}>{formatEventTime(event)}</Text>
<View style={styles.eventInfo}>
<Text style={styles.eventTitle} numberOfLines={1}>
{event.title || '(no title)'}
</Text>
<Text style={styles.eventDate}>
{formatRelativeDate(event.utcStart || event.start)}
</Text>
</View>
</View>
))
)}
</View>
</View>
);
}
const styles = StyleSheet.create({
wrap: { gap: spacing.md, paddingHorizontal: spacing.lg, marginBottom: spacing.lg },
section: {
backgroundColor: colors.card,
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
gap: spacing.sm,
borderLeftWidth: 3,
borderLeftColor: colors.primary,
},
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs },
sectionTitle: { ...typography.bodySemibold, color: colors.text },
loading: { paddingVertical: spacing.sm, alignItems: 'flex-start' },
emptyText: { ...typography.caption, color: colors.textMuted },
row: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.sm,
paddingVertical: 6,
borderRadius: radius.xs,
},
rowPressed: { backgroundColor: colors.surface },
rowContent: { flex: 1, minWidth: 0 },
rowTop: { flexDirection: 'row', justifyContent: 'space-between', gap: spacing.sm },
rowSender: { ...typography.bodyMedium, color: colors.text, flex: 1 },
rowDate: { ...typography.caption, color: colors.textMuted },
rowSubject: { ...typography.body, color: colors.text },
rowPreview: { ...typography.caption, color: colors.textMuted },
eventRow: { flexDirection: 'row', gap: spacing.sm, alignItems: 'baseline', paddingVertical: 4 },
eventTime: {
...typography.caption,
color: colors.textMuted,
width: 64,
},
eventInfo: { flex: 1, minWidth: 0 },
eventTitle: { ...typography.body, color: colors.text },
eventDate: { ...typography.caption, color: colors.textMuted, marginTop: 2 },
});
@@ -0,0 +1,195 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Key, Smartphone, Lock, Eye, EyeOff, Shield, Monitor, Trash2 } from 'lucide-react-native';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Input from '../Input';
import Button from '../Button';
import { colors, spacing, radius, typography } from '../../theme/tokens';
interface Session {
id: string;
device: string;
location: string;
current: boolean;
}
const MOCK_SESSIONS: Session[] = [
{ id: '1', device: 'iPhone 15 Pro — Bulwark Mobile', location: 'Berlin, DE', current: true },
{ id: '2', device: 'Chrome on macOS', location: 'Berlin, DE', current: false },
];
export function AccountSecuritySettings() {
const [currentPwd, setCurrentPwd] = useState('');
const [newPwd, setNewPwd] = useState('');
const [confirmPwd, setConfirmPwd] = useState('');
const [showCurrent, setShowCurrent] = useState(false);
const [showNew, setShowNew] = useState(false);
const [twoFactor, setTwoFactor] = useState(false);
const [appPwds] = useState<{ id: string; label: string; created: string }[]>([]);
return (
<View style={styles.container}>
<SettingsSection title="Password" description="Change the password for this account.">
<View style={{ gap: spacing.md }}>
<View style={styles.pwField}>
<Text style={styles.pwLabel}>Current password</Text>
<View style={styles.pwInputRow}>
<Input
value={currentPwd}
onChangeText={setCurrentPwd}
secureTextEntry={!showCurrent}
containerStyle={{ flex: 1 }}
/>
<Pressable style={styles.eyeBtn} onPress={() => setShowCurrent((v) => !v)}>
{showCurrent ? (
<EyeOff size={16} color={colors.mutedForeground} />
) : (
<Eye size={16} color={colors.mutedForeground} />
)}
</Pressable>
</View>
</View>
<View style={styles.pwField}>
<Text style={styles.pwLabel}>New password</Text>
<View style={styles.pwInputRow}>
<Input
value={newPwd}
onChangeText={setNewPwd}
secureTextEntry={!showNew}
containerStyle={{ flex: 1 }}
/>
<Pressable style={styles.eyeBtn} onPress={() => setShowNew((v) => !v)}>
{showNew ? (
<EyeOff size={16} color={colors.mutedForeground} />
) : (
<Eye size={16} color={colors.mutedForeground} />
)}
</Pressable>
</View>
</View>
<View style={styles.pwField}>
<Text style={styles.pwLabel}>Confirm password</Text>
<Input
value={confirmPwd}
onChangeText={setConfirmPwd}
secureTextEntry={!showNew}
/>
</View>
<View style={{ alignItems: 'flex-end' }}>
<Button size="sm" icon={<Key size={14} color={colors.primaryForeground} />}>
Change Password
</Button>
</View>
</View>
</SettingsSection>
<SettingsSection title="Two-Factor Authentication" description="Add an extra layer of security with an authenticator app.">
<SettingItem label="Enable 2FA" description="Require a one-time code at login.">
<ToggleSwitch checked={twoFactor} onChange={setTwoFactor} />
</SettingItem>
{twoFactor && (
<View style={styles.infoBox}>
<Smartphone size={16} color={colors.primary} />
<Text style={styles.infoText}>
Scan the QR code in your authenticator app to finish setup.
</Text>
</View>
)}
</SettingsSection>
<SettingsSection title="App Passwords" description="Generate passwords for other mail clients.">
{appPwds.length === 0 ? (
<View style={styles.empty}>
<Lock size={24} color={colors.mutedForeground} />
<Text style={styles.emptyText}>No app passwords yet.</Text>
</View>
) : null}
<View style={{ alignItems: 'flex-start' }}>
<Button variant="outline" size="sm">
Generate New
</Button>
</View>
</SettingsSection>
<SettingsSection title="Active Sessions" description="Devices currently signed into your account.">
{MOCK_SESSIONS.map((s) => (
<View key={s.id} style={styles.sessionRow}>
<View style={styles.sessionIcon}>
<Monitor size={18} color={colors.text} />
</View>
<View style={{ flex: 1 }}>
<Text style={styles.sessionDevice}>{s.device}</Text>
<Text style={styles.sessionMeta}>
{s.location} · {s.current ? 'Current' : 'Active'}
</Text>
</View>
{!s.current && (
<Pressable style={styles.trashBtn}>
<Trash2 size={16} color={colors.error} />
</Pressable>
)}
</View>
))}
</SettingsSection>
</View>
);
}
const styles = StyleSheet.create({
container: { gap: spacing.xxxl },
pwField: { gap: 4 },
pwLabel: { ...typography.caption, color: colors.mutedForeground },
pwInputRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
eyeBtn: { padding: spacing.sm },
infoBox: {
flexDirection: 'row',
gap: spacing.sm,
padding: spacing.md,
backgroundColor: colors.primaryBg,
borderRadius: radius.sm,
alignItems: 'center',
},
infoText: {
...typography.caption,
color: colors.primary,
flex: 1,
},
empty: {
alignItems: 'center',
padding: spacing.xl,
gap: spacing.sm,
},
emptyText: {
...typography.body,
color: colors.mutedForeground,
},
sessionRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
sessionIcon: {
width: 36,
height: 36,
borderRadius: radius.sm,
backgroundColor: colors.muted,
alignItems: 'center',
justifyContent: 'center',
},
sessionDevice: { ...typography.bodyMedium, color: colors.text },
sessionMeta: { ...typography.caption, color: colors.mutedForeground, marginTop: 2 },
trashBtn: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.sm,
},
});
+139
View File
@@ -0,0 +1,139 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { SettingsSection, SettingItem } from './settings-section';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { useAuthStore } from '../../stores/auth-store';
import { useAccountStore } from '../../stores/account-store';
interface AccountSettingsProps {
displayName?: string;
email?: string;
username?: string;
serverUrl?: string;
authMode?: 'oauth' | 'basic';
quotaUsed?: number;
quotaTotal?: number;
isDemoMode?: boolean;
}
function formatFileSize(bytes: number): string {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
let n = bytes;
while (n >= 1024 && i < units.length - 1) {
n /= 1024;
i++;
}
return `${n.toFixed(n < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
}
export function AccountSettings(props: AccountSettingsProps = {}) {
const authUsername = useAuthStore((s) => s.username);
const authServerUrl = useAuthStore((s) => s.serverUrl);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const account = useAccountStore((s) =>
activeAccountId ? s.accounts.find((a) => a.id === activeAccountId) : undefined,
);
const displayName = props.displayName ?? account?.displayName ?? authUsername ?? 'Unknown';
const email = props.email ?? account?.email ?? authUsername ?? '';
const username = props.username ?? account?.username ?? authUsername ?? undefined;
const serverUrl = props.serverUrl ?? account?.serverUrl ?? authServerUrl ?? '';
const authMode = props.authMode ?? 'basic';
const quotaUsed = props.quotaUsed ?? 0;
const quotaTotal = props.quotaTotal ?? 0;
const isDemoMode = props.isDemoMode ?? false;
const percent = quotaTotal ? Math.round((quotaUsed / quotaTotal) * 100) : 0;
return (
<SettingsSection title="Account" description="Your account information.">
<SettingItem label="Display Name">
<Text style={styles.value}>{displayName}</Text>
</SettingItem>
<SettingItem label="Email Address">
<Text style={styles.value}>{email}</Text>
</SettingItem>
{username && username !== email && (
<SettingItem label="Username">
<Text style={styles.value}>{username}</Text>
</SettingItem>
)}
<SettingItem label="Authentication">
<Text style={styles.value}>
{authMode === 'oauth' ? 'OAuth' : 'Basic'}
</Text>
</SettingItem>
<SettingItem label="Server">
<Text style={styles.value} numberOfLines={1}>{serverUrl}</Text>
</SettingItem>
{quotaTotal > 0 && (
<SettingItem
label="Storage"
description={`${formatFileSize(quotaUsed)} of ${formatFileSize(quotaTotal)} used`}
>
<View style={styles.storage}>
<Text style={styles.value}>{percent}%</Text>
<View style={styles.bar}>
<View style={[styles.barFill, { width: `${percent}%` }]} />
</View>
</View>
</SettingItem>
)}
{isDemoMode && (
<SettingItem label="Account Type">
<View style={styles.demoRow}>
<View style={styles.demoDot} />
<Text style={styles.demoText}>Demo account</Text>
</View>
</SettingItem>
)}
</SettingsSection>
);
}
const styles = StyleSheet.create({
value: {
...typography.body,
color: colors.text,
maxWidth: 240,
},
storage: {
alignItems: 'flex-end',
gap: 6,
},
bar: {
width: 128,
height: 8,
backgroundColor: colors.muted,
borderRadius: radius.full,
overflow: 'hidden',
},
barFill: {
height: '100%',
backgroundColor: colors.primary,
borderRadius: radius.full,
},
demoRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
demoDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: '#f59e0b',
},
demoText: {
...typography.bodyMedium,
color: '#fbbf24',
},
});
@@ -0,0 +1,168 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, Linking, Alert } from 'react-native';
import { ExternalLink } from 'lucide-react-native';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { useSettingsStore } from '../../stores/settings-store';
const APP_VERSION = '0.0.0';
const GIT_COMMIT = 'unknown';
const DEBUG_CATEGORIES = [
{ id: 'jmap', label: 'JMAP', description: 'JMAP request and response logs.' },
{ id: 'auth', label: 'Auth', description: 'Authentication and session events.' },
{ id: 'sync', label: 'Sync', description: 'Background sync operations.' },
{ id: 'render', label: 'Render', description: 'React render counts and timings.' },
];
export function AdvancedSettings() {
const senderFavicons = useSettingsStore((s) => s.senderFavicons);
const setSenderFaviconsStore = useSettingsStore((s) => s.setSenderFavicons);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
const resetExternalContentPolicy = useSettingsStore((s) => s.setExternalContentPolicy);
const trustedSenders = useSettingsStore((s) => s.trustedSenders);
const removeTrustedSender = useSettingsStore((s) => s.removeTrustedSender);
const [debugMode, setDebugMode] = useState(false);
const [debugCategories, setDebugCategories] = useState<Record<string, boolean>>(
Object.fromEntries(DEBUG_CATEGORIES.map((c) => [c.id, true]))
);
const [syncEnabled, setSyncEnabled] = useState(true);
const [confirmReset, setConfirmReset] = useState(false);
useEffect(() => {
if (!hydrated) void hydrate();
}, [hydrated, hydrate]);
const handleReset = () => {
resetExternalContentPolicy('ask');
setSenderFaviconsStore(true);
for (const email of trustedSenders) removeTrustedSender(email);
setConfirmReset(false);
Alert.alert('Settings reset', 'Saved settings have been restored to defaults.');
};
return (
<View style={styles.container}>
<View style={styles.aboutBox}>
<View style={styles.aboutRow}>
<View style={styles.logo}>
<Text style={styles.logoText}>B</Text>
</View>
<View style={{ flex: 1 }}>
<Text style={styles.aboutTitle}>About</Text>
<Text style={styles.aboutVersion}>
v{APP_VERSION}{' '}
<Text style={styles.aboutCommit}>({GIT_COMMIT})</Text>
</Text>
</View>
<Pressable
style={styles.ghLink}
onPress={() => Linking.openURL('https://github.com/bulwarkmail/webmail')}
>
<Text style={styles.ghText}>GitHub</Text>
<ExternalLink size={12} color={colors.mutedForeground} />
</Pressable>
</View>
</View>
<SettingsSection title="Advanced" description="Low-level options and troubleshooting.">
<SettingItem label="Debug mode" description="Enable verbose logging for support and development.">
<ToggleSwitch checked={debugMode} onChange={setDebugMode} />
</SettingItem>
{debugMode && (
<View style={styles.debugCategoriesBox}>
<Text style={styles.debugCategoriesHint}>
Which categories to include in debug output.
</Text>
{DEBUG_CATEGORIES.map((cat) => (
<SettingItem key={cat.id} label={cat.label} description={cat.description}>
<ToggleSwitch
checked={debugCategories[cat.id] !== false}
onChange={(v) => setDebugCategories({ ...debugCategories, [cat.id]: v })}
/>
</SettingItem>
))}
</View>
)}
<SettingItem label="Settings sync" description="Sync settings across your signed-in devices.">
<ToggleSwitch checked={syncEnabled} onChange={setSyncEnabled} />
</SettingItem>
<SettingItem
label="Sender favicons"
description="Fetch website favicons for sender avatars (experimental)."
>
<ToggleSwitch checked={senderFavicons} onChange={setSenderFaviconsStore} />
</SettingItem>
<SettingItem label="Reset settings" description="Restore saved preferences (external content, trusted senders, favicons) to defaults.">
<Button
variant={confirmReset ? 'destructive' : 'outline'}
size="sm"
onPress={() => {
if (confirmReset) {
handleReset();
} else {
setConfirmReset(true);
setTimeout(() => setConfirmReset(false), 5000);
}
}}
>
{confirmReset ? 'Confirm reset' : 'Reset'}
</Button>
</SettingItem>
</SettingsSection>
</View>
);
}
const styles = StyleSheet.create({
container: { gap: spacing.xxxl },
aboutBox: {
padding: spacing.xl,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.card,
},
aboutRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.lg,
},
logo: {
width: 48,
height: 48,
borderRadius: radius.md,
backgroundColor: colors.primary,
alignItems: 'center',
justifyContent: 'center',
},
logoText: { fontSize: 24, fontWeight: '700', color: colors.primaryForeground },
aboutTitle: { ...typography.bodyMedium, color: colors.text },
aboutVersion: { ...typography.caption, color: colors.mutedForeground, marginTop: 2 },
aboutCommit: { color: colors.mutedForeground, opacity: 0.6 },
ghLink: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
ghText: { ...typography.caption, color: colors.mutedForeground },
debugCategoriesBox: {
marginLeft: spacing.lg,
paddingLeft: spacing.lg,
borderLeftWidth: 2,
borderLeftColor: colors.muted,
gap: 4,
},
debugCategoriesHint: {
...typography.caption,
color: colors.mutedForeground,
marginBottom: spacing.sm,
},
});
@@ -0,0 +1,233 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { PlayCircle } from 'lucide-react-native';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { colors, spacing, radius, typography } from '../../theme/tokens';
type Theme = 'light' | 'dark' | 'system';
type FontSize = 'small' | 'medium' | 'large';
type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
type ToolbarPosition = 'top' | 'below-subject';
const DENSITY_PREVIEW: Record<Density, { py: number; gap: number; showAvatar: boolean; showPreview: boolean }> = {
'extra-compact': { py: 2, gap: 6, showAvatar: false, showPreview: false },
compact: { py: 4, gap: 8, showAvatar: true, showPreview: false },
regular: { py: 10, gap: 12, showAvatar: true, showPreview: true },
comfortable: { py: 16, gap: 16, showAvatar: true, showPreview: true },
};
const PREVIEW_ROWS = [
{ unread: true, sender: 'Alice Johnson', subject: 'Project update - Q1 roadmap', preview: 'Here are the latest numbers from…' },
{ unread: false, sender: 'Bob Smith', subject: 'Re: Meeting notes', preview: 'Thanks, will review and get back...' },
{ unread: true, sender: 'Carol Lee', subject: 'Invoice #4092', preview: 'Please find attached the invoice…' },
];
function DensityPreview({ density }: { density: Density }) {
const cfg = DENSITY_PREVIEW[density];
return (
<View style={styles.densityPreview}>
{PREVIEW_ROWS.map((row, i) => (
<View
key={i}
style={[
styles.densityRow,
{
paddingVertical: cfg.py,
gap: cfg.gap,
borderBottomWidth: i < PREVIEW_ROWS.length - 1 ? 1 : 0,
},
]}
>
{cfg.showAvatar && (
<View style={[
styles.densityAvatar,
density === 'comfortable' ? { width: 32, height: 32 } : { width: 24, height: 24 },
]} />
)}
<View style={{ flex: 1 }}>
<View style={styles.densityHeader}>
<Text
style={[
styles.densitySender,
row.unread ? { color: colors.text, fontWeight: '600' } : { color: colors.mutedForeground },
]}
numberOfLines={1}
>
{row.sender}
</Text>
<Text style={styles.densityTime}>12:00</Text>
</View>
<Text
style={[
styles.densitySubject,
row.unread ? { color: colors.text, fontWeight: '500' } : { color: 'rgba(250,250,250,0.8)' },
]}
numberOfLines={1}
>
{row.subject}
</Text>
{cfg.showPreview && (
<Text style={styles.densityPreviewText} numberOfLines={1}>
{row.preview}
</Text>
)}
</View>
</View>
))}
</View>
);
}
export function AppearanceSettings() {
const [theme, setTheme] = useState<Theme>('dark');
const [fontSize, setFontSize] = useState<FontSize>('medium');
const [density, setDensity] = useState<Density>('regular');
const [toolbarPosition, setToolbarPosition] = useState<ToolbarPosition>('top');
const [showToolbarLabels, setShowToolbarLabels] = useState(true);
const [hideAccountSwitcher, setHideAccountSwitcher] = useState(false);
const [showRailAccountList, setShowRailAccountList] = useState(false);
const [colorfulSidebarIcons, setColorfulSidebarIcons] = useState(true);
const [animationsEnabled, setAnimationsEnabled] = useState(true);
const [language, setLanguage] = useState('en');
return (
<SettingsSection title="Appearance" description="Customize how the interface looks and feels.">
<SettingItem label="Theme" description="Choose between light, dark, or system theme.">
<RadioGroup
value={theme}
onChange={(v) => setTheme(v as Theme)}
options={[
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'system', label: 'System' },
]}
/>
</SettingItem>
<SettingItem label="Language" description="Interface language.">
<RadioGroup
value={language}
onChange={setLanguage}
options={[
{ value: 'en', label: 'EN' },
{ value: 'de', label: 'DE' },
{ value: 'fr', label: 'FR' },
]}
/>
</SettingItem>
<SettingItem label="Font Size" description="Adjust text size.">
<RadioGroup
value={fontSize}
onChange={(v) => setFontSize(v as FontSize)}
options={[
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
]}
/>
</SettingItem>
<View>
<SettingItem label="List Density" description="How tight the email list is packed." noBorder />
<RadioGroup
value={density}
onChange={(v) => setDensity(v as Density)}
options={[
{ value: 'extra-compact', label: 'Extra' },
{ value: 'compact', label: 'Compact' },
{ value: 'regular', label: 'Regular' },
{ value: 'comfortable', label: 'Comfy' },
]}
/>
<DensityPreview density={density} />
<View style={styles.divider} />
</View>
<SettingItem label="Toolbar Position" description="Where to place the message toolbar.">
<RadioGroup
value={toolbarPosition}
onChange={(v) => setToolbarPosition(v as ToolbarPosition)}
options={[
{ value: 'top', label: 'Top' },
{ value: 'below-subject', label: 'Below' },
]}
/>
</SettingItem>
<SettingItem label="Toolbar Labels" description="Show text labels on toolbar buttons.">
<ToggleSwitch checked={showToolbarLabels} onChange={setShowToolbarLabels} />
</SettingItem>
<SettingItem label="Hide Account Switcher" description="Hide the account switcher from the navigation rail.">
<ToggleSwitch checked={hideAccountSwitcher} onChange={setHideAccountSwitcher} />
</SettingItem>
<SettingItem label="Rail Account List" description="Show all accounts inside the navigation rail.">
<ToggleSwitch checked={showRailAccountList} onChange={setShowRailAccountList} />
</SettingItem>
<SettingItem label="Colorful Sidebar Icons" description="Display sidebar icons with their brand colors.">
<ToggleSwitch checked={colorfulSidebarIcons} onChange={setColorfulSidebarIcons} />
</SettingItem>
<SettingItem label="Animations" description="Enable or disable interface animations.">
<ToggleSwitch checked={animationsEnabled} onChange={setAnimationsEnabled} />
</SettingItem>
<SettingItem label="Restart Tour" description="Replay the onboarding walkthrough.">
<Button variant="outline" size="sm" icon={<PlayCircle size={14} color={colors.text} />}>
Restart
</Button>
</SettingItem>
</SettingsSection>
);
}
const styles = StyleSheet.create({
densityPreview: {
marginTop: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
overflow: 'hidden',
backgroundColor: colors.background,
},
densityRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
borderBottomColor: colors.border,
},
densityAvatar: {
borderRadius: radius.full,
backgroundColor: colors.muted,
},
densityHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
gap: spacing.sm,
},
densitySender: {
...typography.caption,
flex: 1,
},
densityTime: {
fontSize: 10,
color: colors.mutedForeground,
},
densitySubject: {
...typography.caption,
},
densityPreviewText: {
fontSize: 11,
color: 'rgba(161,161,170,0.7)',
},
divider: {
height: 1,
backgroundColor: colors.border,
marginTop: spacing.md,
},
});
@@ -0,0 +1,107 @@
import React, { useState } from 'react';
import { SettingsSection, SettingItem, Select, RadioGroup, ToggleSwitch } from './settings-section';
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);
return (
<SettingsSection title="Calendar">
<SettingItem label="Default view">
<Select
value={viewMode}
onChange={setViewMode}
options={[
{ value: 'month', label: 'Month' },
{ value: 'week', label: 'Week' },
{ value: 'day', label: 'Day' },
{ value: 'agenda', label: 'Agenda' },
]}
/>
</SettingItem>
<SettingItem label="Week starts on">
<Select
value={firstDay}
onChange={setFirstDay}
options={[
{ value: '1', label: 'Monday' },
{ value: '0', label: 'Sunday' },
]}
/>
</SettingItem>
<SettingItem label="Time format">
<RadioGroup
value={timeFormat}
onChange={setTimeFormat}
options={[
{ value: '12h', label: '12-hour' },
{ value: '24h', label: '24-hour' },
]}
/>
</SettingItem>
<SettingItem
label="Show time in month view"
description="Show start times next to events in the month grid."
>
<ToggleSwitch checked={showTimeInMonth} onChange={setShowTimeInMonth} />
</SettingItem>
<SettingItem
label="Show week numbers"
description="Display ISO week numbers in month and week views."
>
<ToggleSwitch checked={showWeekNumbers} onChange={setShowWeekNumbers} />
</SettingItem>
<SettingItem
label="Hover preview"
description="Show event details on hover or tap-and-hold."
>
<Select
value={hoverPreview}
onChange={setHoverPreview}
options={[
{ value: 'instant', label: 'Instant' },
{ value: 'delay-500ms', label: '500ms delay' },
{ value: 'delay-1s', label: '1s delay' },
{ value: 'delay-2s', label: '2s delay' },
{ value: 'off', label: 'Off' },
]}
/>
</SettingItem>
<SettingItem
label="Show birthday calendar"
description="Automatically include birthdays from your contacts."
>
<ToggleSwitch checked={birthdayCal} onChange={setBirthdayCal} />
</SettingItem>
<SettingItem
label="Enable tasks"
description="Show task management alongside events."
>
<ToggleSwitch checked={tasksEnabled} onChange={setTasksEnabled} />
</SettingItem>
{tasksEnabled && (
<SettingItem
label="Show tasks on calendar"
description="Overlay tasks with due dates on the calendar."
>
<ToggleSwitch checked={showTasksOnCal} onChange={setShowTasksOnCal} />
</SettingItem>
)}
</SettingsSection>
);
}
@@ -0,0 +1,87 @@
import React from 'react';
import { Alert, Share, Text } from 'react-native';
import * as FileSystem from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing';
import { Download } from 'lucide-react-native';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { useSettingsStore } from '../../stores/settings-store';
import { useContactsStore } from '../../stores/contacts-store';
import { contactsToVCard } from '../../lib/vcard';
import { isGroup } from '../../lib/contact-utils';
import { colors, typography } from '../../theme/tokens';
export function ContactsSettings() {
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
const setGroupByLetter = useSettingsStore((s) => s.setGroupContactsByLetter);
const contacts = useContactsStore((s) => s.contacts);
const [exporting, setExporting] = React.useState(false);
const exportable = React.useMemo(() => contacts.filter((c) => !isGroup(c)), [contacts]);
const exportLabel =
exportable.length === 0
? 'No contacts to export'
: `Export ${exportable.length} contact${exportable.length === 1 ? '' : 's'} as a single vCard file.`;
const handleExport = async () => {
if (exportable.length === 0 || exporting) return;
setExporting(true);
try {
const vcf = contactsToVCard(exportable);
const filename = `contacts-${new Date().toISOString().slice(0, 10)}.vcf`;
const path = `${FileSystem.cacheDirectory}${filename}`;
await FileSystem.writeAsStringAsync(path, vcf);
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(path, {
mimeType: 'text/vcard',
UTI: 'public.vcard',
dialogTitle: 'Export contacts',
});
} else {
await Share.share({ message: vcf });
}
} catch (err) {
Alert.alert('Export failed', err instanceof Error ? err.message : 'Unknown error');
} finally {
setExporting(false);
}
};
return (
<SettingsSection
title="Contacts"
description="Display preferences and address-book tools."
>
<SettingItem
label="Group by first letter"
description="Show alphabetical section headers in the contacts list."
>
<ToggleSwitch checked={groupByLetter} onChange={setGroupByLetter} />
</SettingItem>
<SettingItem
label="Export contacts"
description={exportLabel}
>
<Button
variant="outline"
size="sm"
icon={<Download size={14} color={colors.text} />}
onPress={() => { void handleExport(); }}
disabled={exportable.length === 0 || exporting}
loading={exporting}
>
Export
</Button>
</SettingItem>
<SettingItem
label="Import contacts"
description="Importing vCard files is currently available on the web client only."
>
<Text style={[typography.caption, { color: colors.textMuted }]}>Web only</Text>
</SettingItem>
</SettingsSection>
);
}
+529
View File
@@ -0,0 +1,529 @@
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 { SettingsSection, SettingItem, Select, RadioGroup, ToggleSwitch } from './settings-section';
import { colors, spacing, radius, typography } from '../../theme/tokens';
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'];
function MailLayoutPreview({ value }: { value: MailLayout }) {
const isSplit = value === 'split';
return (
<View style={styles.layoutPreview}>
<View>
<Text style={styles.layoutTitle}>{isSplit ? 'Split' : 'Focus'}</Text>
<Text style={styles.layoutDesc}>
{isSplit ? 'List and reader side by side.' : 'Single column for focused reading.'}
</Text>
</View>
<View style={styles.layoutFrame}>
<View style={styles.layoutRail} />
{isSplit ? (
<>
<View style={styles.layoutList}>
{[1, 2, 3].map((i) => (
<View
key={i}
style={[styles.layoutRow, i === 2 && styles.layoutRowSel]}
>
<Text style={styles.layoutRowName}>Alice</Text>
<Text style={styles.layoutRowSub}>Subject line</Text>
</View>
))}
</View>
<View style={styles.layoutReader}>
<View style={[styles.layoutBar, { width: 80 }]} />
<View style={[styles.layoutBar, { width: '100%', marginTop: 8 }]} />
<View style={[styles.layoutBar, { width: '85%', marginTop: 6 }]} />
<View style={[styles.layoutBar, { width: '60%', marginTop: 6 }]} />
</View>
</>
) : (
<View style={{ flex: 1, padding: 8 }}>
{[1, 2, 3].map((i) => (
<View
key={i}
style={[styles.layoutFocus, i === 2 && styles.layoutFocusSel]}
>
<Text style={styles.layoutFocusText}>
<Text style={{ fontWeight: '500' }}>Alice </Text>
Subject line preview…
</Text>
</View>
))}
</View>
)}
</View>
</View>
);
}
export function EmailSettings() {
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 [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);
useEffect(() => {
if (!hydrated) void hydrate();
}, [hydrated, hydrate]);
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}
options={[
{ value: '0', label: 'Instant' },
{ value: '3000', label: 'After 3s' },
{ value: '5000', label: 'After 5s' },
{ value: '-1', label: 'Never' },
]}
/>
</SettingItem>
<View style={styles.group}>
<SettingItem label="Delete Action" description="Where deleted emails go." noBorder />
<Select
value={deleteAction}
onChange={(v) => setDeleteAction(v as DeleteAction)}
options={[
{ value: 'trash', label: 'Move to Trash' },
{ value: 'permanent', label: 'Permanently delete' },
]}
/>
{deleteAction === 'permanent' && (
<View style={styles.warning}>
<AlertTriangle size={14} color={colors.error} />
<Text style={styles.warningText}>Permanent deletion cannot be undone.</Text>
</View>
)}
<View style={styles.divider} />
</View>
<View style={styles.group}>
<SettingItem label="Archive Mode" description="Organize archive into subfolders." noBorder />
<Select
value={archiveMode}
onChange={(v) => setArchiveMode(v as ArchiveMode)}
options={[
{ value: 'single', label: 'Single folder' },
{ value: 'year', label: 'By year' },
{ value: 'month', label: 'By year/month' },
]}
/>
{archiveMode !== 'single' && (
<Pressable style={styles.inlineBtn}>
<FolderSync size={14} color={colors.text} />
<Text style={styles.inlineBtnText}>Reorganize existing archive</Text>
</Pressable>
)}
<View style={styles.divider} />
</View>
<SettingItem label="Permanently Delete Junk" description="Skip the trash when deleting spam.">
<ToggleSwitch checked={permanentlyDeleteJunk} onChange={setPermanentlyDeleteJunk} />
</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)}
options={[
{ value: 'split', label: 'Split' },
{ value: 'focus', label: 'Focus' },
]}
/>
<MailLayoutPreview value={mailLayout} />
<View style={styles.divider} />
</View>
<SettingItem label="Show Preview" description="Preview text under each subject.">
<ToggleSwitch checked={showPreview} onChange={setShowPreview} />
</SettingItem>
<SettingItem label="Disable Thread Grouping" description="Show emails individually instead of threaded.">
<ToggleSwitch checked={disableThreading} onChange={setDisableThreading} />
</SettingItem>
<SettingItem label="Plain Text Mode" description="Compose and read in plain text only.">
<ToggleSwitch checked={plainTextMode} onChange={setPlainTextMode} />
</SettingItem>
<SettingItem label="Auto-select Reply Identity" description="Pick the best identity when replying.">
<ToggleSwitch checked={autoSelectReplyIdentity} onChange={setAutoSelectReplyIdentity} />
</SettingItem>
<SettingItem label="Attachment Reminder" description="Warn when the word 'attached' is present but no file is attached.">
<ToggleSwitch
checked={attachmentReminderEnabled}
onChange={setAttachmentReminderEnabled}
/>
</SettingItem>
{attachmentReminderEnabled && (
<View style={styles.group}>
<Text style={styles.subLabel}>Trigger Keywords</Text>
<Text style={styles.subDesc}>Add words that should trigger the reminder.</Text>
<View style={styles.chipRow}>
{attachmentReminderKeywords.map((kw) => (
<View key={kw} style={styles.chip}>
<Text style={styles.chipText}>{kw}</Text>
<Pressable
onPress={() =>
setAttachmentReminderKeywords((list) => list.filter((k) => k !== kw))
}
hitSlop={6}
>
<X size={12} color={colors.mutedForeground} />
</Pressable>
</View>
))}
</View>
<View style={styles.addKeywordRow}>
<TextInput
value={newKeyword}
onChangeText={setNewKeyword}
placeholder="Add a keyword"
placeholderTextColor={colors.mutedForeground}
style={styles.keywordInput}
onSubmitEditing={() => {
const t = newKeyword.trim().toLowerCase();
if (t && !attachmentReminderKeywords.includes(t)) {
setAttachmentReminderKeywords([...attachmentReminderKeywords, t]);
}
setNewKeyword('');
}}
/>
<Pressable
style={styles.addKeywordBtn}
onPress={() => {
const t = newKeyword.trim().toLowerCase();
if (t && !attachmentReminderKeywords.includes(t)) {
setAttachmentReminderKeywords([...attachmentReminderKeywords, t]);
}
setNewKeyword('');
}}
>
<Text style={styles.addKeywordText}>Add</Text>
</Pressable>
</View>
<View style={styles.divider} />
</View>
)}
<SettingItem label="Hide Inline Image Attachments" description="Don't list inline images in the attachment list.">
<ToggleSwitch checked={hideInlineImageAttachments} onChange={setHideInlineImageAttachments} />
</SettingItem>
<SettingItem label="Attachment Click Action" description="What happens when you tap an attachment.">
<Select
value={mailAttachmentAction}
onChange={(v) => setMailAttachmentAction(v as AttachmentAction)}
options={[
{ value: 'preview', label: 'Preview' },
{ value: 'download', label: 'Download' },
]}
/>
</SettingItem>
<SettingItem label="Attachment Position" description="Where attachments appear in messages.">
<Select
value={attachmentPosition}
onChange={(v) => setAttachmentPosition(v as AttachmentPosition)}
options={[
{ value: 'beside-sender', label: 'Beside sender' },
{ value: 'below-header', label: 'Below header' },
]}
/>
</SettingItem>
<SettingItem label="Emails Per Page" description="How many emails to load at a time.">
<Select
value={emailsPerPage}
onChange={setEmailsPerPage}
options={[
{ value: '10', label: '10' },
{ value: '25', label: '25' },
{ value: '50', label: '50' },
{ value: '100', label: '100' },
]}
/>
</SettingItem>
<SettingItem label="Always Light Mode for Emails" description="Render email content on a white background.">
<ToggleSwitch checked={emailAlwaysLightMode} onChange={setEmailAlwaysLightMode} />
</SettingItem>
<SettingItem label="External Content" description="How to handle images and remote resources in incoming email.">
<Select
value={externalContentPolicy}
onChange={(v) => setExternalContentPolicyStore(v as ExternalContentPolicy)}
options={[
{ value: 'ask', label: 'Ask' },
{ value: 'block', label: 'Block' },
{ value: 'allow', label: 'Allow' },
]}
/>
</SettingItem>
<View style={styles.group}>
<SettingItem
label="Trusted Senders"
description="Senders whose external content loads without asking."
noBorder
>
<Pressable style={styles.inlineBtn} onPress={() => setShowTrustedList((v) => !v)}>
<Text style={styles.inlineBtnText}>
{trustedSenders.length === 0
? 'No trusted senders'
: `${trustedSenders.length} sender${trustedSenders.length === 1 ? '' : 's'}`}
</Text>
</Pressable>
</SettingItem>
{showTrustedList && trustedSenders.length > 0 && (
<View style={styles.trustedList}>
{trustedSenders.map((email) => (
<View key={email} style={styles.trustedRow}>
<Text style={styles.trustedEmail} numberOfLines={1}>{email}</Text>
<Pressable onPress={() => removeTrustedSender(email)} hitSlop={8}>
<X size={14} color={colors.mutedForeground} />
</Pressable>
</View>
))}
</View>
)}
<View style={styles.divider} />
</View>
<SettingItem label="Sync Trusted Senders" description="Store trusted senders in the address book.">
<ToggleSwitch
checked={trustedSendersAddressBook}
onChange={setTrustedSendersAddressBook}
/>
</SettingItem>
</SettingsSection>
);
}
const styles = StyleSheet.create({
group: {},
warning: {
flexDirection: 'row',
gap: spacing.sm,
padding: spacing.sm,
backgroundColor: 'rgba(239,68,68,0.1)',
borderRadius: radius.sm,
marginTop: spacing.sm,
},
warningText: {
...typography.caption,
color: colors.error,
flex: 1,
},
divider: {
height: 1,
backgroundColor: colors.border,
marginTop: spacing.md,
},
inlineBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: 6,
backgroundColor: colors.muted,
borderRadius: radius.sm,
alignSelf: 'flex-start',
marginTop: spacing.sm,
},
inlineBtnText: {
...typography.body,
color: colors.text,
},
subLabel: {
...typography.bodyMedium,
color: colors.text,
marginTop: spacing.sm,
},
subDesc: {
...typography.caption,
color: colors.mutedForeground,
marginTop: 2,
marginBottom: spacing.sm,
},
chipRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 6,
},
chip: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: radius.full,
backgroundColor: colors.muted,
},
chipText: {
...typography.caption,
color: colors.text,
},
addKeywordRow: {
flexDirection: 'row',
gap: spacing.sm,
marginTop: spacing.sm,
},
keywordInput: {
flex: 1,
height: 32,
paddingHorizontal: spacing.sm,
borderWidth: 1,
borderColor: colors.border,
borderRadius: radius.sm,
backgroundColor: colors.background,
color: colors.text,
...typography.body,
},
addKeywordBtn: {
paddingHorizontal: spacing.md,
justifyContent: 'center',
backgroundColor: colors.muted,
borderRadius: radius.sm,
},
addKeywordText: {
...typography.body,
color: colors.text,
},
layoutPreview: {
marginTop: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.background,
padding: spacing.md,
},
layoutTitle: {
...typography.bodyMedium,
color: colors.text,
},
layoutDesc: {
...typography.caption,
color: colors.mutedForeground,
marginTop: 2,
},
layoutFrame: {
flexDirection: 'row',
height: 112,
marginTop: spacing.md,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
overflow: 'hidden',
backgroundColor: 'rgba(24,24,27,0.4)',
},
layoutRail: {
width: 32,
borderRightWidth: 1,
borderRightColor: colors.border,
backgroundColor: 'rgba(24,24,27,0.6)',
},
layoutList: {
width: 96,
borderRightWidth: 1,
borderRightColor: colors.border,
backgroundColor: colors.background,
},
layoutRow: {
paddingHorizontal: 6,
paddingVertical: 4,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
layoutRowSel: {
backgroundColor: 'rgba(59,130,246,0.1)',
},
layoutRowName: {
fontSize: 9,
fontWeight: '500',
color: colors.text,
},
layoutRowSub: {
fontSize: 9,
color: colors.mutedForeground,
},
layoutReader: {
flex: 1,
padding: spacing.md,
backgroundColor: colors.background,
},
layoutBar: {
height: 6,
borderRadius: 3,
backgroundColor: 'rgba(250,250,250,0.1)',
},
layoutFocus: {
paddingHorizontal: spacing.sm,
paddingVertical: 4,
backgroundColor: 'rgba(24,24,27,0.4)',
borderRadius: radius.xs,
marginBottom: 4,
},
layoutFocusSel: {
backgroundColor: 'rgba(59,130,246,0.1)',
},
layoutFocusText: {
fontSize: 9,
color: colors.text,
},
trustedList: {
marginTop: spacing.sm,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.muted,
},
trustedRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.md,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
trustedEmail: { ...typography.caption, color: colors.text, flex: 1 },
});
+262
View File
@@ -0,0 +1,262 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import {
Folder, FileText, FileCode, FileAudio, File, Image as ImageIcon,
} from 'lucide-react-native';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import { colors, spacing, radius, typography } from '../../theme/tokens';
type FolderLayout = 'inline' | 'sidebar';
type ViewMode = 'list' | 'grid';
type SortKey = 'name' | 'size' | 'modified';
type SortDir = 'asc' | 'desc';
interface FilesPrefs {
folderLayout: FolderLayout;
defaultViewMode: ViewMode;
defaultSortKey: SortKey;
defaultSortDir: SortDir;
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;
size: number;
modified: string;
hidden?: boolean;
}
const SAMPLE: SampleFile[] = [
{ name: 'Documents', isFolder: true, size: 0, modified: '03-10' },
{ name: 'Photos', isFolder: true, size: 0, modified: '03-14' },
{ name: 'report.pdf', isFolder: false, size: 245000, modified: '03-15' },
{ name: 'notes.md', isFolder: false, size: 1200, modified: '03-12' },
{ name: 'song.mp3', isFolder: false, size: 5200000, modified: '03-01' },
{ name: '.config', isFolder: false, size: 340, modified: '02-20', hidden: true },
];
function formatSize(b: number) {
if (b === 0) return '-';
if (b < 1024) return `${b} B`;
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
return `${(b / (1024 * 1024)).toFixed(1)} MB`;
}
function getIcon(file: SampleFile, colored: boolean, sz: number) {
if (file.isFolder) return <Folder size={sz} color={colored ? '#60a5fa' : colors.mutedForeground} />;
const ext = file.name.split('.').pop()?.toLowerCase();
switch (ext) {
case 'jpg': case 'png': case 'gif':
return <ImageIcon size={sz} color={colored ? '#4ade80' : colors.mutedForeground} />;
case 'mp3': case 'wav':
return <FileAudio size={sz} color={colored ? '#a78bfa' : colors.mutedForeground} />;
case 'pdf':
return <FileText size={sz} color={colored ? '#f87171' : colors.mutedForeground} />;
case 'md': case 'json': case 'js': case 'ts':
return <FileCode size={sz} color={colored ? '#fbbf24' : colors.mutedForeground} />;
default:
return <File size={sz} color={colors.mutedForeground} />;
}
}
function FilesPreview({ prefs }: { prefs: FilesPrefs }) {
const files = SAMPLE.filter((f) => {
if (!prefs.showHiddenFiles && f.hidden) return false;
return true;
}).sort((a, b) => {
if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1;
let cmp = 0;
if (prefs.defaultSortKey === 'name') cmp = a.name.localeCompare(b.name);
if (prefs.defaultSortKey === 'size') cmp = a.size - b.size;
if (prefs.defaultSortKey === 'modified') cmp = a.modified.localeCompare(b.modified);
return prefs.defaultSortDir === 'desc' ? -cmp : cmp;
});
if (prefs.defaultViewMode === 'grid') {
return (
<View style={styles.previewBox}>
<View style={styles.gridWrap}>
{files.map((f) => (
<View key={f.name} style={[styles.gridItem, f.hidden && { opacity: 0.5 }]}>
{prefs.showIcons ? getIcon(f, prefs.coloredIcons, 24) : <View style={{ width: 24, height: 24 }} />}
<Text style={styles.gridName} numberOfLines={1}>{f.name}</Text>
</View>
))}
</View>
</View>
);
}
return (
<View style={styles.previewBox}>
<View style={styles.listHeader}>
<Text style={[styles.listHeaderText, { flex: 1 }]}>Name</Text>
<Text style={[styles.listHeaderText, { width: 60, textAlign: 'right' }]}>Size</Text>
<Text style={[styles.listHeaderText, { width: 60, textAlign: 'right' }]}>Modified</Text>
</View>
{files.map((f) => (
<View key={f.name} style={[styles.listRow, f.hidden && { opacity: 0.5 }]}>
{prefs.showIcons && getIcon(f, prefs.coloredIcons, 14)}
<Text
style={[styles.listName, f.isFolder && { fontWeight: '500' }]}
numberOfLines={1}
>
{f.name}
</Text>
<Text style={styles.listMeta}>{formatSize(f.size)}</Text>
<Text style={styles.listMeta}>{f.modified}</Text>
</View>
))}
</View>
);
}
export function FilesSettings() {
const [prefs, setPrefs] = useState<FilesPrefs>(DEFAULTS);
const update = (patch: Partial<FilesPrefs>) => setPrefs((p) => ({ ...p, ...patch }));
return (
<View style={styles.container}>
<View>
<Text style={styles.previewLabel}>Preview</Text>
<FilesPreview prefs={prefs} />
</View>
<SettingsSection title="Display" description="How files and folders are presented.">
<SettingItem label="Folder layout" description="Choose how folders appear in the browser.">
<RadioGroup
value={prefs.folderLayout}
onChange={(v) => update({ folderLayout: v as FolderLayout })}
options={[
{ value: 'inline', label: 'Inline' },
{ value: 'sidebar', label: 'Sidebar' },
]}
/>
</SettingItem>
<SettingItem label="Default view" description="List or grid view.">
<RadioGroup
value={prefs.defaultViewMode}
onChange={(v) => update({ defaultViewMode: v as ViewMode })}
options={[
{ value: 'list', label: 'List' },
{ value: 'grid', label: 'Grid' },
]}
/>
</SettingItem>
<SettingItem label="Default sort" description="Primary sort key.">
<RadioGroup
value={prefs.defaultSortKey}
onChange={(v) => update({ defaultSortKey: v as SortKey })}
options={[
{ value: 'name', label: 'Name' },
{ value: 'size', label: 'Size' },
{ value: 'modified', label: 'Modified' },
]}
/>
</SettingItem>
<SettingItem label="Sort direction">
<RadioGroup
value={prefs.defaultSortDir}
onChange={(v) => update({ defaultSortDir: v as SortDir })}
options={[
{ value: 'asc', label: 'Ascending' },
{ value: 'desc', label: 'Descending' },
]}
/>
</SettingItem>
</SettingsSection>
<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 })} />
</SettingItem>
<SettingItem label="Colored icons" description="Use file-type-specific colors.">
<ToggleSwitch
checked={prefs.coloredIcons}
onChange={(v) => update({ coloredIcons: v })}
disabled={!prefs.showIcons}
/>
</SettingItem>
<SettingItem label="Show thumbnails" description="Generate previews for images.">
<ToggleSwitch checked={prefs.showThumbnails} onChange={(v) => update({ showThumbnails: 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 })}
/>
</SettingItem>
</SettingsSection>
</View>
);
}
const styles = StyleSheet.create({
container: { gap: spacing.xxxl },
previewLabel: { ...typography.bodyMedium, color: colors.text, marginBottom: spacing.sm },
previewBox: {
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.background,
overflow: 'hidden',
minHeight: 160,
},
listHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingVertical: 4,
paddingHorizontal: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.border,
backgroundColor: colors.muted,
},
listHeaderText: { fontSize: 10, fontWeight: '500', color: colors.mutedForeground },
listRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingVertical: 6,
paddingHorizontal: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
listName: { fontSize: 11, color: colors.text, flex: 1 },
listMeta: { fontSize: 10, color: colors.mutedForeground, width: 60, textAlign: 'right' },
gridWrap: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 6,
padding: spacing.sm,
},
gridItem: {
alignItems: 'center',
gap: 4,
padding: spacing.sm,
width: 72,
borderRadius: radius.sm,
},
gridName: { fontSize: 9, color: colors.text, textAlign: 'center' },
});
+171
View File
@@ -0,0 +1,171 @@
import React, { 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 { colors, spacing, radius, typography } from '../../theme/tokens';
interface Rule {
id: string;
name: string;
enabled: boolean;
summary: string;
action: string;
}
const SAMPLE_RULES: Rule[] = [];
export function FilterSettings() {
const [rules, setRules] = useState<Rule[]>(SAMPLE_RULES);
const [expandedView, setExpandedView] = useState(false);
const [isOpaque] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(null);
const toggle = (id: string) => {
setRules((rs) => rs.map((r) => r.id === id ? { ...r, enabled: !r.enabled } : r));
};
const remove = (id: string) => {
setRules((rs) => rs.filter((r) => r.id !== id));
setDeleteId(null);
};
return (
<View style={styles.container}>
<SettingsSection title="Filters & Rules" description="Server-side rules that process incoming mail.">
{isOpaque && (
<View style={styles.opaqueWarning}>
<AlertTriangle size={16} color="#fbbf24" />
<View style={{ flex: 1 }}>
<Text style={styles.opaqueText}>
Your Sieve script contains advanced features. Edit raw or reset to visual builder.
</Text>
<View style={styles.opaqueActions}>
<Pressable>
<Text style={styles.linkText}>Open Sieve Editor</Text>
</Pressable>
<Pressable style={styles.resetLink}>
<RotateCcw size={12} color={colors.error} />
<Text style={styles.resetText}>Reset to visual</Text>
</Pressable>
</View>
</View>
</View>
)}
{rules.length === 0 && !isOpaque && (
<View style={styles.emptyState}>
<Filter size={40} color={colors.mutedForeground} style={{ opacity: 0.4 }} />
<Text style={styles.emptyText}>No rules yet.</Text>
</View>
)}
{rules.length > 0 && (
<View style={{ gap: 4 }}>
{rules.map((rule) => (
<View key={rule.id} style={[styles.ruleRow, !rule.enabled && styles.ruleDisabled]}>
<View style={styles.grip}>
<GripVertical size={16} color={colors.mutedForeground} />
</View>
<ToggleSwitch checked={rule.enabled} onChange={() => toggle(rule.id)} />
<View style={{ flex: 1 }}>
<Text style={styles.ruleName}>{rule.name}</Text>
<Text style={styles.ruleSummary} numberOfLines={expandedView ? 0 : 1}>
{rule.summary} → {rule.action}
</Text>
</View>
{deleteId === rule.id ? (
<View style={{ flexDirection: 'row', gap: 4 }}>
<Button variant="destructive" size="sm" onPress={() => remove(rule.id)}>
Confirm
</Button>
<Button variant="ghost" size="sm" onPress={() => setDeleteId(null)}>
Cancel
</Button>
</View>
) : (
<Pressable style={styles.iconBtn} onPress={() => setDeleteId(rule.id)}>
<X size={16} color={colors.mutedForeground} />
</Pressable>
)}
</View>
))}
</View>
)}
</SettingsSection>
<View style={styles.footer}>
<View style={{ flexDirection: 'row', gap: spacing.sm }}>
{!isOpaque && (
<Button variant="outline" size="sm" icon={<Plus size={14} color={colors.text} />}>
Add Rule
</Button>
)}
<Button variant="outline" size="sm" icon={<Code size={14} color={colors.text} />}>
Raw Editor
</Button>
</View>
{!isOpaque && rules.length > 0 && (
<View style={styles.expandedToggle}>
<Text style={styles.expandedLabel}>Expanded view</Text>
<ToggleSwitch checked={expandedView} onChange={setExpandedView} />
</View>
)}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { gap: spacing.xl },
opaqueWarning: {
flexDirection: 'row',
gap: spacing.sm,
padding: spacing.md,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: 'rgba(252, 211, 77, 0.4)',
backgroundColor: 'rgba(120, 53, 15, 0.25)',
alignItems: 'flex-start',
},
opaqueText: { ...typography.caption, color: '#fde68a' },
opaqueActions: { flexDirection: 'row', gap: spacing.md, marginTop: spacing.sm },
linkText: { ...typography.caption, color: colors.primary, fontWeight: '500' },
resetLink: { flexDirection: 'row', alignItems: 'center', gap: 4 },
resetText: { ...typography.caption, color: colors.error, fontWeight: '500' },
emptyState: { alignItems: 'center', paddingVertical: spacing.xxl, gap: spacing.md },
emptyText: { ...typography.body, color: colors.mutedForeground },
ruleRow: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.md,
padding: spacing.md,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
},
ruleDisabled: { opacity: 0.6 },
grip: { paddingTop: 2 },
ruleName: { ...typography.bodyMedium, color: colors.text },
ruleSummary: { ...typography.caption, color: colors.mutedForeground, marginTop: 2 },
iconBtn: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.sm,
},
footer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.md,
flexWrap: 'wrap',
},
expandedToggle: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
expandedLabel: { ...typography.caption, color: colors.mutedForeground },
});
+127
View File
@@ -0,0 +1,127 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native';
import {
Folder, Inbox, Send, FileText, Trash, ShieldAlert, Archive,
} from 'lucide-react-native';
import { SettingsSection, SettingItem } from './settings-section';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { useEmailStore } from '../../stores/email-store';
import type { Mailbox } from '../../api/types';
const ROLE_LABEL: Record<string, string> = {
inbox: 'Inbox', drafts: 'Drafts', sent: 'Sent', trash: 'Trash',
junk: 'Junk', archive: 'Archive', important: 'Important', all: 'All',
};
const ROLE_ICON: Record<string, any> = {
inbox: Inbox, drafts: FileText, sent: Send, trash: Trash,
junk: ShieldAlert, archive: Archive,
};
function getIcon(mb: Mailbox) {
if (mb.role && ROLE_ICON[mb.role]) return ROLE_ICON[mb.role];
return Folder;
}
export function FolderSettings() {
const mailboxes = useEmailStore((s) => s.mailboxes);
const fetchMailboxes = useEmailStore((s) => s.fetchMailboxes);
useEffect(() => {
if (mailboxes.length === 0) void fetchMailboxes();
}, [mailboxes.length, fetchMailboxes]);
const sorted = [...mailboxes].sort((a, b) => {
const ar = a.role ? 0 : 1;
const br = b.role ? 0 : 1;
if (ar !== br) return ar - br;
return a.name.localeCompare(b.name);
});
const totalUnread = mailboxes.reduce((sum, m) => sum + (m.unreadEmails ?? 0), 0);
return (
<View style={styles.container}>
<SettingsSection
title="Folders"
description={`Your mail folders. ${mailboxes.length} folder${mailboxes.length === 1 ? '' : 's'}, ${totalUnread} unread.`}
>
{mailboxes.length === 0 ? (
<View style={styles.loading}>
<ActivityIndicator size="small" color={colors.primary} />
</View>
) : (
<View>
{sorted.map((mb) => {
const Icon = getIcon(mb);
return (
<View key={mb.id} style={styles.folderRow}>
<View style={styles.folderLeft}>
<Icon
size={16}
color={mb.role ? colors.primary : colors.mutedForeground}
/>
<Text style={styles.folderName} numberOfLines={1}>{mb.name}</Text>
{mb.role && ROLE_LABEL[mb.role] && (
<View style={styles.rolePill}>
<Text style={styles.rolePillText}>{ROLE_LABEL[mb.role]}</Text>
</View>
)}
</View>
<View style={styles.folderRight}>
{mb.unreadEmails > 0 && (
<View style={styles.unreadBadge}>
<Text style={styles.unreadText}>{mb.unreadEmails}</Text>
</View>
)}
<Text style={styles.total}>{mb.totalEmails}</Text>
</View>
</View>
);
})}
</View>
)}
</SettingsSection>
<SettingsSection
title="Management"
description="Creating, renaming and deleting folders is not yet available in the mobile app. Use the web client."
>
<SettingItem label="Read-only">
<Text style={styles.caption}>Folder list shown above is live.</Text>
</SettingItem>
</SettingsSection>
</View>
);
}
const styles = StyleSheet.create({
container: { gap: spacing.xxxl },
loading: { paddingVertical: 40, alignItems: 'center' },
folderRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.sm,
},
folderLeft: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flex: 1 },
folderName: { ...typography.body, color: colors.text, flexShrink: 1 },
rolePill: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: radius.full,
backgroundColor: colors.primaryBg,
},
rolePillText: { fontSize: 10, fontWeight: '500', color: colors.primary },
folderRight: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
unreadBadge: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: radius.full,
backgroundColor: colors.primary,
},
unreadText: { fontSize: 10, fontWeight: '500', color: colors.primaryForeground },
total: { ...typography.caption, color: colors.mutedForeground, minWidth: 32, textAlign: 'right' },
caption: { ...typography.caption, color: colors.mutedForeground },
});
@@ -0,0 +1,104 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native';
import { SettingsSection, SettingItem } from './settings-section';
import Button from '../Button';
import { colors, typography, spacing, radius } from '../../theme/tokens';
import { useSettingsStore } from '../../stores/settings-store';
export function IdentitySettings() {
const identities = useSettingsStore((s) => s.identities);
const loading = useSettingsStore((s) => s.loading);
const error = useSettingsStore((s) => s.error);
const fetchIdentities = useSettingsStore((s) => s.fetchIdentities);
useEffect(() => {
void fetchIdentities();
}, [fetchIdentities]);
const count = identities.length;
const countLabel =
count === 0 ? 'No identities' : count === 1 ? '1 identity' : `${count} identities`;
return (
<SettingsSection
title="Identities"
description="Manage sender names, email addresses, and signatures."
>
<SettingItem
label="Identities"
description="Your configured sender identities."
>
<View style={styles.row}>
{loading ? (
<ActivityIndicator size="small" color={colors.primary} />
) : (
<Text style={styles.count}>{countLabel}</Text>
)}
</View>
</SettingItem>
{error && (
<View style={styles.errorBox}>
<Text style={styles.errorText}>{error}</Text>
</View>
)}
{identities.map((identity) => (
<View key={identity.id} style={styles.identityRow}>
<View style={{ flex: 1 }}>
<Text style={styles.identityName}>{identity.name || '(no name)'}</Text>
<Text style={styles.identityEmail}>{identity.email}</Text>
</View>
{!identity.mayDelete && (
<View style={styles.badge}>
<Text style={styles.badgeText}>primary</Text>
</View>
)}
</View>
))}
<SettingItem
label="Sub-addressing"
description="Use plus-addressing (you+tag@example.com) to route mail."
>
<Button variant="outline" size="sm">Learn More</Button>
</SettingItem>
</SettingsSection>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
count: {
...typography.body,
color: colors.text,
},
errorBox: {
padding: spacing.md,
borderRadius: radius.sm,
backgroundColor: colors.errorBg,
},
errorText: { ...typography.caption, color: colors.error },
identityRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md,
borderRadius: radius.sm,
backgroundColor: colors.muted,
},
identityName: { ...typography.bodyMedium, color: colors.text },
identityEmail: { ...typography.caption, color: colors.mutedForeground, marginTop: 2 },
badge: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: radius.full,
backgroundColor: 'rgba(59,130,246,0.15)',
},
badgeText: { fontSize: 10, fontWeight: '500', color: colors.primary },
});
+273
View File
@@ -0,0 +1,273 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
import { Plus, Pencil, Trash2, Check, X, RotateCcw } from 'lucide-react-native';
import { SettingsSection } from './settings-section';
import { colors, spacing, radius, typography } from '../../theme/tokens';
interface Keyword {
id: string;
label: string;
color: keyof typeof colors.tags;
}
const PALETTE_KEYS = Object.keys(colors.tags) as (keyof typeof colors.tags)[];
const DEFAULT_KEYWORDS: Keyword[] = [
{ id: 'important', label: 'Important', color: 'red' },
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'personal', label: 'Personal', color: 'green' },
{ id: 'todo', label: 'Todo', color: 'amber' },
];
export function KeywordSettings() {
const [keywords, setKeywords] = useState<Keyword[]>(DEFAULT_KEYWORDS);
const [editingId, setEditingId] = useState<string | null>(null);
const [isAdding, setIsAdding] = useState(false);
const saveKeyword = (kw: Keyword, editing: boolean) => {
if (editing) {
setKeywords((ks) => ks.map((k) => k.id === editingId ? kw : k));
setEditingId(null);
} else {
setKeywords((ks) => [...ks, kw]);
setIsAdding(false);
}
};
const deleteKeyword = (id: string) => {
setKeywords((ks) => ks.filter((k) => k.id !== id));
};
const resetDefaults = () => {
setKeywords(DEFAULT_KEYWORDS);
};
return (
<SettingsSection title="Keywords & Labels" description="Colored tags to organize your mail.">
<View style={{ gap: spacing.sm }}>
{keywords.map((kw) => {
if (editingId === kw.id) {
return (
<KeywordForm
key={kw.id}
initial={kw}
existingIds={keywords.filter((k) => k.id !== kw.id).map((k) => k.id)}
onSave={(k) => saveKeyword(k, true)}
onCancel={() => setEditingId(null)}
/>
);
}
const palette = colors.tags[kw.color];
return (
<View key={kw.id} style={styles.kwRow}>
<View style={[styles.kwDot, { backgroundColor: palette.dot }]} />
<Text style={styles.kwLabel}>{kw.label}</Text>
<Text style={styles.kwId}>$label:{kw.id}</Text>
<View style={{ flexDirection: 'row', gap: 2 }}>
<Pressable style={styles.iconBtn} onPress={() => setEditingId(kw.id)}>
<Pencil size={14} color={colors.mutedForeground} />
</Pressable>
<Pressable style={styles.iconBtn} onPress={() => deleteKeyword(kw.id)}>
<Trash2 size={14} color={colors.mutedForeground} />
</Pressable>
</View>
</View>
);
})}
{isAdding && (
<KeywordForm
existingIds={keywords.map((k) => k.id)}
onSave={(k) => saveKeyword(k, false)}
onCancel={() => setIsAdding(false)}
/>
)}
{!isAdding && editingId === null && (
<View style={styles.bottomActions}>
<Pressable style={styles.outlineBtn} onPress={() => setIsAdding(true)}>
<Plus size={14} color={colors.mutedForeground} />
<Text style={styles.outlineBtnText}>Add keyword</Text>
</Pressable>
<Pressable style={styles.outlineBtn} onPress={resetDefaults}>
<RotateCcw size={14} color={colors.mutedForeground} />
<Text style={styles.outlineBtnText}>Reset defaults</Text>
</Pressable>
</View>
)}
</View>
</SettingsSection>
);
}
interface KeywordFormProps {
initial?: Keyword;
existingIds: string[];
onSave: (kw: Keyword) => void;
onCancel: () => void;
}
function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProps) {
const [label, setLabel] = useState(initial?.label ?? '');
const [color, setColor] = useState<keyof typeof colors.tags>(initial?.color ?? 'blue');
const normalizedId = label
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
const isDuplicate = normalizedId.length > 0 && existingIds.includes(normalizedId);
const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate;
const handleSave = () => {
if (!isValid) return;
onSave({ id: normalizedId, label: label.trim(), color });
};
return (
<View style={styles.form}>
<View>
<Text style={styles.formLabel}>Label</Text>
<TextInput
value={label}
onChangeText={setLabel}
placeholder="e.g. Important"
placeholderTextColor={colors.mutedForeground}
style={styles.input}
maxLength={30}
autoFocus
/>
{isDuplicate && <Text style={styles.errorText}>An ID with that name already exists.</Text>}
</View>
<View>
<Text style={styles.formLabel}>Color</Text>
<View style={styles.palette}>
{PALETTE_KEYS.map((key) => {
const p = colors.tags[key];
return (
<Pressable
key={key}
onPress={() => setColor(key)}
style={[
styles.colorSwatch,
{ backgroundColor: p.dot },
color === key && styles.colorSwatchSelected,
]}
/>
);
})}
</View>
</View>
<View style={styles.formActions}>
<Pressable style={styles.cancelFormBtn} onPress={onCancel}>
<X size={14} color={colors.text} />
<Text style={styles.cancelFormText}>Cancel</Text>
</Pressable>
<Pressable
style={[styles.saveBtn, !isValid && { opacity: 0.5 }]}
onPress={handleSave}
disabled={!isValid}
>
<Check size={14} color={colors.primaryForeground} />
<Text style={styles.saveBtnText}>{initial ? 'Save' : 'Add'}</Text>
</Pressable>
</View>
</View>
);
}
const styles = StyleSheet.create({
kwRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
padding: spacing.md,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.background,
},
kwDot: { width: 20, height: 20, borderRadius: 10 },
kwLabel: { ...typography.bodyMedium, color: colors.text, flex: 1 },
kwId: { fontSize: 10, color: colors.mutedForeground, fontFamily: 'monospace' },
iconBtn: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.sm,
},
bottomActions: {
flexDirection: 'row',
gap: spacing.sm,
paddingTop: spacing.sm,
},
outlineBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radius.sm,
borderWidth: 1,
borderStyle: 'dashed',
borderColor: colors.border,
},
outlineBtnText: { ...typography.caption, color: colors.mutedForeground },
form: {
gap: spacing.md,
padding: spacing.md,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.primaryBorder,
backgroundColor: colors.accent,
},
formLabel: { ...typography.caption, color: colors.mutedForeground, marginBottom: 4 },
input: {
paddingHorizontal: spacing.sm,
paddingVertical: 6,
borderRadius: radius.sm,
backgroundColor: colors.background,
borderWidth: 1,
borderColor: colors.border,
color: colors.text,
...typography.body,
},
errorText: { ...typography.caption, color: colors.error, marginTop: 4 },
palette: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
colorSwatch: { width: 24, height: 24, borderRadius: 12 },
colorSwatchSelected: {
borderWidth: 2,
borderColor: colors.text,
},
formActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm,
},
cancelFormBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
},
cancelFormText: { ...typography.caption, color: colors.text },
saveBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radius.sm,
backgroundColor: colors.primary,
},
saveBtnText: { ...typography.caption, color: colors.primaryForeground, fontWeight: '500' },
});
@@ -0,0 +1,271 @@
import React, { useEffect, useState } from 'react';
import {
ActivityIndicator,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { CheckCircle2, Volume2, XCircle } from 'lucide-react-native';
import Button from '../Button';
import Dialog from '../Dialog';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { colors, radius, spacing, typography } from '../../theme/tokens';
import { useAuthStore } from '../../stores/auth-store';
import {
DEFAULT_RELAY_BASE_URL,
getStoredRelayBaseUrl,
setStoredRelayBaseUrl,
setupPushNotifications,
teardownPushNotifications,
} from '../../lib/push-notifications';
const SOUNDS = [
{ value: 'default', label: 'Default' },
{ value: 'chime', label: 'Chime' },
{ value: 'ping', label: 'Ping' },
{ value: 'pop', label: 'Pop' },
{ value: 'none', label: 'Silent' },
];
type PushStatus =
| { kind: 'idle' }
| { kind: 'busy'; message: string }
| { kind: 'enabled' }
| { kind: 'error'; message: string };
export function NotificationSettings() {
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 [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(() => {
void (async () => {
const stored = await getStoredRelayBaseUrl();
if (stored) {
setRelayUrl(stored);
setHasSaved(true);
setPushStatus({ kind: 'enabled' });
}
})();
}, []);
const trimmed = relayUrl.trim().replace(/\/+$/, '');
const isValidUrl = /^https?:\/\/.+/i.test(trimmed);
const busy = pushStatus.kind === 'busy';
const canEnable = isValidUrl && !busy;
const handleEnable = async () => {
if (!isValidUrl) {
setPushStatus({ kind: 'error', message: 'Enter a valid https:// URL' });
return;
}
setPushStatus({ kind: 'busy', message: 'Registering device…' });
try {
await setStoredRelayBaseUrl(trimmed);
await setupPushNotifications({
relayBaseUrl: trimmed,
accountLabel: username ?? undefined,
});
setHasSaved(true);
setPushStatus({ kind: 'enabled' });
} catch (error) {
setPushStatus({
kind: 'error',
message: error instanceof Error ? error.message : 'Setup failed',
});
}
};
const handleDisable = () => {
setConfirmDisable(true);
};
const performDisable = async () => {
setConfirmDisable(false);
setPushStatus({ kind: 'busy', message: 'Disabling…' });
try {
await teardownPushNotifications();
await setStoredRelayBaseUrl(null);
setRelayUrl(DEFAULT_RELAY_BASE_URL);
setHasSaved(false);
setPushStatus({ kind: 'idle' });
} catch (error) {
setPushStatus({
kind: 'error',
message: error instanceof Error ? error.message : 'Teardown failed',
});
}
};
return (
<View style={styles.container}>
<SettingsSection
title="Push Notifications"
description="Delivered via the Bulwark push relay. The relay only sees an opaque token and new-mail timing — never mail content."
>
<View style={styles.pushCard}>
<View style={styles.pushHeader}>
<Text style={styles.fieldLabel}>Relay base URL</Text>
<View style={styles.statusRow}>
{pushStatus.kind === 'busy' && (
<>
<ActivityIndicator size="small" color={colors.primary} />
<Text style={styles.statusText}>{pushStatus.message}</Text>
</>
)}
{pushStatus.kind === 'enabled' && (
<>
<CheckCircle2 size={14} color={colors.success} />
<Text style={[styles.statusText, { color: colors.success }]}>Active</Text>
</>
)}
{pushStatus.kind === 'error' && (
<>
<XCircle size={14} color={colors.error} />
<Text style={[styles.statusText, { color: colors.error }]}>
{pushStatus.message}
</Text>
</>
)}
{pushStatus.kind === 'idle' && (
<Text style={[styles.statusText, { color: colors.mutedForeground }]}>
Not configured
</Text>
)}
</View>
</View>
<Text style={styles.fieldDescription}>
Defaults to the hosted Bulwark relay. Change only if you self-host.
</Text>
<TextInput
value={relayUrl}
onChangeText={setRelayUrl}
placeholder={DEFAULT_RELAY_BASE_URL}
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
editable={!busy}
style={styles.urlInput}
/>
<View style={styles.actions}>
<Button
variant="default"
onPress={handleEnable}
disabled={!canEnable}
loading={busy}
>
{hasSaved ? 'Re-register' : 'Enable'}
</Button>
{hasSaved && (
<Button variant="outline" onPress={handleDisable} disabled={busy}>
Disable
</Button>
)}
</View>
</View>
</SettingsSection>
<SettingsSection title="Sound Selection" description="Pick the sound used for new notifications.">
<SettingItem label="Notification Sound" description="Played when a new notification arrives.">
<View style={styles.row}>
<Button
variant="ghost"
size="icon"
icon={<Volume2 size={16} color={colors.text} />}
/>
<Select
value={sound}
onChange={setSound}
options={SOUNDS}
/>
</View>
</SettingItem>
</SettingsSection>
<SettingsSection title="Email" description="Notifications for incoming email.">
<SettingItem label="Email Notifications" description="Show desktop alerts for new email.">
<ToggleSwitch checked={emailEnabled} onChange={setEmailEnabled} />
</SettingItem>
<SettingItem label="Email Sound" description="Play a sound when new email arrives.">
<ToggleSwitch checked={emailSound} onChange={setEmailSound} 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} />
</SettingItem>
<SettingItem label="Calendar Sound" description="Play a sound for calendar alerts.">
<ToggleSwitch checked={calSound} onChange={setCalSound} disabled={!calEnabled} />
</SettingItem>
<SettingItem label="Invitation Parsing" description="Automatically detect and parse event invitations.">
<ToggleSwitch checked={invitationParsing} onChange={setInvitationParsing} />
</SettingItem>
</SettingsSection>
<Dialog
visible={confirmDisable}
variant="destructive"
title="Disable push notifications?"
message="The device will stop receiving new-mail alerts from this server."
confirmText="Disable"
cancelText="Cancel"
onCancel={() => setConfirmDisable(false)}
onConfirm={performDisable}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { gap: spacing.xxxl },
row: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
pushCard: {
borderWidth: 1,
borderColor: colors.border,
borderRadius: radius.md,
padding: spacing.lg,
gap: spacing.sm,
},
pushHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.md,
},
fieldLabel: {
...typography.bodyMedium,
color: colors.text,
},
fieldDescription: {
...typography.caption,
color: colors.mutedForeground,
},
urlInput: {
...typography.body,
color: colors.text,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: 8,
alignSelf: 'stretch',
marginTop: 4,
},
statusRow: { flexDirection: 'row', alignItems: 'center', gap: 6, flexShrink: 1 },
statusText: { ...typography.caption, color: colors.text },
actions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginTop: spacing.md },
});
+224
View File
@@ -0,0 +1,224 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Upload, Puzzle, AlertTriangle, Lock, Trash2, ChevronDown, ChevronRight } from 'lucide-react-native';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { colors, spacing, radius, typography } from '../../theme/tokens';
interface Plugin {
id: string;
name: string;
author: string;
version: string;
type: string;
description?: string;
enabled: boolean;
status: 'installed' | 'enabled' | 'running' | 'disabled' | 'error';
error?: string;
permissions: string[];
forceEnabled?: boolean;
}
const STATUS_STYLE: Record<Plugin['status'], { bg: string; fg: string }> = {
installed: { bg: colors.muted, fg: colors.mutedForeground },
enabled: { bg: 'rgba(59, 130, 246, 0.15)', fg: '#60a5fa' },
running: { bg: 'rgba(34, 197, 94, 0.15)', fg: '#4ade80' },
disabled: { bg: colors.muted, fg: colors.mutedForeground },
error: { bg: 'rgba(239, 68, 68, 0.15)', fg: '#f87171' },
};
export function PluginsSettings() {
const [plugins, setPlugins] = useState<Plugin[]>([]);
const [expandedId, setExpandedId] = useState<string | null>(null);
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 uninstall = (id: string) => {
setPlugins((ps) => ps.filter((p) => p.id !== id));
};
return (
<SettingsSection
title="Plugins"
description="Manage installed plugins. Upload plugin .zip files to add new functionality."
experimental
experimentalDescription="Plugins is an experimental feature. The plugin API is not yet stable. Only install plugins from sources you trust."
>
{plugins.length === 0 ? (
<View style={styles.empty}>
<Puzzle size={48} color={colors.mutedForeground} style={{ opacity: 0.3 }} />
<Text style={styles.emptyTitle}>No plugins installed</Text>
<Text style={styles.emptyDesc}>Upload a plugin .zip file to get started.</Text>
</View>
) : (
<View style={{ gap: spacing.sm }}>
{plugins.map((plugin) => {
const expanded = expandedId === plugin.id;
const status = STATUS_STYLE[plugin.status];
return (
<View
key={plugin.id}
style={[
styles.card,
plugin.status === 'error' && { borderColor: colors.errorBorder },
]}
>
<View style={styles.cardHeader}>
<Pressable
style={{ flex: 1 }}
onPress={() => setExpandedId(expanded ? null : plugin.id)}
>
<View style={styles.headerRow}>
<Text style={styles.pluginName} numberOfLines={1}>{plugin.name}</Text>
<View style={[styles.statusPill, { backgroundColor: status.bg }]}>
<Text style={[styles.statusText, { color: status.fg }]}>{plugin.status}</Text>
</View>
{plugin.forceEnabled && (
<View style={styles.forcedPill}>
<Lock size={10} color="#fbbf24" />
<Text style={styles.forcedText}>Forced</Text>
</View>
)}
</View>
<View style={styles.metaRow}>
<Text style={styles.meta}>{plugin.author}</Text>
<Text style={styles.metaDot}>v{plugin.version}</Text>
<Text style={styles.metaDot}>{plugin.type}</Text>
</View>
</Pressable>
<ToggleSwitch
checked={plugin.enabled}
onChange={() => togglePlugin(plugin.id)}
disabled={plugin.forceEnabled}
/>
</View>
{expanded && (
<View style={styles.cardBody}>
{plugin.description && (
<Text style={styles.description}>{plugin.description}</Text>
)}
{plugin.error && (
<View style={styles.errorBox}>
<AlertTriangle size={14} color={colors.error} />
<Text style={styles.errorText}>{plugin.error}</Text>
</View>
)}
{plugin.permissions.length > 0 && (
<View>
<Text style={styles.sectionLabel}>Permissions</Text>
<View style={styles.permWrap}>
{plugin.permissions.map((p) => (
<View key={p} style={styles.permPill}>
<Text style={styles.permText}>{p}</Text>
</View>
))}
</View>
</View>
)}
<View style={styles.uninstallRow}>
<Button
variant="destructive"
size="sm"
icon={<Trash2 size={14} color="#fff" />}
onPress={() => uninstall(plugin.id)}
disabled={plugin.forceEnabled}
>
Uninstall
</Button>
</View>
</View>
)}
</View>
);
})}
</View>
)}
<SettingItem label="Upload Plugin" description="Install a new plugin from a .zip file.">
<Button variant="outline" size="sm" icon={<Upload size={14} color={colors.text} />}>
Upload .zip
</Button>
</SettingItem>
</SettingsSection>
);
}
const styles = StyleSheet.create({
empty: {
alignItems: 'center',
paddingVertical: spacing.xxxl,
gap: spacing.sm,
},
emptyTitle: { ...typography.body, color: colors.mutedForeground },
emptyDesc: { ...typography.caption, color: colors.mutedForeground },
card: {
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
},
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
padding: spacing.md,
},
headerRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flexWrap: 'wrap' },
pluginName: { ...typography.bodyMedium, color: colors.text },
statusPill: { paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.full },
statusText: { fontSize: 10, fontWeight: '500' },
forcedPill: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: radius.full,
backgroundColor: 'rgba(251, 191, 36, 0.15)',
},
forcedText: { fontSize: 10, fontWeight: '500', color: '#fbbf24' },
metaRow: { flexDirection: 'row', gap: spacing.sm, marginTop: 2 },
meta: { ...typography.caption, color: colors.mutedForeground },
metaDot: { ...typography.caption, color: colors.mutedForeground, opacity: 0.6 },
cardBody: {
borderTopWidth: 1,
borderTopColor: colors.border,
padding: spacing.md,
gap: spacing.md,
},
description: { ...typography.caption, color: colors.mutedForeground },
errorBox: {
flexDirection: 'row',
gap: spacing.sm,
padding: spacing.sm,
borderRadius: radius.sm,
backgroundColor: colors.errorBg,
alignItems: 'flex-start',
},
errorText: { ...typography.caption, color: colors.error, flex: 1 },
sectionLabel: { ...typography.captionMedium, color: colors.text, marginBottom: 4 },
permWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 4 },
permPill: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: radius.xs,
backgroundColor: colors.muted,
},
permText: { fontSize: 10, color: colors.mutedForeground },
uninstallRow: {
flexDirection: 'row',
justifyContent: 'flex-end',
paddingTop: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.border,
},
});
@@ -0,0 +1,288 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
import {
Plus, Pencil, Trash2, Globe, ExternalLink, PanelRight, GripVertical,
} from 'lucide-react-native';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { colors, spacing, radius, typography } from '../../theme/tokens';
interface SidebarApp {
id: string;
name: string;
url: string;
icon: string;
openMode: 'tab' | 'inline';
showOnMobile: boolean;
}
export function SidebarAppsSettings() {
const [keepLoaded, setKeepLoaded] = useState(false);
const [apps, setApps] = useState<SidebarApp[]>([]);
const [editingId, setEditingId] = useState<string | null>(null);
const [isAdding, setIsAdding] = useState(false);
const addApp = (data: Omit<SidebarApp, 'id'>) => {
setApps((list) => [...list, { ...data, id: `app-${Math.random().toString(36).slice(2)}` }]);
setIsAdding(false);
};
const updateApp = (id: string, data: Omit<SidebarApp, 'id'>) => {
setApps((list) => list.map((a) => a.id === id ? { ...data, id } : a));
setEditingId(null);
};
const removeApp = (id: string) => {
setApps((list) => list.filter((a) => a.id !== 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} />
</SettingItem>
</SettingsSection>
<SettingsSection title="Manage Apps" description="Add, edit, or remove sidebar apps.">
<View style={{ gap: spacing.md }}>
{apps.length === 0 && !isAdding && (
<Text style={styles.emptyText}>No apps added yet.</Text>
)}
{apps.map((app) => {
if (editingId === app.id) {
return (
<AppForm
key={app.id}
initial={app}
onSave={(data) => updateApp(app.id, data)}
onCancel={() => setEditingId(null)}
/>
);
}
return (
<View key={app.id} style={styles.appRow}>
<GripVertical size={16} color={colors.mutedForeground} style={{ opacity: 0.5 }} />
<View style={styles.appIcon}>
<Globe size={16} color={colors.text} />
</View>
<View style={{ flex: 1 }}>
<Text style={styles.appName} numberOfLines={1}>{app.name}</Text>
<Text style={styles.appUrl} numberOfLines={1}>{app.url}</Text>
</View>
<View style={[
styles.modeBadge,
app.openMode === 'inline' ? styles.modeBadgeInline : styles.modeBadgeTab,
]}>
<Text style={[
styles.modeBadgeText,
{ color: app.openMode === 'inline' ? '#60a5fa' : colors.mutedForeground },
]}>
{app.openMode === 'inline' ? 'Inline' : 'Tab'}
</Text>
</View>
<Pressable style={styles.iconBtn} onPress={() => setEditingId(app.id)}>
<Pencil size={14} color={colors.mutedForeground} />
</Pressable>
<Pressable style={styles.iconBtn} onPress={() => removeApp(app.id)}>
<Trash2 size={14} color={colors.mutedForeground} />
</Pressable>
</View>
);
})}
{isAdding && (
<AppForm
onSave={addApp}
onCancel={() => setIsAdding(false)}
/>
)}
{!isAdding && editingId === null && (
<Pressable style={styles.addBtn} onPress={() => setIsAdding(true)}>
<Plus size={16} color={colors.text} />
<Text style={styles.addBtnText}>Add app</Text>
</Pressable>
)}
</View>
</SettingsSection>
</View>
);
}
interface AppFormProps {
initial?: SidebarApp;
onSave: (data: Omit<SidebarApp, 'id'>) => void;
onCancel: () => void;
}
function AppForm({ initial, onSave, onCancel }: AppFormProps) {
const [name, setName] = useState(initial?.name ?? '');
const [url, setUrl] = useState(initial?.url ?? '');
const [openMode, setOpenMode] = useState<'tab' | 'inline'>(initial?.openMode ?? 'tab');
const [showOnMobile, setShowOnMobile] = useState(initial?.showOnMobile ?? false);
const canSave = name.trim().length > 0 && url.trim().length > 0;
return (
<View style={formStyles.form}>
<View>
<Text style={formStyles.label}>Name</Text>
<TextInput
value={name}
onChangeText={setName}
placeholder="App name"
placeholderTextColor={colors.mutedForeground}
style={formStyles.input}
/>
</View>
<View>
<Text style={formStyles.label}>URL</Text>
<TextInput
value={url}
onChangeText={setUrl}
placeholder="https://example.com"
placeholderTextColor={colors.mutedForeground}
style={formStyles.input}
autoCapitalize="none"
keyboardType="url"
/>
</View>
<View>
<Text style={formStyles.label}>Open mode</Text>
<View style={{ flexDirection: 'row', gap: spacing.sm }}>
<Pressable
onPress={() => setOpenMode('tab')}
style={[formStyles.modeBtn, openMode === 'tab' && formStyles.modeBtnActive]}
>
<ExternalLink size={14} color={openMode === 'tab' ? colors.primary : colors.text} />
<Text style={[formStyles.modeBtnText, openMode === 'tab' && { color: colors.primary }]}>
New tab
</Text>
</Pressable>
<Pressable
onPress={() => setOpenMode('inline')}
style={[formStyles.modeBtn, openMode === 'inline' && formStyles.modeBtnActive]}
>
<PanelRight size={14} color={openMode === 'inline' ? colors.primary : colors.text} />
<Text style={[formStyles.modeBtnText, openMode === 'inline' && { color: colors.primary }]}>
Inline
</Text>
</Pressable>
</View>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={formStyles.label}>Show on mobile</Text>
<ToggleSwitch checked={showOnMobile} onChange={setShowOnMobile} />
</View>
<View style={{ flexDirection: 'row', gap: spacing.sm, justifyContent: 'flex-end' }}>
<Button variant="ghost" size="sm" onPress={onCancel}>Cancel</Button>
<Button
size="sm"
disabled={!canSave}
onPress={() => onSave({ name: name.trim(), url: url.trim(), icon: 'Globe', openMode, showOnMobile })}
>
{initial ? 'Update' : 'Add'}
</Button>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { gap: spacing.xxxl },
emptyText: {
...typography.body,
color: colors.mutedForeground,
textAlign: 'center',
paddingVertical: spacing.lg,
},
appRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
padding: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
},
appIcon: {
width: 32,
height: 32,
borderRadius: radius.sm,
backgroundColor: colors.muted,
alignItems: 'center',
justifyContent: 'center',
},
appName: { ...typography.bodyMedium, color: colors.text },
appUrl: { ...typography.caption, color: colors.mutedForeground, marginTop: 2 },
modeBadge: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: radius.full,
},
modeBadgeInline: { backgroundColor: 'rgba(59, 130, 246, 0.1)' },
modeBadgeTab: { backgroundColor: colors.muted },
modeBadgeText: { fontSize: 10, fontWeight: '500' },
iconBtn: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.sm,
},
addBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
paddingVertical: spacing.sm,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
},
addBtnText: { ...typography.body, color: colors.text },
});
const formStyles = StyleSheet.create({
form: {
gap: spacing.md,
padding: spacing.lg,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.muted,
},
label: { ...typography.captionMedium, color: colors.text },
input: {
marginTop: 4,
paddingHorizontal: spacing.md,
paddingVertical: 8,
borderRadius: radius.sm,
backgroundColor: colors.background,
borderWidth: 1,
borderColor: colors.border,
color: colors.text,
...typography.body,
},
modeBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: spacing.md,
paddingVertical: 8,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
},
modeBtnActive: {
borderColor: colors.primary,
backgroundColor: 'rgba(59, 130, 246, 0.1)',
},
modeBtnText: { ...typography.caption, color: colors.text },
});
+234
View File
@@ -0,0 +1,234 @@
import React, { 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 Button from '../Button';
import { colors, spacing, radius, typography } from '../../theme/tokens';
interface KeyRecord {
id: string;
email: string;
subject: string;
issuer: string;
notAfter: string;
algorithm: string;
unlocked?: boolean;
}
interface PublicCert {
id: string;
email: string;
subject: string;
issuer: string;
notAfter: string;
source: string;
}
const MOCK_KEYS: KeyRecord[] = [];
const MOCK_CERTS: PublicCert[] = [];
export function SmimeSettings() {
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);
const isExpired = (d: string) => new Date(d) < new Date();
const formatDate = (d: string) => {
try { return new Date(d).toLocaleDateString(); } catch { return d; }
};
const toggleUnlock = (id: string) => {
setKeys((ks) => ks.map((k) => k.id === id ? { ...k, unlocked: !k.unlocked } : k));
};
const deleteKey = (id: string) => setKeys((ks) => ks.filter((k) => k.id !== id));
const deleteCert = (id: string) => setCerts((cs) => cs.filter((c) => c.id !== id));
return (
<View style={styles.container}>
<SettingsSection
title="Your Certificates"
description="Private keys used to sign and decrypt mail."
>
<View style={{ gap: spacing.sm }}>
{keys.map((k) => {
const expired = isExpired(k.notAfter);
return (
<View key={k.id} style={styles.certRow}>
<View style={styles.certLeft}>
<View style={[styles.certIcon, expired ? styles.certIconError : styles.certIconOk]}>
{expired ? (
<ShieldAlert size={16} color={colors.error} />
) : (
<ShieldCheck size={16} color={colors.primary} />
)}
</View>
<View style={{ flex: 1 }}>
<Text style={styles.certName} numberOfLines={1}>{k.email || k.subject}</Text>
<Text style={styles.certMeta}>
{k.issuer} · Expires {formatDate(k.notAfter)}
{expired && <Text style={styles.expiredText}> (Expired)</Text>}
</Text>
</View>
</View>
<View style={styles.certActions}>
<Pressable style={styles.iconBtn} onPress={() => toggleUnlock(k.id)}>
{k.unlocked ? (
<Unlock size={16} color={colors.success} />
) : (
<Lock size={16} color={colors.text} />
)}
</Pressable>
<Pressable style={styles.iconBtn}>
<Eye size={16} color={colors.text} />
</Pressable>
<Pressable style={styles.iconBtn}>
<Download size={16} color={colors.text} />
</Pressable>
<Pressable style={styles.iconBtn} onPress={() => deleteKey(k.id)}>
<Trash2 size={16} color={colors.error} />
</Pressable>
</View>
</View>
);
})}
{keys.length === 0 && (
<Text style={styles.empty}>No certificates imported.</Text>
)}
</View>
<View style={{ alignItems: 'flex-start', marginTop: spacing.sm }}>
<Button variant="outline" size="sm" icon={<Upload size={14} color={colors.text} />}>
Import PKCS#12
</Button>
</View>
</SettingsSection>
<SettingsSection
title="Recipient Certificates"
description="Public certificates used to encrypt outgoing mail."
>
<View style={{ gap: spacing.sm }}>
{certs.map((c) => {
const expired = isExpired(c.notAfter);
return (
<View key={c.id} style={styles.certRow}>
<View style={styles.certLeft}>
<View style={[styles.certIcon, styles.certIconMuted]}>
<Users size={16} color={colors.mutedForeground} />
</View>
<View style={{ flex: 1 }}>
<Text style={styles.certName} numberOfLines={1}>{c.email || c.subject}</Text>
<Text style={styles.certMeta}>
{c.issuer} · {c.source}
{expired && <Text style={styles.expiredText}> (Expired)</Text>}
</Text>
</View>
</View>
<View style={styles.certActions}>
<Pressable style={styles.iconBtn}>
<Eye size={16} color={colors.text} />
</Pressable>
<Pressable style={styles.iconBtn} onPress={() => deleteCert(c.id)}>
<Trash2 size={16} color={colors.error} />
</Pressable>
</View>
</View>
);
})}
{certs.length === 0 && (
<Text style={styles.empty}>No recipient certificates.</Text>
)}
</View>
<View style={{ alignItems: 'flex-start', marginTop: spacing.sm }}>
<Button variant="outline" size="sm" icon={<Upload size={14} color={colors.text} />}>
Import Certificate
</Button>
</View>
</SettingsSection>
<SettingsSection
title="Defaults"
description="Defaults for new outgoing messages."
>
<SettingItem
label="Encrypt by default"
description="Encrypt new messages when recipient certificates are available."
>
<ToggleSwitch checked={defaultEncrypt} onChange={setDefaultEncrypt} />
</SettingItem>
<SettingItem
label="Remember unlocked keys"
description="Keep private keys unlocked across the session."
>
<ToggleSwitch checked={rememberUnlocked} onChange={setRememberUnlocked} />
</SettingItem>
<SettingItem
label="Auto-import signer certs"
description="Save public certificates from signed messages."
>
<ToggleSwitch checked={autoImport} onChange={setAutoImport} />
</SettingItem>
</SettingsSection>
</View>
);
}
const styles = StyleSheet.create({
container: { gap: spacing.xxxl },
certRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.md,
padding: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
},
certLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
flex: 1,
minWidth: 0,
},
certIcon: {
width: 32,
height: 32,
borderRadius: radius.full,
alignItems: 'center',
justifyContent: 'center',
},
certIconOk: { backgroundColor: colors.primaryBg },
certIconError: { backgroundColor: colors.errorBg },
certIconMuted: { backgroundColor: colors.muted },
certName: { ...typography.bodyMedium, color: colors.text },
certMeta: { ...typography.caption, color: colors.mutedForeground, marginTop: 2 },
expiredText: { color: colors.error },
certActions: { flexDirection: 'row', gap: 2 },
iconBtn: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.sm,
},
empty: {
...typography.body,
color: colors.mutedForeground,
textAlign: 'center',
paddingVertical: spacing.lg,
},
});
@@ -0,0 +1,55 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { FileText, Download, Upload } from 'lucide-react-native';
import { SettingsSection } from './settings-section';
import Button from '../Button';
import { colors, spacing, typography } from '../../theme/tokens';
export function TemplateSettings() {
const [templates] = useState<{ id: string; name: string }[]>([]);
return (
<View style={styles.container}>
<SettingsSection title="Templates" description="Reusable email templates for common responses.">
<View style={styles.row}>
<View style={styles.countRow}>
<FileText size={16} color={colors.mutedForeground} />
<Text style={styles.count}>
{templates.length === 1 ? '1 template' : `${templates.length} templates`}
</Text>
</View>
<Button variant="outline" size="sm">Manage</Button>
</View>
</SettingsSection>
<SettingsSection title="Export / Import" description="Back up or restore your templates.">
<View style={styles.actions}>
<Button
variant="outline"
size="sm"
disabled={templates.length === 0}
icon={<Download size={14} color={colors.text} />}
>
Export
</Button>
<Button variant="outline" size="sm" icon={<Upload size={14} color={colors.text} />}>
Import
</Button>
</View>
</SettingsSection>
</View>
);
}
const styles = StyleSheet.create({
container: { gap: spacing.xxxl },
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: spacing.md,
},
countRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
count: { ...typography.body, color: colors.mutedForeground },
actions: { flexDirection: 'row', gap: spacing.md, paddingVertical: spacing.md },
});
+121
View File
@@ -0,0 +1,121 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Upload, Check, Palette, Trash2 } from 'lucide-react-native';
import { SettingsSection, SettingItem } from './settings-section';
import Button from '../Button';
import { colors, spacing, radius, typography } from '../../theme/tokens';
interface Theme {
id: string | null;
name: string;
author: string;
builtIn?: boolean;
variants?: ('light' | 'dark')[];
}
const BUILT_IN: Theme[] = [
{ id: null, name: 'Default', author: 'Bulwark', builtIn: true, variants: ['light', 'dark'] },
{ id: 'qui', name: 'Qui', author: 'Bulwark', builtIn: true, variants: ['dark'] },
{ id: 'sepia', name: 'Sepia', author: 'Bulwark', builtIn: true, variants: ['light'] },
];
export function ThemesSettings() {
const [themes] = useState<Theme[]>(BUILT_IN);
const [active, setActive] = useState<string | null>(null);
return (
<SettingsSection
title="Themes"
description="Customize the appearance with color themes."
experimental
experimentalDescription="Themes is an experimental feature. Custom themes may not cover all UI elements, and theme formats could change in future updates."
>
<View style={styles.grid}>
{themes.map((theme) => {
const isActive = active === theme.id;
return (
<Pressable
key={theme.id ?? 'default'}
onPress={() => setActive(theme.id)}
style={[styles.card, isActive && styles.cardActive]}
>
<View style={styles.preview}>
<Palette size={32} color={colors.mutedForeground} style={{ opacity: 0.4 }} />
</View>
<View style={{ width: '100%' }}>
<View style={styles.cardHeader}>
<Text style={styles.cardName} numberOfLines={1}>{theme.name}</Text>
{isActive && <Check size={16} color={colors.primary} />}
</View>
<Text style={styles.cardAuthor} numberOfLines={1}>{theme.author}</Text>
{theme.variants && (
<View style={styles.variants}>
{theme.variants.map((v) => (
<View key={v} style={styles.variantPill}>
<Text style={styles.variantText}>{v}</Text>
</View>
))}
</View>
)}
</View>
</Pressable>
);
})}
</View>
<SettingItem
label="Upload Theme"
description="Install a custom theme from a .zip file."
>
<Button variant="outline" size="sm" icon={<Upload size={14} color={colors.text} />}>
Upload .zip
</Button>
</SettingItem>
</SettingsSection>
);
}
const styles = StyleSheet.create({
grid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.md,
},
card: {
width: '47%',
alignItems: 'center',
padding: spacing.md,
borderRadius: radius.lg,
borderWidth: 2,
borderColor: colors.border,
backgroundColor: colors.card,
gap: spacing.sm,
},
cardActive: {
borderColor: colors.primary,
backgroundColor: 'rgba(59, 130, 246, 0.05)',
},
preview: {
width: '100%',
aspectRatio: 16 / 10,
borderRadius: radius.md,
backgroundColor: colors.muted,
alignItems: 'center',
justifyContent: 'center',
},
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
cardName: { ...typography.bodyMedium, color: colors.text, flex: 1 },
cardAuthor: { ...typography.caption, color: colors.mutedForeground },
variants: { flexDirection: 'row', gap: 4, marginTop: 4 },
variantPill: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: radius.xs,
backgroundColor: colors.muted,
},
variantText: { fontSize: 10, color: colors.mutedForeground },
});
@@ -0,0 +1,289 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, TextInput, Pressable, ActivityIndicator, Alert } from 'react-native';
import { AlertTriangle, Eye, EyeOff } from 'lucide-react-native';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { useVacationStore } from '../../stores/vacation-store';
function toJmapDate(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
// Accept "YYYY-MM-DD" or "YYYY-MM-DD HH:MM" — normalize to "YYYY-MM-DDTHH:MM:SS".
const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(trimmed);
const dateTime = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2})?$/.test(trimmed);
if (dateOnly) return `${trimmed}T00:00:00`;
if (dateTime) return trimmed.replace(' ', 'T').length === 16 ? `${trimmed.replace(' ', 'T')}:00` : trimmed.replace(' ', 'T');
return null;
}
function fromJmapDate(value: string | null): string {
if (!value) return '';
return value.replace('T', ' ').slice(0, 16);
}
export function VacationSettings() {
const store = useVacationStore();
const [enabled, setEnabled] = useState(store.isEnabled);
const [fromDate, setFromDate] = useState(fromJmapDate(store.fromDate));
const [toDate, setToDate] = useState(fromJmapDate(store.toDate));
const [subject, setSubject] = useState(store.subject);
const [body, setBody] = useState(store.textBody);
const [showPreview, setShowPreview] = useState(false);
useEffect(() => {
void store.fetch();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!store.hasLoaded) return;
setEnabled(store.isEnabled);
setFromDate(fromJmapDate(store.fromDate));
setToDate(fromJmapDate(store.toDate));
setSubject(store.subject);
setBody(store.textBody);
}, [store.hasLoaded, store.isEnabled, store.fromDate, store.toDate, store.subject, store.textBody]);
const dateErrorMsg = fromDate && toDate && toJmapDate(fromDate) && toJmapDate(toDate) && new Date(toJmapDate(toDate)!) <= new Date(toJmapDate(fromDate)!);
const formatError = (fromDate && !toJmapDate(fromDate)) || (toDate && !toJmapDate(toDate));
const emptyBody = enabled && !body.trim();
const warnings: string[] = [];
if (dateErrorMsg) warnings.push('End date must be after start date.');
if (formatError) warnings.push('Dates must be "YYYY-MM-DD" or "YYYY-MM-DD HH:MM".');
if (emptyBody) warnings.push('Auto-response body cannot be empty.');
const canSave = !dateErrorMsg && !formatError && !emptyBody && !store.isSaving;
const handleSave = async () => {
try {
await store.save({
isEnabled: enabled,
fromDate: toJmapDate(fromDate),
toDate: toJmapDate(toDate),
subject: subject.trim(),
textBody: body,
});
Alert.alert('Saved', 'Vacation responder updated.');
} catch (err) {
Alert.alert('Error', err instanceof Error ? err.message : 'Failed to save');
}
};
if (store.isLoading && !store.hasLoaded) {
return (
<View style={styles.centered}>
<ActivityIndicator color={colors.primary} />
</View>
);
}
if (!store.isSupported && store.hasLoaded) {
return (
<SettingsSection title="Vacation Responder" description="Automatically reply while you are away.">
<View style={styles.unsupported}>
<AlertTriangle size={16} color={colors.warning} />
<Text style={styles.unsupportedText}>
Your server does not advertise support for the JMAP vacation responder.
</Text>
</View>
</SettingsSection>
);
}
return (
<View style={styles.container}>
<SettingsSection title="Vacation Responder" description="Automatically reply while you are away.">
<SettingItem label="Status" description="Enable or disable the responder.">
<View style={styles.statusRow}>
<View style={[styles.pill, enabled ? styles.pillActive : styles.pillInactive]}>
<Text style={[styles.pillText, enabled ? styles.pillTextActive : styles.pillTextInactive]}>
{enabled ? 'Active' : 'Inactive'}
</Text>
</View>
<ToggleSwitch checked={enabled} onChange={setEnabled} />
</View>
</SettingItem>
</SettingsSection>
<SettingsSection title="Date Range" description="Optional window for the responder.">
<SettingItem label="Start" description="When the responder becomes active.">
<TextInput
value={fromDate}
onChangeText={setFromDate}
placeholder="YYYY-MM-DD HH:MM"
placeholderTextColor={colors.mutedForeground}
style={styles.dateInput}
autoCapitalize="none"
/>
</SettingItem>
<SettingItem label="End" description="When the responder is turned off.">
<TextInput
value={toDate}
onChangeText={setToDate}
placeholder="YYYY-MM-DD HH:MM"
placeholderTextColor={colors.mutedForeground}
style={styles.dateInput}
autoCapitalize="none"
/>
</SettingItem>
</SettingsSection>
<SettingsSection title="Message" description="What recipients will see.">
<SettingItem label="Subject" description="Optional subject for the reply.">
<TextInput
value={subject}
onChangeText={setSubject}
placeholder="Out of office"
placeholderTextColor={colors.mutedForeground}
style={styles.subjectInput}
/>
</SettingItem>
<View style={styles.bodyBlock}>
<Text style={styles.bodyLabel}>Body</Text>
<Text style={styles.bodyDesc}>Short explanation for senders.</Text>
<TextInput
value={body}
onChangeText={setBody}
placeholder="I am away from…"
placeholderTextColor={colors.mutedForeground}
style={styles.bodyInput}
multiline
numberOfLines={6}
textAlignVertical="top"
/>
</View>
</SettingsSection>
{body.trim().length > 0 && (
<SettingsSection title="Preview">
<Pressable style={styles.previewToggle} onPress={() => setShowPreview((v) => !v)}>
{showPreview ? (
<EyeOff size={14} color={colors.primary} />
) : (
<Eye size={14} color={colors.primary} />
)}
<Text style={styles.previewToggleText}>
{showPreview ? 'Hide preview' : 'Show preview'}
</Text>
</Pressable>
{showPreview && (
<View style={styles.previewBox}>
{subject.length > 0 && <Text style={styles.previewSubject}>{subject}</Text>}
<Text style={styles.previewBody}>{body}</Text>
</View>
)}
</SettingsSection>
)}
{warnings.length > 0 && (
<View style={styles.warnings}>
{warnings.map((w, i) => (
<View key={i} style={styles.warnRow}>
<AlertTriangle size={14} color={colors.warning} />
<Text style={styles.warnText}>{w}</Text>
</View>
))}
</View>
)}
{store.error && (
<View style={styles.errorBox}>
<Text style={styles.errorText}>{store.error}</Text>
</View>
)}
<View style={styles.saveRow}>
<Button onPress={handleSave} disabled={!canSave} loading={store.isSaving}>
Save
</Button>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { gap: spacing.xl },
centered: { paddingVertical: 40, alignItems: 'center' },
unsupported: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
padding: spacing.md,
backgroundColor: 'rgba(202,138,4,0.12)',
borderRadius: radius.sm,
},
unsupportedText: { ...typography.body, color: colors.warning, flex: 1 },
statusRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.md },
pill: {
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: radius.full,
},
pillActive: { backgroundColor: 'rgba(22,163,74,0.2)' },
pillInactive: { backgroundColor: colors.muted },
pillText: { fontSize: 11, fontWeight: '500' },
pillTextActive: { color: '#4ade80' },
pillTextInactive: { color: colors.mutedForeground },
dateInput: {
minWidth: 180,
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radius.sm,
backgroundColor: colors.muted,
borderWidth: 1,
borderColor: colors.border,
color: colors.text,
...typography.body,
},
subjectInput: {
width: 240,
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radius.sm,
backgroundColor: colors.muted,
borderWidth: 1,
borderColor: colors.border,
color: colors.text,
...typography.body,
},
bodyBlock: { paddingVertical: spacing.md },
bodyLabel: { ...typography.bodyMedium, color: colors.text },
bodyDesc: { ...typography.caption, color: colors.mutedForeground, marginTop: 2, marginBottom: spacing.sm },
bodyInput: {
width: '100%',
minHeight: 96,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.sm,
backgroundColor: colors.muted,
borderWidth: 1,
borderColor: colors.border,
color: colors.text,
...typography.body,
},
previewToggle: { flexDirection: 'row', alignItems: 'center', gap: 6 },
previewToggleText: { ...typography.body, color: colors.primary },
previewBox: {
marginTop: spacing.sm,
padding: spacing.lg,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.background,
},
previewSubject: { ...typography.bodyMedium, color: colors.text, marginBottom: spacing.sm },
previewBody: { ...typography.body, color: colors.mutedForeground },
warnings: { gap: spacing.sm },
warnRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
warnText: { ...typography.body, color: colors.warning, flex: 1 },
errorBox: {
padding: spacing.md,
borderRadius: radius.sm,
backgroundColor: colors.errorBg,
},
errorText: { ...typography.body, color: colors.error },
saveRow: { alignItems: 'flex-end' },
});
@@ -0,0 +1,336 @@
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
Pressable,
Modal,
ScrollView,
ViewStyle,
} from 'react-native';
import { FlaskConical, Lock, ChevronDown, Check } from 'lucide-react-native';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import ToggleSwitchComponent from '../ToggleSwitch';
/**
* Mirrors webmail settings-section.tsx:
* - SettingsSection: space-y-4, with experimental banner, h3 title + description
* - SettingItem: flex items-start justify-between py-3 border-b border-border
* - ToggleSwitch: h-6 w-11
* - Select: rounded-md bg-muted border text-sm px-3 py-1.5
* - RadioGroup: flex gap-1.5, pill buttons
*/
interface SettingsSectionProps {
title: string;
description?: string;
children: React.ReactNode;
experimental?: boolean;
experimentalDescription?: string;
}
export function SettingsSection({
title,
description,
children,
experimental,
experimentalDescription,
}: SettingsSectionProps) {
return (
<View style={styles.section}>
{experimental && (
<View style={styles.experimentalBanner}>
<FlaskConical size={20} color="#fbbf24" />
<View style={{ flex: 1 }}>
<Text style={styles.experimentalTitle}>Experimental Feature</Text>
{experimentalDescription && (
<Text style={styles.experimentalDesc}>{experimentalDescription}</Text>
)}
</View>
</View>
)}
<View>
<Text style={styles.sectionTitle}>{title}</Text>
{description && <Text style={styles.sectionDescription}>{description}</Text>}
</View>
<View style={styles.sectionBody}>{children}</View>
</View>
);
}
interface SettingItemProps {
label: string;
description?: string;
children?: React.ReactNode;
locked?: boolean;
noBorder?: boolean;
}
export function SettingItem({
label,
description,
children,
locked,
noBorder,
}: SettingItemProps) {
return (
<View style={[styles.settingItem, !noBorder && styles.settingItemBorder, locked && styles.locked]}>
<View style={styles.settingContent}>
<View style={styles.settingLabelRow}>
<Text style={styles.settingLabel}>{label}</Text>
{locked && <Lock size={12} color={colors.mutedForeground} />}
</View>
{description && <Text style={styles.settingDescription}>{description}</Text>}
</View>
{children && <View style={styles.settingRight}>{children}</View>}
</View>
);
}
interface ToggleSwitchProps {
checked: boolean;
onChange: (v: boolean) => void;
disabled?: boolean;
}
export function ToggleSwitch({ checked, onChange, disabled }: ToggleSwitchProps) {
return (
<ToggleSwitchComponent
value={checked}
onValueChange={onChange}
disabled={disabled}
/>
);
}
interface RadioGroupProps {
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
style?: ViewStyle;
}
export function RadioGroup({ value, onChange, options, style }: RadioGroupProps) {
return (
<View style={[styles.radioGroup, style]}>
{options.map((opt) => {
const selected = opt.value === value;
return (
<Pressable
key={opt.value}
onPress={() => onChange(opt.value)}
style={[
styles.radioOption,
selected ? styles.radioSelected : styles.radioUnselected,
]}
>
<Text style={[
styles.radioText,
selected ? styles.radioTextSelected : styles.radioTextUnselected,
]}>
{opt.label}
</Text>
</Pressable>
);
})}
</View>
);
}
interface SelectProps {
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
style?: ViewStyle;
}
export function Select({ value, onChange, options, style }: SelectProps) {
const [open, setOpen] = useState(false);
const current = options.find((o) => o.value === value);
return (
<>
<Pressable style={[styles.select, style]} onPress={() => setOpen(true)}>
<Text style={styles.selectText}>{current?.label ?? ''}</Text>
<ChevronDown size={14} color={colors.mutedForeground} />
</Pressable>
<Modal
visible={open}
transparent
animationType="fade"
onRequestClose={() => setOpen(false)}
>
<Pressable style={styles.modalBackdrop} onPress={() => setOpen(false)}>
<View style={styles.modalContent}>
<ScrollView>
{options.map((opt) => {
const selected = opt.value === value;
return (
<Pressable
key={opt.value}
style={({ pressed }) => [
styles.selectItem,
pressed && styles.selectItemPressed,
]}
onPress={() => {
onChange(opt.value);
setOpen(false);
}}
>
<Text style={styles.selectItemText}>{opt.label}</Text>
{selected && <Check size={16} color={colors.primary} />}
</Pressable>
);
})}
</ScrollView>
</View>
</Pressable>
</Modal>
</>
);
}
const styles = StyleSheet.create({
section: {
gap: spacing.lg,
},
sectionBody: {
gap: spacing.lg,
},
sectionTitle: {
...typography.h3,
color: colors.text,
},
sectionDescription: {
...typography.body,
color: colors.mutedForeground,
marginTop: 4,
},
experimentalBanner: {
flexDirection: 'row',
gap: spacing.md,
borderWidth: 1,
borderColor: 'rgba(252, 211, 77, 0.4)',
backgroundColor: 'rgba(120, 53, 15, 0.25)',
padding: spacing.md,
borderRadius: radius.md,
},
experimentalTitle: {
...typography.bodyMedium,
color: '#fcd34d',
},
experimentalDesc: {
...typography.caption,
color: '#fde68a',
marginTop: 2,
},
settingItem: {
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
paddingVertical: spacing.md,
gap: spacing.md,
},
settingItemBorder: {
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
locked: {
opacity: 0.6,
},
settingContent: {
flex: 1,
paddingRight: spacing.lg,
},
settingLabelRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
settingLabel: {
...typography.bodyMedium,
color: colors.text,
},
settingDescription: {
...typography.caption,
color: colors.mutedForeground,
marginTop: 2,
},
settingRight: {
flexShrink: 0,
},
radioGroup: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 6,
},
radioOption: {
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radius.sm,
},
radioSelected: {
backgroundColor: colors.primary,
},
radioUnselected: {
backgroundColor: colors.muted,
},
radioText: {
...typography.caption,
},
radioTextSelected: {
color: colors.primaryForeground,
fontWeight: '500',
},
radioTextUnselected: {
color: colors.text,
},
select: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radius.sm,
backgroundColor: colors.muted,
borderWidth: 1,
borderColor: colors.border,
minWidth: 120,
},
selectText: {
...typography.body,
color: colors.text,
flex: 1,
},
modalBackdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center',
alignItems: 'center',
padding: spacing.lg,
},
modalContent: {
width: '100%',
maxWidth: 320,
maxHeight: '70%',
backgroundColor: colors.popover,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
paddingVertical: spacing.sm,
},
selectItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
},
selectItemPressed: {
backgroundColor: colors.accent,
},
selectItemText: {
...typography.body,
color: colors.text,
},
});
+146 -7
View File
@@ -190,7 +190,132 @@ export function getTimedEventBoundsForDay(
};
}
// ─── Overlapping event layout (column packing) ───────────
// ─── Timed events that fill the day ──────────────────────
// Events shown in the timed grid that effectively span 00:00–24:00 on the
// given day get promoted to the all-day strip in the week/day views (matches
// webmail behavior — keeps the timed grid usable when an event covers the day).
export function isTimedEventFullDayOnDate(event: CalendarEvent, day: Date): boolean {
const bounds = getTimedEventBoundsForDay(event, day);
return bounds?.startMinutes === 0 && bounds?.endMinutes === 1440;
}
// ─── Multi-day all-day segment packing (week strip) ──────
// Webmail draws multi-day all-day events as a single bar across the days they
// span. RN was previously rendering one chip per day, which broke continuity.
export interface CalendarWeekSegment {
event: CalendarEvent;
startIndex: number; // first day index in weekDays this segment covers
span: number; // number of days
row: number; // assigned row after packing
continuesBefore: boolean;
continuesAfter: boolean;
}
export function packWeekSegments(rawSegments: CalendarWeekSegment[]): CalendarWeekSegment[] {
rawSegments.sort((left, right) => {
if (left.startIndex !== right.startIndex) return left.startIndex - right.startIndex;
if (left.span !== right.span) return right.span - left.span;
if (left.event.showWithoutTime !== right.event.showWithoutTime) {
return left.event.showWithoutTime ? -1 : 1;
}
const timeDiff = getEventStartDate(left.event).getTime() - getEventStartDate(right.event).getTime();
if (timeDiff !== 0) return timeDiff;
return (left.event.title || '').localeCompare(right.event.title || '');
});
const rowEndIndices: number[] = [];
return rawSegments.map((segment) => {
const segmentEndIndex = segment.startIndex + segment.span - 1;
let row = rowEndIndices.findIndex((endIndex) => endIndex < segment.startIndex);
if (row === -1) {
row = rowEndIndices.length;
rowEndIndices.push(segmentEndIndex);
} else {
rowEndIndices[row] = segmentEndIndex;
}
return { ...segment, row };
});
}
export function buildWeekSegmentsRaw(
events: CalendarEvent[],
weekDays: Date[],
): CalendarWeekSegment[] {
if (weekDays.length === 0) return [];
const weekStart = startOfDay(weekDays[0]);
const weekEnd = startOfDay(weekDays[weekDays.length - 1]);
return events.flatMap((event) => {
const { startDay, endDay } = getEventDayBounds(event);
if (endDay < weekStart || startDay > weekEnd) return [];
const segmentStart = startDay < weekStart ? weekStart : startDay;
const segmentEnd = endDay > weekEnd ? weekEnd : endDay;
const startIndex = differenceInCalendarDays(segmentStart, weekStart);
const span = differenceInCalendarDays(segmentEnd, segmentStart) + 1;
return [{
event,
startIndex,
span,
row: -1,
continuesBefore: startDay < weekStart,
continuesAfter: endDay > weekEnd,
} satisfies CalendarWeekSegment];
});
}
export function buildTimedFullDayWeekSegments(
events: CalendarEvent[],
weekDays: Date[],
): CalendarWeekSegment[] {
if (weekDays.length === 0) return [];
const rawSegments = events.flatMap((event) => {
const fullDayIndices = weekDays
.map((day, index) => (isTimedEventFullDayOnDate(event, day) ? index : -1))
.filter((index) => index >= 0);
if (fullDayIndices.length === 0) return [];
const segments: CalendarWeekSegment[] = [];
let rangeStart = fullDayIndices[0];
let previousIndex = fullDayIndices[0];
const pushSegment = (startIndex: number, endIndex: number) => {
const startDay = weekDays[startIndex];
const endDay = weekDays[endIndex];
segments.push({
event,
startIndex,
span: endIndex - startIndex + 1,
row: -1,
continuesBefore: isTimedEventFullDayOnDate(event, addDays(startDay, -1)),
continuesAfter: isTimedEventFullDayOnDate(event, addDays(endDay, 1)),
});
};
for (let index = 1; index < fullDayIndices.length; index++) {
const currentIndex = fullDayIndices[index];
if (currentIndex !== previousIndex + 1) {
pushSegment(rangeStart, previousIndex);
rangeStart = currentIndex;
}
previousIndex = currentIndex;
}
pushSegment(rangeStart, previousIndex);
return segments;
});
return rawSegments;
}
// ─── Overlapping timed-event layout (cluster-based packing) ──
// Webmail rewrote this to track clusters: a new cluster starts when an event
// begins after every prior event has ended. Within a cluster every event gets
// the same `totalColumns`; across clusters, events get to use the full width
// they actually need. The previous global packing forced an event with no
// neighbors at 17:00 to share columns with a 9am cluster — visible squish.
export interface TimedEventLayout {
event: CalendarEvent;
column: number;
@@ -213,13 +338,29 @@ export function layoutOverlappingEvents(
const sorted = layoutInputs.sort((a, b) => {
const diff = a.startMinutes - b.startMinutes;
if (diff !== 0) return diff;
return b.endMinutes - b.startMinutes - (a.endMinutes - a.startMinutes);
return (b.endMinutes - b.startMinutes) - (a.endMinutes - a.startMinutes);
});
const columns: { end: number }[][] = [];
const result: TimedEventLayout[] = [];
let columns: { end: number }[][] = [];
let clusterStart = 0;
let clusterMaxEnd = 0;
const flushCluster = () => {
const total = columns.length;
for (let i = clusterStart; i < result.length; i++) {
result[i].totalColumns = total;
}
};
for (const item of sorted) {
if (columns.length > 0 && item.startMinutes >= clusterMaxEnd) {
flushCluster();
clusterStart = result.length;
columns = [];
clusterMaxEnd = 0;
}
let placed = false;
for (let col = 0; col < columns.length; col++) {
if (columns[col].every((e) => e.end <= item.startMinutes)) {
@@ -233,12 +374,10 @@ export function layoutOverlappingEvents(
columns.push([{ end: item.endMinutes }]);
result.push({ ...item, column: columns.length - 1, totalColumns: 0 });
}
clusterMaxEnd = Math.max(clusterMaxEnd, item.endMinutes);
}
const total = columns.length || 1;
result.forEach((r) => {
r.totalColumns = total;
});
flushCluster();
return result;
}
+106
View File
@@ -0,0 +1,106 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { NativeModules } from 'react-native';
import { jmapClient } from '../api/jmap-client';
import { getEmails, getMailboxes, queryEmails } from '../api/email';
import {
generateEmailAvatarColor,
getEmailInitials,
getFaviconDomain,
getFaviconUrl,
} from './avatar-utils';
const PUSH_ACCOUNT_ID_KEY = 'push:accountId:v1';
const LAST_NOTIFIED_EMAIL_ID_KEY = 'push:lastNotifiedEmailId:v1';
interface ShowNotificationOptions {
notificationId: string;
title: string;
body: string;
initials: string;
bgColorHex: string;
iconUrl?: string;
emailId: string;
threadId: string;
subject?: string;
}
interface BulwarkFcmNative {
showNotification(opts: ShowNotificationOptions): Promise<void>;
}
// Fired by BulwarkPushTaskService when a data FCM message arrives. Runs in a
// short-lived headless JS runtime — keep it fast, catch all errors, and always
// resolve so the native service can release its wake lock.
export async function pushBackgroundTask(_data: unknown): Promise<void> {
try {
const accountId = await AsyncStorage.getItem(PUSH_ACCOUNT_ID_KEY);
if (!accountId) return;
const loaded = await jmapClient.loadAccount(accountId);
if (!loaded) return;
const mailboxes = await getMailboxes();
const inbox = mailboxes.find((m) => m.role === 'inbox');
if (!inbox) return;
const { ids } = await queryEmails(inbox.id, {
filter: { notKeyword: '$seen' },
limit: 1,
});
if (ids.length === 0) return;
const emailId = ids[0];
const previouslyNotified = await AsyncStorage.getItem(LAST_NOTIFIED_EMAIL_ID_KEY);
if (previouslyNotified === emailId) return;
const [email] = await getEmails([emailId]);
if (!email) return;
const from = email.from?.[0];
const name = from?.name ?? '';
const address = from?.email ?? '';
const title = name || address || 'New mail';
const body = email.subject || '(no subject)';
const initials = getEmailInitials(name, address);
const bgColorHex = hslToHex(generateEmailAvatarColor(name, address));
const faviconDomain = getFaviconDomain(address);
const iconUrl = faviconDomain ? getFaviconUrl(faviconDomain) : undefined;
const native = NativeModules.BulwarkFcm as BulwarkFcmNative | undefined;
if (!native?.showNotification) return;
await native.showNotification({
notificationId: `mail:${emailId}`,
title,
body,
initials,
bgColorHex,
iconUrl,
emailId,
threadId: email.threadId,
subject: email.subject ?? undefined,
});
await AsyncStorage.setItem(LAST_NOTIFIED_EMAIL_ID_KEY, emailId);
} catch (error) {
console.warn(
'[push] background task failed:',
error instanceof Error ? error.message : error,
);
}
}
function hslToHex(hsl: string): string {
const match = hsl.match(/hsl\(\s*(\d+)\s*,\s*(\d+)%\s*,\s*(\d+)%\s*\)/);
if (!match) return '#2563eb';
const h = Number(match[1]);
const s = Number(match[2]) / 100;
const l = Number(match[3]) / 100;
const a = s * Math.min(l, 1 - l);
const component = (n: number): string => {
const k = (n + h / 30) % 12;
const c = l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
return Math.round(255 * c).toString(16).padStart(2, '0');
};
return `#${component(0)}${component(8)}${component(4)}`;
}
+292
View File
@@ -0,0 +1,292 @@
import { NativeEventEmitter, NativeModules, PermissionsAndroid, Platform } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import {
createPushSubscription,
destroyPushSubscription,
listPushSubscriptions,
verifyPushSubscription,
} from '../api/push';
import { jmapClient } from '../api/jmap-client';
import { generateAccountId } from './account-utils';
// Persist identifiers across launches so we reuse the same JMAP subscription
// after app restarts. deviceClientId also routes to the relay slot.
const DEVICE_CLIENT_ID_KEY = 'push:deviceClientId:v1';
const SUBSCRIPTION_ID_KEY = 'push:subscriptionId:v1';
const RELAY_BASE_URL_KEY = 'push:relayBaseUrl:v1';
const PUSH_ACCOUNT_ID_KEY = 'push:accountId:v1';
const LAST_NOTIFIED_EMAIL_ID_KEY = 'push:lastNotifiedEmailId:v1';
// Hosted relay so users don't need to run their own Firebase project. The
// relay only ever sees FCM tokens + JMAP state-id hashes — no mail content.
// Power users can override this from the settings screen.
export const DEFAULT_RELAY_BASE_URL = 'https://notifications.relay.bulwarkmail.org';
// Types the mobile app wants StateChange pings for. Submission is excluded —
// outgoing mail state changes don't belong in a user-visible push.
const PUSH_TYPES = ['Email', 'EmailDelivery', 'Mailbox'] as const;
type BulwarkFcmNative = {
getToken(): Promise<string>;
deleteToken(): Promise<void>;
};
function getNative(): BulwarkFcmNative | null {
if (Platform.OS !== 'android') return null;
return (NativeModules as Record<string, unknown>).BulwarkFcm as BulwarkFcmNative | undefined ?? null;
}
export interface PushSetupParams {
// Optional — falls back to the hosted relay if omitted.
relayBaseUrl?: string;
accountLabel?: string;
}
export interface PushSetupResult {
subscriptionId: string;
verified: boolean;
}
function randomClientId(): string {
const bytes = new Uint8Array(16);
if (globalThis.crypto?.getRandomValues) {
globalThis.crypto.getRandomValues(bytes);
} else {
for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
}
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}
async function getOrCreateDeviceClientId(): Promise<string> {
const existing = await AsyncStorage.getItem(DEVICE_CLIENT_ID_KEY);
if (existing) return existing;
const next = randomClientId();
await AsyncStorage.setItem(DEVICE_CLIENT_ID_KEY, next);
return next;
}
export async function getStoredRelayBaseUrl(): Promise<string | null> {
return AsyncStorage.getItem(RELAY_BASE_URL_KEY);
}
export async function getEffectiveRelayBaseUrl(): Promise<string> {
const stored = await AsyncStorage.getItem(RELAY_BASE_URL_KEY);
return stored ?? DEFAULT_RELAY_BASE_URL;
}
export async function setStoredRelayBaseUrl(url: string | null): Promise<void> {
if (!url) {
await AsyncStorage.removeItem(RELAY_BASE_URL_KEY);
} else {
await AsyncStorage.setItem(RELAY_BASE_URL_KEY, url.replace(/\/+$/, ''));
}
}
export async function requestNotificationPermission(): Promise<boolean> {
if (Platform.OS !== 'android') return true;
if (Platform.Version < 33) return true;
const status = await PermissionsAndroid.request(
'android.permission.POST_NOTIFICATIONS' as Parameters<typeof PermissionsAndroid.request>[0],
);
return status === PermissionsAndroid.RESULTS.GRANTED;
}
export async function getFcmToken(): Promise<string | null> {
const native = getNative();
if (!native) return null;
try {
return await native.getToken();
} catch {
return null;
}
}
function buildRelayUrl(base: string, suffix: string): string {
return base.replace(/\/+$/, '') + suffix;
}
async function registerWithRelay(params: {
relayBaseUrl: string;
subscriptionId: string;
fcmToken: string;
accountLabel?: string;
}): Promise<void> {
const res = await fetch(buildRelayUrl(params.relayBaseUrl, '/api/push/register'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
subscriptionId: params.subscriptionId,
fcmToken: params.fcmToken,
accountLabel: params.accountLabel,
}),
});
if (!res.ok) {
throw new Error(`Relay register failed: ${res.status}`);
}
}
async function pollVerificationCode(
relayBaseUrl: string,
subscriptionId: string,
): Promise<string> {
const timeoutAt = Date.now() + 20_000;
let delay = 400;
while (Date.now() < timeoutAt) {
const res = await fetch(
buildRelayUrl(relayBaseUrl, `/api/push/verify/${encodeURIComponent(subscriptionId)}`),
);
if (res.ok) {
const body = (await res.json()) as { verificationCode?: string | null };
if (body.verificationCode) return body.verificationCode;
}
await new Promise((r) => setTimeout(r, delay));
delay = Math.min(delay * 1.5, 2000);
}
throw new Error('Timed out waiting for PushVerification from JMAP server');
}
/**
* Full setup flow: ask permission, fetch the device's FCM token, register
* with the relay, create a JMAP PushSubscription, poll for the verification
* code, and finalise the subscription.
*/
export async function setupPushNotifications(
params: PushSetupParams,
): Promise<PushSetupResult> {
const native = getNative();
if (!native) throw new Error('Push notifications require Android');
const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
if (!relayBaseUrl) throw new Error('relayBaseUrl is required');
const granted = await requestNotificationPermission();
if (!granted) throw new Error('Notification permission denied');
const fcmToken = await native.getToken();
if (!fcmToken) throw new Error('FCM token unavailable');
const deviceClientId = await getOrCreateDeviceClientId();
await setStoredRelayBaseUrl(relayBaseUrl);
// Persist the account id so the background push task can reload credentials
// from SecureStore without the main app being running.
const username = jmapClient.username;
const serverUrl = jmapClient.serverUrl;
if (username && serverUrl) {
await AsyncStorage.setItem(
PUSH_ACCOUNT_ID_KEY,
generateAccountId(username, serverUrl),
);
}
await registerWithRelay({
relayBaseUrl,
subscriptionId: deviceClientId,
fcmToken,
accountLabel: params.accountLabel,
});
// Reuse the previous JMAP subscription when the server still has it.
const storedServerId = await AsyncStorage.getItem(SUBSCRIPTION_ID_KEY);
if (storedServerId) {
const existing = await listPushSubscriptions().catch(() => []);
if (existing.some((s) => s.id === storedServerId)) {
return { subscriptionId: storedServerId, verified: true };
}
await AsyncStorage.removeItem(SUBSCRIPTION_ID_KEY);
}
const serverAssignedId = await createPushSubscription({
deviceClientId,
url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`),
types: [...PUSH_TYPES],
});
const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId);
await verifyPushSubscription(serverAssignedId, verificationCode);
await AsyncStorage.setItem(SUBSCRIPTION_ID_KEY, serverAssignedId);
return { subscriptionId: serverAssignedId, verified: true };
}
/**
* Tear down push on logout / disable. Removes the server-side JMAP
* subscription, tells the relay to drop the mapping, and clears the local
* FCM token. Never throws — callers treat teardown as best effort.
*/
export async function teardownPushNotifications(): Promise<void> {
const storedId = await AsyncStorage.getItem(SUBSCRIPTION_ID_KEY);
const relayBaseUrl = await getStoredRelayBaseUrl();
const deviceClientId = await AsyncStorage.getItem(DEVICE_CLIENT_ID_KEY);
if (storedId) {
await destroyPushSubscription(storedId).catch(() => undefined);
await AsyncStorage.removeItem(SUBSCRIPTION_ID_KEY);
}
if (relayBaseUrl && deviceClientId) {
await fetch(
buildRelayUrl(relayBaseUrl, `/api/push/register/${encodeURIComponent(deviceClientId)}`),
{ method: 'DELETE' },
).catch(() => undefined);
}
await AsyncStorage.removeItem(PUSH_ACCOUNT_ID_KEY);
await AsyncStorage.removeItem(LAST_NOTIFIED_EMAIL_ID_KEY);
const native = getNative();
if (native) {
await native.deleteToken().catch(() => undefined);
}
}
export type FcmMessageListener = (payload: {
title: string;
body: string;
data: Record<string, string>;
}) => void;
export function addMessageListener(listener: FcmMessageListener): () => void {
if (Platform.OS !== 'android') return () => undefined;
const emitter = new NativeEventEmitter(NativeModules.BulwarkFcm);
const sub = emitter.addListener('fcm:message', listener);
return () => sub.remove();
}
export type FcmTokenListener = (payload: { token: string }) => void;
export function addTokenRefreshListener(listener: FcmTokenListener): () => void {
if (Platform.OS !== 'android') return () => undefined;
const emitter = new NativeEventEmitter(NativeModules.BulwarkFcm);
const sub = emitter.addListener('fcm:newToken', listener);
return () => sub.remove();
}
export interface NotificationTapPayload {
emailId: string;
threadId: string;
subject?: string;
}
// Returns — and clears — any pending "notification tap" that launched the app
// before JS was ready to handle it. Subsequent taps while running are delivered
// via addNotificationTapListener.
export async function getInitialNotificationTap(): Promise<NotificationTapPayload | null> {
if (Platform.OS !== 'android') return null;
const native = (NativeModules as Record<string, unknown>).BulwarkFcm as
| { getInitialNotification?: () => Promise<NotificationTapPayload | null> }
| undefined;
if (!native?.getInitialNotification) return null;
try {
return (await native.getInitialNotification()) ?? null;
} catch {
return null;
}
}
export function addNotificationTapListener(
listener: (payload: NotificationTapPayload) => void,
): () => void {
if (Platform.OS !== 'android') return () => undefined;
const emitter = new NativeEventEmitter(NativeModules.BulwarkFcm);
const sub = emitter.addListener('fcm:notificationTap', listener);
return () => sub.remove();
}
+8
View File
@@ -0,0 +1,8 @@
export function toWildcardQuery(query: string): string {
return query
.trim()
.split(/\s+/)
.filter(Boolean)
.map((word) => (word.endsWith('*') || word.endsWith('"') ? word : word + '*'))
.join(' ');
}
+43
View File
@@ -10,6 +10,7 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import {
ArrowLeft, Edit2, Trash2, Mail, Phone, MessageSquare, Share2, MapPin,
Building, Briefcase, Cake, Heart, Globe, Tag, Users, FileText, BookUser,
Copy,
} from 'lucide-react-native';
import type { RootStackParamList } from '../navigation/types';
import type { ContactCard } from '../api/types';
@@ -22,6 +23,7 @@ import {
import { contactToVCard } from '../lib/vcard';
import SenderAvatar from '../components/SenderAvatar';
import Dialog from '../components/Dialog';
import { ContactActivity } from '../components/contacts/ContactActivity';
import { colors, spacing, radius, typography } from '../theme/tokens';
type Nav = NativeStackNavigationProp<RootStackParamList, 'ContactDetail'>;
@@ -79,6 +81,7 @@ export default function ContactDetailScreen() {
const contact = useContactsStore((s) => s.contacts.find((c) => c.id === contactId));
const addressBooks = useContactsStore((s) => s.addressBooks);
const deleteContact = useContactsStore((s) => s.deleteContact);
const createContact = useContactsStore((s) => s.createContact);
const [confirmDelete, setConfirmDelete] = React.useState(false);
@@ -147,6 +150,35 @@ export default function ContactDetailScreen() {
}
};
const doDuplicate = async () => {
const targetBookId = bookIds[0] || addressBooks[0]?.id;
if (!targetBookId) {
Alert.alert('No address book', 'Cannot duplicate without an address book.');
return;
}
// Strip server-managed identity fields and re-create as a new card.
const {
id: _id,
addressBookIds: _abIds,
created: _created,
updated: _updated,
...rest
} = contact;
const baseName =
(rest.name?.full ? `${rest.name.full} (Copy)` : null) ??
`${name} (Copy)`;
const draft: Partial<ContactCard> = {
...rest,
name: rest.name ? { ...rest.name, full: baseName } : { full: baseName },
};
try {
const created = await createContact(draft, targetBookId);
navigation.replace('ContactDetail', { contactId: created.id });
} catch (err) {
Alert.alert('Duplicate failed', err instanceof Error ? err.message : 'Unknown error');
}
};
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
@@ -155,6 +187,15 @@ export default function ContactDetailScreen() {
</Pressable>
<Text style={styles.headerTitle} numberOfLines={1}>{name}</Text>
<View style={styles.headerActions}>
{!isGroup(contact) && (
<Pressable
onPress={() => { void doDuplicate(); }}
style={styles.headerBtn}
hitSlop={8}
>
<Copy size={18} color={colors.text} />
</Pressable>
)}
<Pressable
onPress={() => navigation.navigate('ContactForm', { contactId: contact.id })}
style={styles.headerBtn}
@@ -362,6 +403,8 @@ export default function ContactDetailScreen() {
))}
</Section>
)}
{!isGroup(contact) && <ContactActivity contact={contact} />}
</ScrollView>
<Dialog
+14 -6
View File
@@ -28,6 +28,7 @@ import { getContactDisplayName, isGroup, matchesContactSearch } from '../lib/con
import { ContactListRow } from '../components/contacts';
import Dialog from '../components/Dialog';
import ContactsSidebarDrawer from '../components/contacts/ContactsSidebarDrawer';
import { useSettingsStore } from '../stores/settings-store';
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
type Nav = NativeStackNavigationProp<RootStackParamList>;
@@ -88,6 +89,7 @@ export default function ContactsScreen() {
const hydrate = useContactsStore((s) => s.hydrate);
const bulkDelete = useContactsStore((s) => s.bulkDelete);
const setSelectedCategory = useContactsStore((s) => s.setSelectedCategory);
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
const [searchQuery, setSearchQuery] = React.useState('');
const [searchActive, setSearchActive] = React.useState(false);
@@ -137,7 +139,11 @@ export default function ContactsScreen() {
return filtered.filter((c) => matchesContactSearch(c, searchQuery));
}, [contacts, individuals, selectedCategory, searchQuery]);
const sections = React.useMemo(() => groupContacts(visible), [visible]);
const sections = React.useMemo<Section[]>(() => {
if (groupByLetter) return groupContacts(visible);
// Flat list — single unnamed section keeps SectionList rendering simple.
return [{ title: '', data: sortContactsByDisplayName(visible) }];
}, [visible, groupByLetter]);
const selectionMode = selection.size > 0;
const toggleSelect = (id: string) => {
@@ -323,11 +329,13 @@ export default function ContactsScreen() {
selectionMode={selectionMode}
/>
)}
renderSectionHeader={({ section }) => (
<View style={styles.sectionHeader}>
<Text style={styles.sectionHeaderText}>{section.title}</Text>
</View>
)}
renderSectionHeader={({ section }) =>
section.title ? (
<View style={styles.sectionHeader}>
<Text style={styles.sectionHeaderText}>{section.title}</Text>
</View>
) : null
}
ItemSeparatorComponent={() => <View style={styles.separator} />}
stickySectionHeadersEnabled
contentContainerStyle={styles.listContent}
+588 -106
View File
@@ -1,9 +1,10 @@
import React from 'react';
import { View, Text, StyleSheet, FlatList, Pressable, TextInput, Image, ActivityIndicator, Modal, TouchableOpacity, TouchableWithoutFeedback } from 'react-native';
import { View, Text, StyleSheet, FlatList, Pressable, TextInput, Image, ActivityIndicator, Modal, Platform, ScrollView, TouchableWithoutFeedback } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
import {
Search, SquarePen, Menu, Filter, Square, X, Check,
Star, Paperclip, Mail as MailIcon,
Search, SquarePen, Menu, Filter, Square, SquareCheck, Minus, X,
Star, Paperclip, Mail as MailIcon, MailOpen, Trash2, RotateCcw, CalendarDays,
} from 'lucide-react-native';
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
import SidebarDrawer from '../components/SidebarDrawer';
@@ -42,7 +43,19 @@ function formatRelativeTime(date: Date): string {
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
function EmailRow({ item, onPress }: { item: Email; onPress: () => void }) {
function EmailRow({
item,
onPress,
onLongPress,
selected,
selectionMode,
}: {
item: Email;
onPress: () => void;
onLongPress: () => void;
selected: boolean;
selectionMode: boolean;
}) {
const senderName = getSenderName(item);
const senderEmail = getSenderEmail(item);
const unread = isUnread(item);
@@ -50,9 +63,24 @@ function EmailRow({ item, onPress }: { item: Email; onPress: () => void }) {
return (
<Pressable
style={({ pressed }) => [styles.emailRow, pressed && styles.emailRowPressed]}
style={({ pressed }) => [
styles.emailRow,
pressed && styles.emailRowPressed,
selected && styles.emailRowSelected,
]}
onPress={onPress}
onLongPress={onLongPress}
delayLongPress={300}
>
{selectionMode && (
<View style={styles.rowCheckboxWrap}>
{selected ? (
<SquareCheck size={16} color={colors.primary} />
) : (
<Square size={16} color={colors.textMuted} />
)}
</View>
)}
<SenderAvatar name={senderName} email={senderEmail} size={componentSizes.avatarMd} />
{/* Content */}
@@ -113,6 +141,77 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
const setSearchQuery = useEmailStore((s) => s.setSearchQuery);
const setFilters = useEmailStore((s) => s.setFilters);
const clearSearchAndFilters = useEmailStore((s) => s.clearSearchAndFilters);
const markRead = useEmailStore((s) => s.markRead);
const markUnread = useEmailStore((s) => s.markUnread);
const toggleStar = useEmailStore((s) => s.toggleStar);
const deleteEmailAction = useEmailStore((s) => s.deleteEmail);
// Selection state
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set());
const selectionMode = selectedIds.size > 0;
const allSelected =
emails.length > 0 && emails.every((e) => selectedIds.has(e.id));
const toggleSelect = React.useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const clearSelection = React.useCallback(() => {
setSelectedIds(new Set());
}, []);
const toggleSelectAllVisible = React.useCallback(() => {
setSelectedIds((prev) => {
const allCurrent = emails.length > 0 && emails.every((e) => prev.has(e.id));
if (allCurrent) return new Set();
return new Set(emails.map((e) => e.id));
});
}, [emails]);
// Clear selection when mailbox changes
React.useEffect(() => {
setSelectedIds(new Set());
}, [currentMailboxId]);
const selectedEmails = React.useMemo(
() => emails.filter((e) => selectedIds.has(e.id)),
[emails, selectedIds],
);
const allSelectedAreRead = selectedEmails.length > 0 && selectedEmails.every((e) => !isUnread(e));
const allSelectedAreStarred = selectedEmails.length > 0 && selectedEmails.every((e) => isStarred(e));
const handleBulkMarkReadToggle = async () => {
const ids = Array.from(selectedIds);
if (allSelectedAreRead) {
await Promise.all(ids.map((id) => markUnread(id)));
} else {
await Promise.all(ids.map((id) => markRead(id)));
}
clearSelection();
};
const handleBulkStar = async () => {
const ids = Array.from(selectedIds);
const next = !allSelectedAreStarred;
await Promise.all(ids.map((id) => toggleStar(id, next)));
clearSelection();
};
const handleBulkDelete = async () => {
const trash = mailboxes.find((m) => m.role === 'trash');
if (!trash || !currentMailboxId) {
clearSelection();
return;
}
const ids = Array.from(selectedIds);
await Promise.all(ids.map((id) => deleteEmailAction(id, trash.id, currentMailboxId)));
clearSelection();
};
// Local input state for uninterrupted typing; debounce into the store.
const [searchInput, setSearchInput] = React.useState(storeSearchQuery);
@@ -131,13 +230,35 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
}, [searchInput, storeSearchQuery, setSearchQuery]);
const activeFilterCount =
(filters.unread ? 1 : 0) + (filters.starred ? 1 : 0) + (filters.hasAttachment ? 1 : 0);
(filters.from ? 1 : 0) +
(filters.to ? 1 : 0) +
(filters.subject ? 1 : 0) +
(filters.dateAfter ? 1 : 0) +
(filters.dateBefore ? 1 : 0) +
(filters.hasAttachment !== undefined ? 1 : 0) +
(filters.isStarred !== undefined ? 1 : 0) +
(filters.isUnread !== undefined ? 1 : 0);
const hasActiveSearchOrFilter = Boolean(storeSearchQuery) || activeFilterCount > 0;
const toggleFilter = (key: keyof EmailFilters) => {
setFilters({ ...filters, [key]: !filters[key] });
const cycleTriState = (key: 'hasAttachment' | 'isStarred' | 'isUnread') => {
const current = filters[key];
// unset → true → false → unset
const next = current === undefined ? true : current === true ? false : undefined;
const updated: EmailFilters = { ...filters };
if (next === undefined) delete updated[key];
else updated[key] = next;
setFilters(updated);
};
const setFilterField = (key: keyof EmailFilters, value: string | undefined) => {
const updated: EmailFilters = { ...filters };
if (!value) delete updated[key];
else (updated as Record<string, unknown>)[key] = value;
setFilters(updated);
};
const [datePickerField, setDatePickerField] = React.useState<'dateAfter' | 'dateBefore' | null>(null);
const currentMailbox = React.useMemo(
() => mailboxes.find((m) => m.id === currentMailboxId),
[mailboxes, currentMailboxId],
@@ -160,23 +281,69 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
return (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Header */}
<View style={styles.header}>
<Pressable onPress={() => setDrawerOpen(true)} style={styles.headerButton}>
<Menu size={20} color={colors.textMuted} />
</Pressable>
<Text style={styles.headerTitle}>{currentMailbox?.name ?? 'Inbox'}</Text>
<View style={{ flex: 1 }} />
<Image
source={require('../../assets/logos/Bulwark Logo White.png')}
style={styles.headerLogo}
resizeMode="contain"
/>
</View>
{selectionMode ? (
<View style={styles.header}>
<Pressable onPress={clearSelection} style={styles.headerButton}>
<X size={20} color={colors.text} />
</Pressable>
<Text style={styles.headerTitle}>{selectedIds.size} selected</Text>
<Pressable
onPress={() => { void handleBulkStar(); }}
style={styles.headerButton}
hitSlop={6}
>
<Star
size={20}
color={allSelectedAreStarred ? colors.starred : colors.text}
fill={allSelectedAreStarred ? colors.starred : 'transparent'}
/>
</Pressable>
<Pressable
onPress={() => { void handleBulkMarkReadToggle(); }}
style={styles.headerButton}
hitSlop={6}
>
{allSelectedAreRead ? (
<MailIcon size={20} color={colors.text} />
) : (
<MailOpen size={20} color={colors.text} />
)}
</Pressable>
<Pressable
onPress={() => { void handleBulkDelete(); }}
style={styles.headerButton}
hitSlop={6}
>
<Trash2 size={20} color={colors.text} />
</Pressable>
</View>
) : (
<View style={styles.header}>
<Pressable onPress={() => setDrawerOpen(true)} style={styles.headerButton}>
<Menu size={20} color={colors.textMuted} />
</Pressable>
<Text style={styles.headerTitle}>{currentMailbox?.name ?? 'Inbox'}</Text>
<View style={{ flex: 1 }} />
<Image
source={require('../../assets/logos/Bulwark Logo White.png')}
style={styles.headerLogo}
resizeMode="contain"
/>
</View>
)}
{/* Search bar (always visible) */}
<View style={styles.searchBar}>
<Pressable style={styles.checkboxButton}>
<Square size={18} color={colors.textMuted} />
<Pressable style={styles.checkboxButton} onPress={toggleSelectAllVisible} hitSlop={6}>
{allSelected ? (
<SquareCheck size={18} color={colors.primary} />
) : selectionMode ? (
<View style={styles.checkboxIndeterminate}>
<Minus size={14} color={colors.background} />
</View>
) : (
<Square size={18} color={colors.textMuted} />
)}
</Pressable>
<View style={styles.searchInputArea}>
<Search size={16} color={colors.textMuted} />
@@ -223,22 +390,55 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
</Text>
</View>
) : null}
{filters.unread && (
{filters.from ? (
<View style={styles.chip}>
<MailIcon size={12} color={colors.textSecondary} />
<Text style={styles.chipText}>Unread</Text>
<Text style={styles.chipText} numberOfLines={1}>From: {filters.from}</Text>
</View>
) : null}
{filters.to ? (
<View style={styles.chip}>
<Text style={styles.chipText} numberOfLines={1}>To: {filters.to}</Text>
</View>
) : null}
{filters.subject ? (
<View style={styles.chip}>
<Text style={styles.chipText} numberOfLines={1}>Subject: {filters.subject}</Text>
</View>
) : null}
{filters.dateAfter ? (
<View style={styles.chip}>
<CalendarDays size={12} color={colors.textSecondary} />
<Text style={styles.chipText}>After {filters.dateAfter}</Text>
</View>
) : null}
{filters.dateBefore ? (
<View style={styles.chip}>
<CalendarDays size={12} color={colors.textSecondary} />
<Text style={styles.chipText}>Before {filters.dateBefore}</Text>
</View>
) : null}
{filters.isUnread !== undefined && (
<View style={styles.chip}>
{filters.isUnread ? (
<MailIcon size={12} color={colors.textSecondary} />
) : (
<MailOpen size={12} color={colors.textSecondary} />
)}
<Text style={styles.chipText}>{filters.isUnread ? 'Unread' : 'Read'}</Text>
</View>
)}
{filters.starred && (
{filters.isStarred !== undefined && (
<View style={styles.chip}>
<Star size={12} color={colors.starred} fill={colors.starred} />
<Text style={styles.chipText}>Starred</Text>
<Star size={12} color={colors.starred} fill={filters.isStarred ? colors.starred : 'transparent'} />
<Text style={styles.chipText}>{filters.isStarred ? 'Starred' : 'Not starred'}</Text>
</View>
)}
{filters.hasAttachment && (
{filters.hasAttachment !== undefined && (
<View style={styles.chip}>
<Paperclip size={12} color={colors.textSecondary} />
<Text style={styles.chipText}>Has attachment</Text>
<Text style={styles.chipText}>
{filters.hasAttachment ? 'Has attachment' : 'No attachment'}
</Text>
</View>
)}
<Pressable
@@ -290,7 +490,16 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
data={emails}
keyExtractor={item => item.id}
renderItem={({ item }) => (
<EmailRow item={item} onPress={() => onEmailPress?.(item)} />
<EmailRow
item={item}
selected={selectedIds.has(item.id)}
selectionMode={selectionMode}
onPress={() => {
if (selectionMode) toggleSelect(item.id);
else onEmailPress?.(item);
}}
onLongPress={() => toggleSelect(item.id)}
/>
)}
ItemSeparatorComponent={() => <View style={styles.separator} />}
contentContainerStyle={styles.listContent}
@@ -321,57 +530,218 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<View style={styles.filterBackdrop}>
<TouchableWithoutFeedback>
<View style={styles.filterMenu}>
<Text style={styles.filterMenuTitle}>Filter emails</Text>
<FilterToggle
label="Unread"
icon={<MailIcon size={16} color={colors.textSecondary} />}
active={!!filters.unread}
onPress={() => toggleFilter('unread')}
/>
<FilterToggle
label="Starred"
icon={<Star size={16} color={colors.starred} fill={filters.starred ? colors.starred : 'transparent'} />}
active={!!filters.starred}
onPress={() => toggleFilter('starred')}
/>
<FilterToggle
label="Has attachment"
icon={<Paperclip size={16} color={colors.textSecondary} />}
active={!!filters.hasAttachment}
onPress={() => toggleFilter('hasAttachment')}
/>
{activeFilterCount > 0 && (
<TouchableOpacity
style={styles.filterClearRow}
onPress={() => setFilters({})}
>
<Text style={styles.filterClearText}>Clear filters</Text>
</TouchableOpacity>
)}
<View style={styles.filterMenuHeader}>
<Text style={styles.filterMenuTitle}>Filter emails</Text>
<View style={styles.filterMenuHeaderActions}>
<Pressable
onPress={() => setFilters({})}
style={styles.filterMenuHeaderBtn}
hitSlop={6}
>
<RotateCcw size={12} color={colors.textSecondary} />
<Text style={styles.filterMenuHeaderBtnText}>Clear</Text>
</Pressable>
<Pressable
onPress={() => setFilterMenuOpen(false)}
style={styles.filterMenuClose}
hitSlop={6}
>
<X size={16} color={colors.textSecondary} />
</Pressable>
</View>
</View>
<ScrollView contentContainerStyle={styles.filterMenuBody} keyboardShouldPersistTaps="handled">
<View style={styles.filterFieldRow}>
<View style={styles.filterFieldHalf}>
<Text style={styles.filterFieldLabel}>From</Text>
<TextInput
value={filters.from ?? ''}
onChangeText={(v) => setFilterField('from', v)}
placeholder="sender@example.com"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
style={styles.filterFieldInput}
/>
</View>
<View style={styles.filterFieldHalf}>
<Text style={styles.filterFieldLabel}>To</Text>
<TextInput
value={filters.to ?? ''}
onChangeText={(v) => setFilterField('to', v)}
placeholder="recipient@example.com"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
style={styles.filterFieldInput}
/>
</View>
</View>
<View>
<Text style={styles.filterFieldLabel}>Subject</Text>
<TextInput
value={filters.subject ?? ''}
onChangeText={(v) => setFilterField('subject', v)}
placeholder="Subject contains..."
placeholderTextColor={colors.textMuted}
style={styles.filterFieldInput}
/>
</View>
<View style={styles.filterFieldRow}>
<View style={styles.filterFieldHalf}>
<Text style={styles.filterFieldLabel}>After</Text>
<Pressable
style={styles.filterDateButton}
onPress={() => setDatePickerField('dateAfter')}
>
<CalendarDays size={14} color={colors.textMuted} />
<Text style={[styles.filterDateText, !filters.dateAfter && styles.filterDateTextEmpty]}>
{filters.dateAfter || 'YYYY-MM-DD'}
</Text>
{filters.dateAfter ? (
<Pressable
onPress={() => setFilterField('dateAfter', undefined)}
hitSlop={6}
>
<X size={14} color={colors.textMuted} />
</Pressable>
) : null}
</Pressable>
</View>
<View style={styles.filterFieldHalf}>
<Text style={styles.filterFieldLabel}>Before</Text>
<Pressable
style={styles.filterDateButton}
onPress={() => setDatePickerField('dateBefore')}
>
<CalendarDays size={14} color={colors.textMuted} />
<Text style={[styles.filterDateText, !filters.dateBefore && styles.filterDateTextEmpty]}>
{filters.dateBefore || 'YYYY-MM-DD'}
</Text>
{filters.dateBefore ? (
<Pressable
onPress={() => setFilterField('dateBefore', undefined)}
hitSlop={6}
>
<X size={14} color={colors.textMuted} />
</Pressable>
) : null}
</Pressable>
</View>
</View>
<View style={styles.filterToggleGroup}>
<TriToggle
icon={<Paperclip size={14} color={colors.textSecondary} />}
label="Has attachment"
value={filters.hasAttachment}
onPress={() => cycleTriState('hasAttachment')}
/>
<TriToggle
icon={<Star size={14} color={colors.starred} fill={filters.isStarred ? colors.starred : 'transparent'} />}
label="Starred"
value={filters.isStarred}
onPress={() => cycleTriState('isStarred')}
/>
<TriToggle
icon={
filters.isUnread === false ? (
<MailOpen size={14} color={colors.textSecondary} />
) : (
<MailIcon size={14} color={colors.textSecondary} />
)
}
label={filters.isUnread === false ? 'Read' : 'Unread'}
value={filters.isUnread}
onPress={() => cycleTriState('isUnread')}
/>
</View>
</ScrollView>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
{datePickerField !== null && (() => {
const current = filters[datePickerField];
const initial = current ? new Date(current) : new Date();
const onChange = (event: DateTimePickerEvent, selected?: Date) => {
if (Platform.OS === 'android') {
setDatePickerField(null);
}
if (event.type === 'dismissed' || !selected) return;
const iso = selected.toISOString().slice(0, 10);
setFilterField(datePickerField, iso);
};
if (Platform.OS === 'ios') {
return (
<Modal transparent animationType="fade" onRequestClose={() => setDatePickerField(null)}>
<Pressable style={styles.pickerOverlay} onPress={() => setDatePickerField(null)} />
<View style={styles.pickerSheet}>
<View style={styles.pickerHeader}>
<Pressable onPress={() => setDatePickerField(null)} hitSlop={8}>
<Text style={styles.pickerDone}>Done</Text>
</Pressable>
</View>
<DateTimePicker
value={initial}
mode="date"
display="spinner"
onChange={onChange}
/>
</View>
</Modal>
);
}
return (
<DateTimePicker
value={initial}
mode="date"
display="default"
onChange={onChange}
/>
);
})()}
</SafeAreaView>
);
}
function FilterToggle({
label, icon, active, onPress,
}: { label: string; icon: React.ReactNode; active: boolean; onPress: () => void }) {
function TriToggle({
icon,
label,
value,
onPress,
}: {
icon: React.ReactNode;
label: string;
value: boolean | undefined;
onPress: () => void;
}) {
const state =
value === true ? 'on' : value === false ? 'off' : 'unset';
return (
<Pressable
style={({ pressed }) => [styles.filterToggle, pressed && styles.filterTogglePressed]}
onPress={onPress}
style={[
styles.triToggle,
state === 'on' && styles.triToggleOn,
state === 'off' && styles.triToggleOff,
]}
>
<View style={styles.filterToggleLeft}>
{icon}
<Text style={styles.filterToggleLabel}>{label}</Text>
</View>
<View style={[styles.filterCheck, active && styles.filterCheckActive]}>
{active && <Check size={14} color={colors.background} />}
</View>
{icon}
<Text
style={[
styles.triToggleText,
state === 'on' && styles.triToggleTextOn,
state === 'off' && styles.triToggleTextOff,
]}
>
{label}
</Text>
</Pressable>
);
}
@@ -501,63 +871,159 @@ const styles = StyleSheet.create({
},
filterMenu: {
width: '100%',
maxWidth: 360,
maxWidth: 380,
maxHeight: '85%',
backgroundColor: colors.popover,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
paddingVertical: spacing.sm,
},
filterMenuTitle: {
...typography.bodySemibold,
color: colors.text,
paddingHorizontal: spacing.lg,
paddingTop: spacing.sm,
paddingBottom: spacing.md,
},
filterToggle: {
filterMenuHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
paddingTop: spacing.md,
paddingBottom: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
filterTogglePressed: {
backgroundColor: colors.surfaceHover,
},
filterToggleLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
flex: 1,
},
filterToggleLabel: {
...typography.body,
filterMenuTitle: {
...typography.bodySemibold,
color: colors.text,
},
filterCheck: {
width: 20,
height: 20,
filterMenuHeaderActions: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
filterMenuHeaderBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: radius.xs,
borderWidth: 1.5,
borderColor: colors.border,
},
filterMenuHeaderBtnText: {
...typography.caption,
color: colors.textSecondary,
},
filterMenuClose: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.xs,
},
filterCheckActive: {
backgroundColor: colors.primary,
borderColor: colors.primary,
filterMenuBody: {
padding: spacing.lg,
gap: spacing.md,
},
filterClearRow: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
filterFieldRow: {
flexDirection: 'row',
gap: spacing.sm,
},
filterFieldHalf: { flex: 1 },
filterFieldLabel: {
...typography.caption,
color: colors.textMuted,
marginBottom: 4,
},
filterFieldInput: {
...typography.body,
color: colors.text,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: 6,
height: 32,
},
filterDateButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
height: 32,
},
filterDateText: {
...typography.body,
color: colors.text,
flex: 1,
},
filterDateTextEmpty: {
color: colors.textMuted,
},
filterToggleGroup: {
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'center',
gap: spacing.sm,
marginTop: 4,
},
triToggle: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.background,
},
triToggleOn: {
backgroundColor: 'rgba(59, 130, 246, 0.1)',
borderColor: 'rgba(59, 130, 246, 0.3)',
},
triToggleOff: {
backgroundColor: colors.muted,
borderColor: colors.border,
},
triToggleText: {
...typography.caption,
color: colors.textMuted,
},
triToggleTextOn: {
color: colors.primary,
},
triToggleTextOff: {
color: colors.textMuted,
textDecorationLine: 'line-through',
},
pickerOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.4)',
},
pickerSheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
backgroundColor: colors.popover,
paddingBottom: spacing.xl,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
borderTopWidth: 1,
borderTopColor: colors.border,
marginTop: spacing.xs,
borderColor: colors.border,
},
filterClearText: {
pickerHeader: {
flexDirection: 'row',
justifyContent: 'flex-end',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
pickerDone: {
...typography.bodyMedium,
color: colors.error,
color: colors.primary,
},
// Email list — matches web email-list-item
@@ -571,6 +1037,22 @@ const styles = StyleSheet.create({
borderBottomColor: colors.border, // border-b border-border
},
emailRowPressed: { backgroundColor: colors.surface },
emailRowSelected: { backgroundColor: colors.selection },
rowCheckboxWrap: {
width: 16,
height: componentSizes.avatarMd,
alignItems: 'center',
justifyContent: 'center',
marginTop: 2,
},
checkboxIndeterminate: {
width: 18,
height: 18,
borderRadius: radius.xs,
backgroundColor: colors.primary,
alignItems: 'center',
justifyContent: 'center',
},
emailContent: { flex: 1, minWidth: 0 },
emailHeaderRow: {
flexDirection: 'row',
+107 -27
View File
@@ -1,14 +1,21 @@
import React from 'react';
import React, { useState } from 'react';
import { View, Text, StyleSheet, ScrollView, Pressable } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import {
ArrowLeft, LogOut, Settings,
ArrowLeft, LogOut, Settings, ChevronRight,
Palette, Mail, User, Shield, UserPen, Palmtree, Calendar,
Filter, FileText, FolderOpen, Tags, HardDrive, Wrench,
BookUser, KeyRound, PanelLeftClose, Bell, Puzzle,
type LucideIcon,
} from 'lucide-react-native';
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
import { EmailSettings } from '../components/settings/EmailSettings';
import { NotificationSettings } from '../components/settings/NotificationSettings';
import { AccountSettings } from '../components/settings/AccountSettings';
import { IdentitySettings } from '../components/settings/IdentitySettings';
import { VacationSettings } from '../components/settings/VacationSettings';
import { FolderSettings } from '../components/settings/FolderSettings';
import { AdvancedSettings } from '../components/settings/AdvancedSettings';
type Tab =
| 'appearance' | 'email' | 'notifications'
@@ -25,6 +32,7 @@ interface TabDef {
icon: LucideIcon;
group: TabGroup;
experimental?: boolean;
implemented: boolean;
}
const GROUP_LABELS: Record<TabGroup, string> = {
@@ -38,27 +46,37 @@ const GROUP_LABELS: Record<TabGroup, string> = {
const GROUP_ORDER: TabGroup[] = ['general', 'account', 'organization', 'apps', 'system'];
const TABS: TabDef[] = [
{ id: 'appearance', label: 'Appearance', icon: Palette, group: 'general' },
{ id: 'email', label: 'Email', icon: Mail, group: 'general' },
{ id: 'notifications',label: 'Notifications', icon: Bell, group: 'general' },
{ id: 'account', label: 'Account', icon: User, group: 'account' },
{ id: 'security', label: 'Security', icon: Shield, group: 'account' },
{ id: 'identities', label: 'Identities', icon: UserPen, group: 'account' },
{ id: 'encryption', label: 'S/MIME Encryption', icon: KeyRound, group: 'account' },
{ id: 'vacation', label: 'Vacation Responder', icon: Palmtree, group: 'account' },
{ id: 'filters', label: 'Filters & Rules', icon: Filter, group: 'organization' },
{ id: 'templates', label: 'Templates', icon: FileText, group: 'organization' },
{ id: 'folders', label: 'Folders', icon: FolderOpen, group: 'organization' },
{ id: 'keywords', label: 'Keywords & Labels', icon: Tags, group: 'organization' },
{ id: 'calendar', label: 'Calendar', icon: Calendar, group: 'apps' },
{ id: 'contacts', label: 'Contacts', icon: BookUser, group: 'apps' },
{ id: 'files', label: 'Files', icon: HardDrive, group: 'apps' },
{ id: 'sidebar_apps', label: 'Sidebar Apps', icon: PanelLeftClose, group: 'apps' },
{ id: 'themes', label: 'Themes', icon: Palette, group: 'system', experimental: true },
{ id: 'plugins', label: 'Plugins', icon: Puzzle, group: 'system', experimental: true },
{ id: 'advanced', label: 'Advanced', icon: Wrench, group: 'system' },
{ id: 'appearance', label: 'Appearance', icon: Palette, group: 'general', implemented: false },
{ id: 'email', label: 'Email', icon: Mail, group: 'general', implemented: true },
{ id: 'notifications',label: 'Notifications', icon: Bell, group: 'general', implemented: true },
{ id: 'account', label: 'Account', icon: User, group: 'account', implemented: true },
{ id: 'security', label: 'Security', icon: Shield, group: 'account', implemented: false },
{ id: 'identities', label: 'Identities', icon: UserPen, group: 'account', implemented: true },
{ id: 'encryption', label: 'S/MIME Encryption', icon: KeyRound, group: 'account', implemented: false },
{ id: 'vacation', label: 'Vacation Responder', icon: Palmtree, group: 'account', implemented: true },
{ id: 'filters', label: 'Filters & Rules', icon: Filter, group: 'organization', implemented: false },
{ id: 'templates', label: 'Templates', icon: FileText, group: 'organization', implemented: false },
{ id: 'folders', label: 'Folders', icon: FolderOpen, group: 'organization', implemented: true },
{ id: 'keywords', label: 'Keywords & Labels', icon: Tags, group: 'organization', implemented: false },
{ id: 'calendar', label: 'Calendar', icon: Calendar, group: 'apps', implemented: false },
{ id: 'contacts', label: 'Contacts', icon: BookUser, group: 'apps', implemented: false },
{ id: 'files', label: 'Files', icon: HardDrive, group: 'apps', implemented: false },
{ id: 'sidebar_apps', label: 'Sidebar Apps', icon: PanelLeftClose, group: 'apps', implemented: false },
{ id: 'themes', label: 'Themes', icon: Palette, group: 'system', experimental: true, implemented: false },
{ id: 'plugins', label: 'Plugins', icon: Puzzle, group: 'system', experimental: true, implemented: false },
{ id: 'advanced', label: 'Advanced', icon: Wrench, group: 'system', implemented: true },
];
const TAB_COMPONENTS: Partial<Record<Tab, React.ComponentType<any>>> = {
email: EmailSettings,
notifications: NotificationSettings,
account: AccountSettings,
identities: IdentitySettings,
vacation: VacationSettings,
folders: FolderSettings,
advanced: AdvancedSettings,
};
function groupTabs() {
return GROUP_ORDER.map(group => ({
group,
@@ -74,8 +92,40 @@ interface SettingsScreenProps {
}
export default function SettingsScreen({ onLogout, onBack, onTabSelect }: SettingsScreenProps) {
const [selectedTab, setSelectedTab] = useState<Tab | null>(null);
const groupedTabs = groupTabs();
const handleTabPress = (tab: TabDef) => {
if (!tab.implemented) return;
setSelectedTab(tab.id);
onTabSelect?.(tab.id);
};
if (selectedTab) {
const tabDef = TABS.find(t => t.id === selectedTab)!;
const Component = TAB_COMPONENTS[selectedTab];
const TabIcon = tabDef.icon;
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<Pressable
onPress={() => setSelectedTab(null)}
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
>
<ArrowLeft size={20} color={colors.text} />
</Pressable>
<TabIcon size={20} color={colors.mutedForeground} />
<Text style={styles.headerTitle}>{tabDef.label}</Text>
</View>
<ScrollView style={styles.scrollArea} contentContainerStyle={styles.detailContent}>
{Component ? <Component /> : null}
</ScrollView>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Header — matches webmail mobile: h-14, border-b, back button + icon + title */}
@@ -103,16 +153,42 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
{group.items.map((tab) => {
const Icon = tab.icon;
const disabled = !tab.implemented;
return (
<View key={tab.id} style={[styles.tabItem, styles.tabItemDisabled]}>
<Pressable
key={tab.id}
onPress={() => handleTabPress(tab)}
disabled={disabled}
style={({ pressed }) => [
styles.tabItem,
disabled && styles.tabItemDisabled,
!disabled && pressed && styles.tabItemPressed,
]}
>
<View style={styles.tabItemLeft}>
<Icon size={16} color={colors.mutedForeground} />
<Text style={[styles.tabItemLabel, styles.tabItemLabelDisabled]}>{tab.label}</Text>
<Text
style={[
styles.tabItemLabel,
disabled && styles.tabItemLabelDisabled,
]}
>
{tab.label}
</Text>
{tab.experimental && !disabled && (
<View style={styles.experimentalBadge}>
<Text style={styles.experimentalText}>Experimental</Text>
</View>
)}
</View>
<View style={styles.notWorkingBadge}>
<Text style={styles.notWorkingText}>Not working yet</Text>
</View>
</View>
{disabled ? (
<View style={styles.notWorkingBadge}>
<Text style={styles.notWorkingText}>Not implemented</Text>
</View>
) : (
<ChevronRight size={16} color={colors.mutedForeground} />
)}
</Pressable>
);
})}
</View>
@@ -171,6 +247,10 @@ const styles = StyleSheet.create({
scrollContent: {
paddingBottom: 40,
},
detailContent: {
padding: spacing.lg,
paddingBottom: 40,
},
tabList: {
paddingVertical: spacing.sm,
+50 -2
View File
@@ -6,6 +6,21 @@ import { useEmailStore } from './email-store';
import { useContactsStore } from './contacts-store';
import { useCalendarStore } from './calendar-store';
import { generateAccountId } from '../lib/account-utils';
import { teardownPushNotifications } from '../lib/push-notifications';
// Persist middleware hydrates asynchronously on cold start. Without this
// guard, restoreSession() can read the account-store before AsyncStorage has
// loaded the previous active account, then short-circuit to LoginScreen even
// though the user is actually signed in.
async function waitForHydration(store: { persist: { hasHydrated: () => boolean; onFinishHydration: (cb: () => void) => () => void } }): Promise<void> {
if (store.persist.hasHydrated()) return;
await new Promise<void>((resolve) => {
const unsubscribe = store.persist.onFinishHydration(() => {
unsubscribe();
resolve();
});
});
}
export interface AuthState {
isAuthenticated: boolean;
@@ -35,9 +50,22 @@ function resetFeatureStores(): void {
function refetchFeatureStores(): void {
// Fire-and-forget: each store handles its own errors.
void useEmailStore.getState().fetchMailboxes();
const emailStore = useEmailStore.getState();
void emailStore.fetchMailboxes();
// If a mailbox was selected before this restore (cached from last session),
// refresh its contents so the user sees up-to-date mail without manually
// pulling to refresh.
if (emailStore.currentMailboxId) {
void emailStore.refreshEmails();
}
void useContactsStore.getState().fetchContacts();
void useCalendarStore.getState().fetchCalendars();
const calendarStore = useCalendarStore.getState();
void calendarStore.fetchCalendars();
// Refresh the event range cached from last session (if any) so recurring
// events reflect new invitations / cancellations without the user swiping.
if (calendarStore.loadedRange) {
void calendarStore.refresh();
}
}
function applyConnectedState(
@@ -114,6 +142,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
const accountStore = useAccountStore.getState();
const currentId = get().activeAccountId;
// Best-effort: revoke the JMAP PushSubscription and drop the relay
// mapping before we lose credentials. Do not abort logout on failure.
await teardownPushNotifications().catch(() => undefined);
// Clear credentials for this account first
if (currentId) {
await jmapClient.clearAccountCredentials(currentId);
@@ -154,6 +186,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
logoutAll: async () => {
const accountStore = useAccountStore.getState();
const ids = accountStore.accounts.map((a) => a.id);
await teardownPushNotifications().catch(() => undefined);
await jmapClient.clearAllCredentials(ids);
jmapClient.reset();
resetFeatureStores();
@@ -215,6 +248,17 @@ export const useAuthStore = create<AuthState>((set, get) => ({
restoreSession: async () => {
set({ isLoading: true });
try {
// Wait for persisted caches to finish hydrating from AsyncStorage.
// Otherwise we read empty defaults and bounce the user back to the
// login screen — and the feature stores don't have their cached data
// yet when refetchFeatureStores() checks currentMailboxId / loadedRange
// at the end of this function.
await Promise.all([
waitForHydration(useAccountStore),
waitForHydration(useEmailStore),
waitForHydration(useCalendarStore),
waitForHydration(useContactsStore),
]);
const accountStore = useAccountStore.getState();
// Legacy migration: if there are no registered accounts but the old
@@ -254,6 +298,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
const session = jmapClient.currentSession!;
applyConnectedState(set, session, target.serverUrl, target.username, target.id);
// Refresh the cached mailbox list + current folder now that the session
// is live. Feature stores show persisted data immediately; this swaps
// in fresh data once the network round-trip completes.
refetchFeatureStores();
return true;
} catch {
set({ isLoading: false, hasRestoredSession: true });
+19 -2
View File
@@ -1,4 +1,5 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { Calendar, CalendarEvent, StateChange } from '../api/types';
import {
@@ -60,7 +61,9 @@ function unionRange(loaded: LoadedRange, after: string, before: string): LoadedR
};
}
export const useCalendarStore = create<CalendarState>((set, get) => ({
export const useCalendarStore = create<CalendarState>()(
persist(
(set, get) => ({
calendars: [],
events: [],
hiddenCalendarIds: [],
@@ -205,7 +208,21 @@ export const useCalendarStore = create<CalendarState>((set, get) => ({
loading: false,
error: null,
}),
}));
}),
{
// Persist calendars + expanded events + the range they cover so the
// calendar renders instantly on re-open. A refresh happens in the
// background and replaces the cached data with fresh copies.
name: 'calendar-cache',
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({
calendars: state.calendars,
events: state.events,
loadedRange: state.loadedRange,
}),
},
),
);
// ─── Selectors ───────────────────────────────────────────
export function selectVisibleCalendars(state: CalendarState): Calendar[] {
+18 -2
View File
@@ -1,4 +1,5 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { ContactCard, AddressBook, StateChange } from '../api/types';
import {
@@ -59,7 +60,9 @@ function persistCategory(category: ContactCategory): void {
);
}
export const useContactsStore = create<ContactsState>((set, get) => ({
export const useContactsStore = create<ContactsState>()(
persist(
(set, get) => ({
addressBooks: [],
contacts: [],
selectedCategory: { type: 'all' },
@@ -185,7 +188,20 @@ export const useContactsStore = create<ContactsState>((set, get) => ({
loading: false,
error: null,
}),
}));
}),
{
// Persist address books and contact cards so the list renders instantly
// on re-open. A refresh runs in the background once the JMAP session is
// ready and replaces the cached data.
name: 'contacts-cache',
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({
addressBooks: state.addressBooks,
contacts: state.contacts,
}),
},
),
);
// ─── Selectors ───────────────────────────────────────────
+64 -12
View File
@@ -1,4 +1,6 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { Email, Mailbox } from '../api/types';
import {
getMailboxes as fetchMailboxes,
@@ -10,11 +12,17 @@ import {
deleteEmail as apiDeleteEmail,
searchEmails as apiSearchEmails,
} from '../api/email';
import { toWildcardQuery } from '../lib/search-utils';
export interface EmailFilters {
unread?: boolean;
starred?: boolean;
hasAttachment?: boolean;
from?: string;
to?: string;
subject?: string;
dateAfter?: string; // YYYY-MM-DD
dateBefore?: string; // YYYY-MM-DD
hasAttachment?: boolean; // undefined = unset, true = with, false = without
isStarred?: boolean;
isUnread?: boolean;
}
export interface EmailState {
@@ -49,16 +57,43 @@ function buildJmapFilter(
searchQuery: string,
filters: EmailFilters,
): Record<string, unknown> {
const f: Record<string, unknown> = { inMailbox: mailboxId };
const conditions: Record<string, unknown>[] = [{ inMailbox: mailboxId }];
const trimmed = searchQuery.trim();
if (trimmed) f.text = trimmed;
if (filters.unread) f.notKeyword = '$seen';
if (filters.starred) f.hasKeyword = '$flagged';
if (filters.hasAttachment) f.hasAttachment = true;
return f;
if (trimmed) conditions.push({ text: toWildcardQuery(trimmed) });
if (filters.from) conditions.push({ from: filters.from });
if (filters.to) conditions.push({ to: filters.to });
if (filters.subject) conditions.push({ subject: filters.subject });
if (filters.dateAfter) {
const d = new Date(filters.dateAfter);
if (!isNaN(d.getTime())) conditions.push({ after: d.toISOString() });
}
if (filters.dateBefore) {
const d = new Date(filters.dateBefore);
if (!isNaN(d.getTime())) {
d.setHours(23, 59, 59, 999);
conditions.push({ before: d.toISOString() });
}
}
if (filters.hasAttachment === true) conditions.push({ hasAttachment: true });
else if (filters.hasAttachment === false) conditions.push({ hasAttachment: false });
if (filters.isUnread === true) conditions.push({ notKeyword: '$seen' });
else if (filters.isUnread === false) conditions.push({ hasKeyword: '$seen' });
if (filters.isStarred === true) conditions.push({ hasKeyword: '$flagged' });
else if (filters.isStarred === false) conditions.push({ notKeyword: '$flagged' });
if (conditions.length === 1) return conditions[0];
return { operator: 'AND', conditions };
}
export const useEmailStore = create<EmailState>((set, get) => ({
export const useEmailStore = create<EmailState>()(
persist(
(set, get) => ({
mailboxes: [],
currentMailboxId: null,
emails: [],
@@ -122,7 +157,9 @@ export const useEmailStore = create<EmailState>((set, get) => ({
refreshEmails: async () => {
const { currentMailboxId, searchQuery, filters } = get();
if (!currentMailboxId) return;
set({ loading: true, error: null, emails: [], totalEmails: 0 });
// Keep existing emails visible while refreshing — swap atomically once
// the new list is ready so pull-to-refresh doesn't flash an empty list.
set({ loading: true, error: null });
try {
const filter = buildJmapFilter(currentMailboxId, searchQuery, filters);
const { ids, total } = await queryEmails(currentMailboxId, { limit: 50, filter });
@@ -221,4 +258,19 @@ export const useEmailStore = create<EmailState>((set, get) => ({
searchQuery: '',
filters: {},
}),
}));
}),
{
// Persist the mail list so the UI can render instantly on re-open,
// before the JMAP session has finished restoring. auth-store triggers
// a background refresh once the session is ready.
name: 'email-cache',
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({
mailboxes: state.mailboxes,
currentMailboxId: state.currentMailboxId,
emails: state.emails,
totalEmails: state.totalEmails,
}),
},
),
);
+20
View File
@@ -11,12 +11,14 @@ interface PersistedSettings {
externalContentPolicy: ExternalContentPolicy;
trustedSenders: string[];
senderFavicons: boolean;
groupContactsByLetter: boolean;
}
const DEFAULT_PERSISTED: PersistedSettings = {
externalContentPolicy: 'ask',
trustedSenders: [],
senderFavicons: true,
groupContactsByLetter: true,
};
export interface SettingsState {
@@ -27,12 +29,14 @@ export interface SettingsState {
externalContentPolicy: ExternalContentPolicy;
trustedSenders: string[];
senderFavicons: boolean;
groupContactsByLetter: boolean;
hydrated: boolean;
fetchIdentities: () => Promise<void>;
hydrate: () => Promise<void>;
setExternalContentPolicy: (policy: ExternalContentPolicy) => void;
setSenderFavicons: (enabled: boolean) => void;
setGroupContactsByLetter: (enabled: boolean) => void;
addTrustedSender: (email: string) => void;
removeTrustedSender: (email: string) => void;
isSenderTrusted: (email: string) => boolean;
@@ -53,6 +57,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
externalContentPolicy: DEFAULT_PERSISTED.externalContentPolicy,
trustedSenders: DEFAULT_PERSISTED.trustedSenders,
senderFavicons: DEFAULT_PERSISTED.senderFavicons,
groupContactsByLetter: DEFAULT_PERSISTED.groupContactsByLetter,
hydrated: false,
fetchIdentities: async () => {
@@ -75,6 +80,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
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,
hydrated: true,
});
return;
@@ -91,6 +97,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
externalContentPolicy: policy,
trustedSenders: get().trustedSenders,
senderFavicons: get().senderFavicons,
groupContactsByLetter: get().groupContactsByLetter,
});
},
@@ -100,6 +107,17 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
externalContentPolicy: get().externalContentPolicy,
trustedSenders: get().trustedSenders,
senderFavicons: enabled,
groupContactsByLetter: get().groupContactsByLetter,
});
},
setGroupContactsByLetter: (enabled) => {
set({ groupContactsByLetter: enabled });
persist({
externalContentPolicy: get().externalContentPolicy,
trustedSenders: get().trustedSenders,
senderFavicons: get().senderFavicons,
groupContactsByLetter: enabled,
});
},
@@ -114,6 +132,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
externalContentPolicy: get().externalContentPolicy,
trustedSenders: next,
senderFavicons: get().senderFavicons,
groupContactsByLetter: get().groupContactsByLetter,
});
},
@@ -125,6 +144,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
externalContentPolicy: get().externalContentPolicy,
trustedSenders: next,
senderFavicons: get().senderFavicons,
groupContactsByLetter: get().groupContactsByLetter,
});
},
+86
View File
@@ -0,0 +1,86 @@
import { create } from 'zustand';
import {
getVacationResponse,
setVacationResponse,
isVacationSupported,
type VacationResponse,
} from '../api/vacation';
export interface VacationState {
isEnabled: boolean;
fromDate: string | null;
toDate: string | null;
subject: string;
textBody: string;
htmlBody: string | null;
isLoading: boolean;
isSaving: boolean;
error: string | null;
isSupported: boolean;
hasLoaded: boolean;
fetch: () => Promise<void>;
save: (updates: Partial<Omit<VacationResponse, 'id'>>) => Promise<void>;
reset: () => void;
}
const INITIAL = {
isEnabled: false,
fromDate: null,
toDate: null,
subject: '',
textBody: '',
htmlBody: null,
isLoading: false,
isSaving: false,
error: null,
isSupported: false,
hasLoaded: false,
};
export const useVacationStore = create<VacationState>((set) => ({
...INITIAL,
fetch: async () => {
set({ isLoading: true, error: null, isSupported: isVacationSupported() });
try {
const vacation = await getVacationResponse();
set({
isEnabled: vacation.isEnabled,
fromDate: vacation.fromDate,
toDate: vacation.toDate,
subject: vacation.subject ?? '',
textBody: vacation.textBody ?? '',
htmlBody: vacation.htmlBody,
isLoading: false,
hasLoaded: true,
});
} catch (err) {
set({
isLoading: false,
hasLoaded: true,
error: err instanceof Error ? err.message : 'Failed to load vacation responder',
});
}
},
save: async (updates) => {
set({ isSaving: true, error: null });
try {
await setVacationResponse(updates);
set((s) => ({
...s,
...updates,
isSaving: false,
}));
} catch (err) {
set({
isSaving: false,
error: err instanceof Error ? err.message : 'Failed to save vacation responder',
});
throw err;
}
},
reset: () => set({ ...INITIAL }),
}));