mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
feat: implement keyword management and layout settings for email interactions
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
import React, { useRef } from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, Animated, PanResponder, Pressable, Dimensions,
|
||||
} from 'react-native';
|
||||
import {
|
||||
Archive, Trash2, ShieldAlert, MailOpen, Mail, Star,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react-native';
|
||||
import { colors, typography } from '../theme/tokens';
|
||||
import type { SwipeAction } from '../stores/settings-store';
|
||||
|
||||
interface SwipeableRowProps {
|
||||
children: React.ReactNode;
|
||||
/** Right-to-left swipe action (revealed under a leftward drag). */
|
||||
leftAction: SwipeAction;
|
||||
/** Left-to-right swipe action (revealed under a rightward drag). */
|
||||
rightAction: SwipeAction;
|
||||
/** Pass the row state used to compute action labels (e.g. unread/starred toggling). */
|
||||
context: { unread: boolean; starred: boolean };
|
||||
onAction: (action: SwipeAction) => void;
|
||||
}
|
||||
|
||||
const ACTIVATION_THRESHOLD = 0.4; // fraction of row width
|
||||
const DIRECTION_BIAS = 1.5; // dx must dominate dy by this factor to claim the gesture
|
||||
const MIN_DX_TO_CLAIM = 6;
|
||||
|
||||
const ACTION_META: Record<Exclude<SwipeAction, 'none'>, { icon: LucideIcon; bg: string; defaultLabel: string }> = {
|
||||
archive: { icon: Archive, bg: '#1d4ed8', defaultLabel: 'Archive' },
|
||||
delete: { icon: Trash2, bg: '#b91c1c', defaultLabel: 'Delete' },
|
||||
spam: { icon: ShieldAlert, bg: '#a16207', defaultLabel: 'Spam' },
|
||||
read: { icon: MailOpen, bg: '#0f766e', defaultLabel: 'Read' },
|
||||
star: { icon: Star, bg: '#a16207', defaultLabel: 'Star' },
|
||||
};
|
||||
|
||||
function actionLabel(action: SwipeAction, context: { unread: boolean; starred: boolean }): string {
|
||||
if (action === 'read') return context.unread ? 'Read' : 'Unread';
|
||||
if (action === 'star') return context.starred ? 'Unstar' : 'Star';
|
||||
if (action === 'none') return '';
|
||||
return ACTION_META[action].defaultLabel;
|
||||
}
|
||||
|
||||
export function SwipeableRow({
|
||||
children, leftAction, rightAction, context, onAction,
|
||||
}: SwipeableRowProps) {
|
||||
const dx = useRef(new Animated.Value(0)).current;
|
||||
const claimed = useRef(false);
|
||||
const widthRef = useRef(Dimensions.get('window').width);
|
||||
|
||||
const responder = useRef(
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => false,
|
||||
onMoveShouldSetPanResponder: (_, g) => {
|
||||
if (Math.abs(g.dx) < MIN_DX_TO_CLAIM) return false;
|
||||
if (Math.abs(g.dx) < Math.abs(g.dy) * DIRECTION_BIAS) return false;
|
||||
if (g.dx > 0 && rightAction === 'none') return false;
|
||||
if (g.dx < 0 && leftAction === 'none') return false;
|
||||
claimed.current = true;
|
||||
return true;
|
||||
},
|
||||
onPanResponderMove: (_, g) => {
|
||||
const max = widthRef.current;
|
||||
const clamped = Math.max(-max, Math.min(max, g.dx));
|
||||
dx.setValue(clamped);
|
||||
},
|
||||
onPanResponderRelease: (_, g) => {
|
||||
const threshold = widthRef.current * ACTIVATION_THRESHOLD;
|
||||
const action = g.dx > 0 ? rightAction : leftAction;
|
||||
const triggered = Math.abs(g.dx) > threshold && action !== 'none';
|
||||
if (triggered) {
|
||||
Animated.timing(dx, {
|
||||
toValue: g.dx > 0 ? widthRef.current : -widthRef.current,
|
||||
duration: 160,
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
onAction(action);
|
||||
dx.setValue(0);
|
||||
});
|
||||
} else {
|
||||
Animated.spring(dx, {
|
||||
toValue: 0,
|
||||
useNativeDriver: true,
|
||||
speed: 24,
|
||||
bounciness: 4,
|
||||
}).start();
|
||||
}
|
||||
claimed.current = false;
|
||||
},
|
||||
onPanResponderTerminate: () => {
|
||||
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
claimed.current = false;
|
||||
},
|
||||
onPanResponderTerminationRequest: () => !claimed.current,
|
||||
}),
|
||||
).current;
|
||||
|
||||
const renderBand = (action: SwipeAction, side: 'left' | 'right') => {
|
||||
if (action === 'none') return null;
|
||||
const meta = ACTION_META[action];
|
||||
const Icon = meta.icon;
|
||||
const label = actionLabel(action, context);
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.band,
|
||||
side === 'left' ? { left: 0, alignItems: 'flex-start' } : { right: 0, alignItems: 'flex-end' },
|
||||
{ backgroundColor: meta.bg },
|
||||
]}
|
||||
pointerEvents="none"
|
||||
>
|
||||
<View style={styles.bandInner}>
|
||||
<Icon size={20} color="#fff" />
|
||||
<Text style={styles.bandLabel}>{label}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const onLayout = (e: { nativeEvent: { layout: { width: number } } }) => {
|
||||
widthRef.current = e.nativeEvent.layout.width;
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.wrap} onLayout={onLayout} {...responder.panHandlers}>
|
||||
{renderBand(rightAction, 'left')}
|
||||
{renderBand(leftAction, 'right')}
|
||||
<Animated.View style={{ transform: [{ translateX: dx }] }}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrap: { position: 'relative' },
|
||||
band: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '100%',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
bandInner: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
bandLabel: { color: '#fff', fontWeight: '600', ...typography.caption },
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
|
||||
import { X, Plus } 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';
|
||||
|
||||
export function ComposingSettings() {
|
||||
const autoSelectReplyIdentity = useSettingsStore((s) => s.autoSelectReplyIdentity);
|
||||
const setAutoSelectReplyIdentity = useSettingsStore((s) => s.setAutoSelectReplyIdentity);
|
||||
const attachmentReminderEnabled = useSettingsStore((s) => s.attachmentReminderEnabled);
|
||||
const setAttachmentReminderEnabled = useSettingsStore((s) => s.setAttachmentReminderEnabled);
|
||||
const attachmentReminderKeywords = useSettingsStore((s) => s.attachmentReminderKeywords);
|
||||
const setAttachmentReminderKeywords = useSettingsStore((s) => s.setAttachmentReminderKeywords);
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
|
||||
const [newKeyword, setNewKeyword] = useState('');
|
||||
|
||||
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
|
||||
|
||||
const addKeyword = () => {
|
||||
const trimmed = newKeyword.trim().toLowerCase();
|
||||
if (!trimmed || attachmentReminderKeywords.includes(trimmed)) {
|
||||
setNewKeyword('');
|
||||
return;
|
||||
}
|
||||
setAttachmentReminderKeywords([...attachmentReminderKeywords, trimmed]);
|
||||
setNewKeyword('');
|
||||
};
|
||||
|
||||
const removeKeyword = (kw: string) => {
|
||||
setAttachmentReminderKeywords(attachmentReminderKeywords.filter((k) => k !== kw));
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title="Composing"
|
||||
description="How the composer behaves when you reply, forward, or send."
|
||||
>
|
||||
<SettingItem
|
||||
label="Auto-select reply identity"
|
||||
description="When replying, pick the identity that received the original message."
|
||||
>
|
||||
<ToggleSwitch checked={autoSelectReplyIdentity} onChange={setAutoSelectReplyIdentity} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Attachment reminder"
|
||||
description="Warn before sending if the message mentions an attachment but none is attached."
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={attachmentReminderEnabled}
|
||||
onChange={setAttachmentReminderEnabled}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{attachmentReminderEnabled && (
|
||||
<View style={styles.subBlock}>
|
||||
<Text style={styles.subLabel}>Trigger keywords</Text>
|
||||
<Text style={styles.subDescription}>
|
||||
Words that, when found in the body or subject without an attachment, trigger the reminder.
|
||||
</Text>
|
||||
|
||||
<View style={styles.chips}>
|
||||
{attachmentReminderKeywords.map((kw) => (
|
||||
<View key={kw} style={styles.chip}>
|
||||
<Text style={styles.chipText}>{kw}</Text>
|
||||
<Pressable onPress={() => removeKeyword(kw)} hitSlop={6}>
|
||||
<X size={12} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.addRow}>
|
||||
<TextInput
|
||||
value={newKeyword}
|
||||
onChangeText={setNewKeyword}
|
||||
placeholder="e.g. attached"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
style={styles.input}
|
||||
onSubmitEditing={addKeyword}
|
||||
returnKeyType="done"
|
||||
/>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onPress={addKeyword}
|
||||
disabled={!newKeyword.trim()}
|
||||
icon={<Plus size={14} color={colors.primaryForeground} />}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
subBlock: { paddingVertical: spacing.md, gap: spacing.sm },
|
||||
subLabel: { ...typography.bodyMedium, color: colors.text },
|
||||
subDescription: { ...typography.caption, color: colors.mutedForeground },
|
||||
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
|
||||
chip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: colors.muted,
|
||||
},
|
||||
chipText: { ...typography.caption, color: colors.text },
|
||||
addRow: { flexDirection: 'row', gap: spacing.sm, marginTop: 4 },
|
||||
input: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 8,
|
||||
color: colors.text,
|
||||
...typography.body,
|
||||
},
|
||||
});
|
||||
@@ -1,45 +1,41 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, 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';
|
||||
import { useKeywordsStore, type KeywordDef } from '../../stores/keywords-store';
|
||||
|
||||
interface Keyword {
|
||||
id: string;
|
||||
label: string;
|
||||
color: keyof typeof colors.tags;
|
||||
}
|
||||
type Keyword = KeywordDef;
|
||||
|
||||
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 keywords = useKeywordsStore((s) => s.keywords);
|
||||
const addKeyword = useKeywordsStore((s) => s.add);
|
||||
const updateKeyword = useKeywordsStore((s) => s.update);
|
||||
const removeKeyword = useKeywordsStore((s) => s.remove);
|
||||
const resetDefaults = useKeywordsStore((s) => s.resetDefaults);
|
||||
const hydrated = useKeywordsStore((s) => s.hydrated);
|
||||
const hydrate = useKeywordsStore((s) => s.hydrate);
|
||||
|
||||
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
|
||||
|
||||
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));
|
||||
if (editing && editingId) {
|
||||
const { id, ...patch } = kw;
|
||||
updateKeyword(editingId, patch);
|
||||
setEditingId(null);
|
||||
} else {
|
||||
setKeywords((ks) => [...ks, kw]);
|
||||
addKeyword(kw);
|
||||
setIsAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteKeyword = (id: string) => {
|
||||
setKeywords((ks) => ks.filter((k) => k.id !== id));
|
||||
};
|
||||
|
||||
const resetDefaults = () => {
|
||||
setKeywords(DEFAULT_KEYWORDS);
|
||||
removeKeyword(id);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { ArrowRight, ArrowLeft } from 'lucide-react-native';
|
||||
import { SettingsSection, SettingItem, RadioGroup } from './settings-section';
|
||||
import { colors, spacing, typography } from '../../theme/tokens';
|
||||
import { useSettingsStore, type SwipeAction } from '../../stores/settings-store';
|
||||
|
||||
const SWIPE_OPTIONS: { value: SwipeAction; label: string }[] = [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'archive', label: 'Archive' },
|
||||
{ value: 'delete', label: 'Delete' },
|
||||
{ value: 'spam', label: 'Spam' },
|
||||
{ value: 'read', label: 'Read/Unread' },
|
||||
{ value: 'star', label: 'Star' },
|
||||
];
|
||||
|
||||
export function LayoutSettings() {
|
||||
const swipeLeftAction = useSettingsStore((s) => s.swipeLeftAction);
|
||||
const setSwipeLeftAction = useSettingsStore((s) => s.setSwipeLeftAction);
|
||||
const swipeRightAction = useSettingsStore((s) => s.swipeRightAction);
|
||||
const setSwipeRightAction = useSettingsStore((s) => s.setSwipeRightAction);
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
|
||||
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
|
||||
|
||||
return (
|
||||
<SettingsSection title="Layout" description="Tune the email list interactions for mobile.">
|
||||
<View style={{ gap: spacing.sm }}>
|
||||
<View style={styles.row}>
|
||||
<ArrowRight size={14} color={colors.mutedForeground} />
|
||||
<Text style={styles.rowLabel}>Swipe right (left → right)</Text>
|
||||
</View>
|
||||
<Text style={styles.rowDescription}>
|
||||
Action when you drag a row from its left edge towards the right.
|
||||
</Text>
|
||||
<RadioGroup
|
||||
value={swipeRightAction}
|
||||
onChange={(v) => setSwipeRightAction(v as SwipeAction)}
|
||||
options={SWIPE_OPTIONS}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={{ gap: spacing.sm, marginTop: spacing.lg }}>
|
||||
<View style={styles.row}>
|
||||
<ArrowLeft size={14} color={colors.mutedForeground} />
|
||||
<Text style={styles.rowLabel}>Swipe left (right → left)</Text>
|
||||
</View>
|
||||
<Text style={styles.rowDescription}>
|
||||
Action when you drag a row from its right edge towards the left.
|
||||
</Text>
|
||||
<RadioGroup
|
||||
value={swipeLeftAction}
|
||||
onChange={(v) => setSwipeLeftAction(v as SwipeAction)}
|
||||
options={SWIPE_OPTIONS}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<SettingItem
|
||||
label=""
|
||||
description="Drag past about half a row to commit; lift earlier to cancel."
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||
rowLabel: { ...typography.bodyMedium, color: colors.text },
|
||||
rowDescription: { ...typography.caption, color: colors.mutedForeground },
|
||||
});
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
|
||||
import SidebarDrawer from '../components/SidebarDrawer';
|
||||
import SenderAvatar from '../components/SenderAvatar';
|
||||
import { SwipeableRow } from '../components/SwipeableRow';
|
||||
import { useEmailStore, type EmailFilters } from '../stores/email-store';
|
||||
import { useSettingsStore, type SwipeAction } from '../stores/settings-store';
|
||||
import type { Email } from '../api/types';
|
||||
|
||||
function getSenderName(email: Email): string {
|
||||
@@ -152,6 +154,23 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
const markUnread = useEmailStore((s) => s.markUnread);
|
||||
const toggleStar = useEmailStore((s) => s.toggleStar);
|
||||
const deleteEmailAction = useEmailStore((s) => s.deleteEmail);
|
||||
const moveToMailboxAction = useEmailStore((s) => s.moveToMailbox);
|
||||
|
||||
const swipeLeftAction = useSettingsStore((s) => s.swipeLeftAction);
|
||||
const swipeRightAction = useSettingsStore((s) => s.swipeRightAction);
|
||||
|
||||
const archiveMailboxId = React.useMemo(
|
||||
() => mailboxes.find((m) => m.role === 'archive')?.id ?? null,
|
||||
[mailboxes],
|
||||
);
|
||||
const trashMailboxId = React.useMemo(
|
||||
() => mailboxes.find((m) => m.role === 'trash')?.id ?? null,
|
||||
[mailboxes],
|
||||
);
|
||||
const junkMailboxId = React.useMemo(
|
||||
() => mailboxes.find((m) => m.role === 'junk' || m.role === 'spam')?.id ?? null,
|
||||
[mailboxes],
|
||||
);
|
||||
|
||||
// Selection state
|
||||
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set());
|
||||
@@ -187,17 +206,57 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
}
|
||||
}, [toggleSelect]);
|
||||
|
||||
const handleSwipeAction = React.useCallback((id: string, action: SwipeAction) => {
|
||||
if (action === 'none') return;
|
||||
const email = emailsRef.current.find((e) => e.id === id);
|
||||
if (!email || !currentMailboxId) return;
|
||||
switch (action) {
|
||||
case 'archive':
|
||||
if (archiveMailboxId && currentMailboxId !== archiveMailboxId) {
|
||||
void moveToMailboxAction(id, currentMailboxId, archiveMailboxId);
|
||||
}
|
||||
break;
|
||||
case 'delete':
|
||||
if (trashMailboxId) {
|
||||
void deleteEmailAction(id, trashMailboxId, currentMailboxId);
|
||||
}
|
||||
break;
|
||||
case 'spam':
|
||||
if (junkMailboxId && currentMailboxId !== junkMailboxId) {
|
||||
void moveToMailboxAction(id, currentMailboxId, junkMailboxId);
|
||||
}
|
||||
break;
|
||||
case 'read':
|
||||
if (isUnread(email)) void markRead(id);
|
||||
else void markUnread(id);
|
||||
break;
|
||||
case 'star':
|
||||
void toggleStar(id);
|
||||
break;
|
||||
}
|
||||
}, [
|
||||
currentMailboxId, archiveMailboxId, trashMailboxId, junkMailboxId,
|
||||
moveToMailboxAction, deleteEmailAction, markRead, markUnread, toggleStar,
|
||||
]);
|
||||
|
||||
const renderEmailRow = React.useCallback(
|
||||
({ item }: { item: Email }) => (
|
||||
<EmailRow
|
||||
item={item}
|
||||
selected={selectedIds.has(item.id)}
|
||||
selectionMode={selectionMode}
|
||||
onPress={handleRowPress}
|
||||
onLongPress={toggleSelect}
|
||||
/>
|
||||
<SwipeableRow
|
||||
leftAction={selectionMode ? 'none' : swipeLeftAction}
|
||||
rightAction={selectionMode ? 'none' : swipeRightAction}
|
||||
context={{ unread: isUnread(item), starred: isStarred(item) }}
|
||||
onAction={(action) => handleSwipeAction(item.id, action)}
|
||||
>
|
||||
<EmailRow
|
||||
item={item}
|
||||
selected={selectedIds.has(item.id)}
|
||||
selectionMode={selectionMode}
|
||||
onPress={handleRowPress}
|
||||
onLongPress={toggleSelect}
|
||||
/>
|
||||
</SwipeableRow>
|
||||
),
|
||||
[selectedIds, selectionMode, handleRowPress, toggleSelect],
|
||||
[selectedIds, selectionMode, handleRowPress, toggleSelect, swipeLeftAction, swipeRightAction, handleSwipeAction],
|
||||
);
|
||||
|
||||
const clearSelection = React.useCallback(() => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
ArrowLeft, Star, Trash2, MoreVertical, Reply, ReplyAll, Forward,
|
||||
ChevronLeft, ChevronRight, Paperclip, Archive, Mail, MailOpen,
|
||||
FolderInput, ShieldAlert, ShieldCheck, X, Check,
|
||||
Inbox, Send, File as FileIcon, Ban, Folder, Code, Download,
|
||||
Inbox, Send, File as FileIcon, Ban, Folder, Code, Download, Tag,
|
||||
} from 'lucide-react-native';
|
||||
import type { LucideIcon } from 'lucide-react-native';
|
||||
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
|
||||
@@ -18,6 +18,7 @@ import SenderAvatar from '../components/SenderAvatar';
|
||||
import { useEmailStore } from '../stores/email-store';
|
||||
import { setEmailKeywords } from '../api/email';
|
||||
import { shareEmailEml } from '../lib/email-export';
|
||||
import { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store';
|
||||
import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree';
|
||||
import type { Email, Mailbox } from '../api/types';
|
||||
import type { RootStackParamList } from '../navigation/types';
|
||||
@@ -86,6 +87,11 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [moreMenuOpen, setMoreMenuOpen] = React.useState(false);
|
||||
const [moveMenuOpen, setMoveMenuOpen] = React.useState(false);
|
||||
const [tagMenuOpen, setTagMenuOpen] = React.useState(false);
|
||||
const keywordDefs = useKeywordsStore((s) => s.keywords);
|
||||
const hydrateKeywords = useKeywordsStore((s) => s.hydrate);
|
||||
const keywordsHydrated = useKeywordsStore((s) => s.hydrated);
|
||||
React.useEffect(() => { if (!keywordsHydrated) void hydrateKeywords(); }, [keywordsHydrated, hydrateKeywords]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -131,6 +137,15 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
setEmail((prev) => (prev ? { ...prev, keywords: next } : prev));
|
||||
};
|
||||
|
||||
const onToggleKeyword = (token: string) => {
|
||||
if (!email) return;
|
||||
const next = { ...email.keywords };
|
||||
if (next[token]) delete next[token];
|
||||
else next[token] = true;
|
||||
updateLocalKeywords(next);
|
||||
void setEmailKeywords(email.id, next);
|
||||
};
|
||||
|
||||
const onToggleStar = () => {
|
||||
if (!email) return;
|
||||
const next = { ...email.keywords };
|
||||
@@ -394,9 +409,11 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
isInJunk={isInJunk}
|
||||
canViewSource={!!email?.blobId}
|
||||
canExport={!!email?.blobId}
|
||||
canTag={keywordDefs.length > 0}
|
||||
onArchive={() => { setMoreMenuOpen(false); onArchive(); }}
|
||||
onToggleUnread={() => { setMoreMenuOpen(false); onToggleUnread(); }}
|
||||
onMove={() => { setMoreMenuOpen(false); setMoveMenuOpen(true); }}
|
||||
onTag={() => { setMoreMenuOpen(false); setTagMenuOpen(true); }}
|
||||
onToggleSpam={onToggleSpam}
|
||||
onViewSource={() => {
|
||||
setMoreMenuOpen(false);
|
||||
@@ -426,6 +443,14 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
currentMailboxId={currentMailboxId}
|
||||
onPick={onMoveToMailbox}
|
||||
/>
|
||||
|
||||
<TagMenuSheet
|
||||
visible={tagMenuOpen}
|
||||
onClose={() => setTagMenuOpen(false)}
|
||||
keywords={keywordDefs}
|
||||
activeKeywords={email?.keywords ?? {}}
|
||||
onToggle={onToggleKeyword}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -437,6 +462,7 @@ interface MoreMenuSheetProps {
|
||||
canArchive: boolean;
|
||||
canMarkUnread: boolean;
|
||||
canMove: boolean;
|
||||
canTag: boolean;
|
||||
showSpam: boolean;
|
||||
isInJunk: boolean;
|
||||
canViewSource: boolean;
|
||||
@@ -444,15 +470,16 @@ interface MoreMenuSheetProps {
|
||||
onArchive: () => void;
|
||||
onToggleUnread: () => void;
|
||||
onMove: () => void;
|
||||
onTag: () => void;
|
||||
onToggleSpam: () => void;
|
||||
onViewSource: () => void;
|
||||
onExport: () => void;
|
||||
}
|
||||
|
||||
function MoreMenuSheet({
|
||||
visible, onClose, unread, canArchive, canMarkUnread, canMove,
|
||||
visible, onClose, unread, canArchive, canMarkUnread, canMove, canTag,
|
||||
showSpam, isInJunk, canViewSource, canExport,
|
||||
onArchive, onToggleUnread, onMove, onToggleSpam, onViewSource, onExport,
|
||||
onArchive, onToggleUnread, onMove, onTag, onToggleSpam, onViewSource, onExport,
|
||||
}: MoreMenuSheetProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const slideY = React.useRef(new Animated.Value(400)).current;
|
||||
@@ -518,6 +545,14 @@ function MoreMenuSheet({
|
||||
trailing={<ChevronRight size={16} color={colors.textMuted} />}
|
||||
/>
|
||||
)}
|
||||
{canTag && (
|
||||
<MoreMenuItem
|
||||
icon={<Tag size={18} color={colors.textSecondary} />}
|
||||
label="Tag…"
|
||||
onPress={onTag}
|
||||
trailing={<ChevronRight size={16} color={colors.textMuted} />}
|
||||
/>
|
||||
)}
|
||||
{showSpam && (
|
||||
<MoreMenuItem
|
||||
icon={
|
||||
@@ -550,6 +585,76 @@ function MoreMenuSheet({
|
||||
);
|
||||
}
|
||||
|
||||
interface TagMenuSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
keywords: KeywordDef[];
|
||||
activeKeywords: Record<string, boolean>;
|
||||
onToggle: (token: string) => void;
|
||||
}
|
||||
|
||||
function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }: TagMenuSheetProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const slideY = React.useRef(new Animated.Value(500)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 500, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, slideY, overlayOpacity]);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheet,
|
||||
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
|
||||
]}
|
||||
>
|
||||
<View style={styles.sheetHandle} />
|
||||
<View style={styles.sheetHeader}>
|
||||
<Text style={styles.sheetTitle}>Tags</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
|
||||
<X size={18} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
{keywords.length === 0 ? (
|
||||
<Text style={{ ...typography.body, color: colors.textMuted, paddingVertical: spacing.lg, paddingHorizontal: spacing.lg }}>
|
||||
No tags yet. Add some in Settings → Keywords & Labels.
|
||||
</Text>
|
||||
) : (
|
||||
keywords.map((kw) => {
|
||||
const token = keywordToken(kw.id);
|
||||
const active = !!activeKeywords[token];
|
||||
const palette = colors.tags[kw.color];
|
||||
return (
|
||||
<MoreMenuItem
|
||||
key={kw.id}
|
||||
icon={<View style={{ width: 18, height: 18, borderRadius: 9, backgroundColor: palette.dot }} />}
|
||||
label={kw.label}
|
||||
onPress={() => onToggle(token)}
|
||||
trailing={active ? <Check size={16} color={colors.primary} /> : null}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface MoveMenuSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
|
||||
@@ -32,6 +32,8 @@ import { ThemesSettings } from '../components/settings/ThemesSettings';
|
||||
import { PluginsSettings } from '../components/settings/PluginsSettings';
|
||||
import { ContentSendersSettings } from '../components/settings/ContentSendersSettings';
|
||||
import { LanguageSettings } from '../components/settings/LanguageSettings';
|
||||
import { ComposingSettings } from '../components/settings/ComposingSettings';
|
||||
import { LayoutSettings } from '../components/settings/LayoutSettings';
|
||||
|
||||
type Tab =
|
||||
| 'account' | 'language' | 'notifications'
|
||||
@@ -72,11 +74,11 @@ const TABS: TabDef[] = [
|
||||
|
||||
// Appearance
|
||||
{ id: 'appearance', label: 'Appearance', icon: Palette, group: 'appearance', implemented: true },
|
||||
{ id: 'layout', label: 'Layout', icon: LayoutGrid, group: 'appearance', implemented: false },
|
||||
{ id: 'layout', label: 'Layout', icon: LayoutGrid, group: 'appearance', implemented: true },
|
||||
|
||||
// Mail
|
||||
{ id: 'reading', label: 'Reading', icon: BookOpen, group: 'mail', implemented: true },
|
||||
{ id: 'composing', label: 'Composing', icon: PenLine, group: 'mail', implemented: false },
|
||||
{ id: 'composing', label: 'Composing', icon: PenLine, group: 'mail', implemented: true },
|
||||
{ id: 'identities', label: 'Identities', icon: UserPen, group: 'mail', implemented: true },
|
||||
{ id: 'vacation', label: 'Vacation Responder', icon: Palmtree, group: 'mail', implemented: true },
|
||||
{ id: 'filters', label: 'Filters & Rules', icon: Filter, group: 'mail', implemented: true },
|
||||
@@ -108,7 +110,9 @@ const TAB_COMPONENTS: Partial<Record<Tab, React.ComponentType<any>>> = {
|
||||
language: LanguageSettings,
|
||||
notifications: NotificationSettings,
|
||||
appearance: AppearanceSettings,
|
||||
layout: LayoutSettings,
|
||||
reading: ReadingSettings,
|
||||
composing: ComposingSettings,
|
||||
identities: IdentitySettings,
|
||||
vacation: VacationSettings,
|
||||
filters: FilterSettings,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { create } from 'zustand';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { colors } from '../theme/tokens';
|
||||
|
||||
const STORAGE_KEY = 'webmail:keywords:v1';
|
||||
|
||||
export type KeywordColor = keyof typeof colors.tags;
|
||||
|
||||
export interface KeywordDef {
|
||||
id: string;
|
||||
label: string;
|
||||
color: KeywordColor;
|
||||
}
|
||||
|
||||
const DEFAULT_KEYWORDS: KeywordDef[] = [
|
||||
{ id: 'important', label: 'Important', color: 'red' },
|
||||
{ id: 'work', label: 'Work', color: 'blue' },
|
||||
{ id: 'personal', label: 'Personal', color: 'green' },
|
||||
{ id: 'todo', label: 'Todo', color: 'amber' },
|
||||
];
|
||||
|
||||
/**
|
||||
* JMAP keyword token used on emails for a given keyword id.
|
||||
* Matches webmail convention: `$label:<id>`.
|
||||
*/
|
||||
export function keywordToken(id: string): string {
|
||||
return `$label:${id}`;
|
||||
}
|
||||
|
||||
interface KeywordsState {
|
||||
keywords: KeywordDef[];
|
||||
hydrated: boolean;
|
||||
hydrate: () => Promise<void>;
|
||||
add: (kw: KeywordDef) => void;
|
||||
update: (id: string, patch: Partial<Omit<KeywordDef, 'id'>>) => void;
|
||||
remove: (id: string) => void;
|
||||
resetDefaults: () => void;
|
||||
}
|
||||
|
||||
function persist(keywords: KeywordDef[]): void {
|
||||
void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(keywords)).catch((err) => {
|
||||
console.warn('[keywords-store] persist failed', err);
|
||||
});
|
||||
}
|
||||
|
||||
export const useKeywordsStore = create<KeywordsState>((set, get) => ({
|
||||
keywords: DEFAULT_KEYWORDS,
|
||||
hydrated: false,
|
||||
|
||||
hydrate: async () => {
|
||||
if (get().hydrated) return;
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as KeywordDef[];
|
||||
if (Array.isArray(parsed)) {
|
||||
set({ keywords: parsed, hydrated: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[keywords-store] hydrate failed', err);
|
||||
}
|
||||
set({ hydrated: true });
|
||||
},
|
||||
|
||||
add: (kw) => {
|
||||
const next = [...get().keywords, kw];
|
||||
set({ keywords: next });
|
||||
persist(next);
|
||||
},
|
||||
|
||||
update: (id, patch) => {
|
||||
const next = get().keywords.map((k) => (k.id === id ? { ...k, ...patch } : k));
|
||||
set({ keywords: next });
|
||||
persist(next);
|
||||
},
|
||||
|
||||
remove: (id) => {
|
||||
const next = get().keywords.filter((k) => k.id !== id);
|
||||
set({ keywords: next });
|
||||
persist(next);
|
||||
},
|
||||
|
||||
resetDefaults: () => {
|
||||
set({ keywords: DEFAULT_KEYWORDS });
|
||||
persist(DEFAULT_KEYWORDS);
|
||||
},
|
||||
}));
|
||||
@@ -10,6 +10,8 @@ export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
|
||||
|
||||
const STORAGE_KEY = 'webmail:settings:v1';
|
||||
|
||||
export type SwipeAction = 'none' | 'archive' | 'delete' | 'spam' | 'read' | 'star';
|
||||
|
||||
interface PersistedSettings {
|
||||
externalContentPolicy: ExternalContentPolicy;
|
||||
trustedSenders: string[];
|
||||
@@ -22,6 +24,13 @@ interface PersistedSettings {
|
||||
showToolbarLabels: boolean;
|
||||
animationsEnabled: boolean;
|
||||
emailAlwaysLightMode: boolean;
|
||||
// Composing
|
||||
autoSelectReplyIdentity: boolean;
|
||||
attachmentReminderEnabled: boolean;
|
||||
attachmentReminderKeywords: string[];
|
||||
// Layout / list interactions
|
||||
swipeLeftAction: SwipeAction;
|
||||
swipeRightAction: SwipeAction;
|
||||
}
|
||||
|
||||
const DEFAULT_PERSISTED: PersistedSettings = {
|
||||
@@ -35,6 +44,11 @@ const DEFAULT_PERSISTED: PersistedSettings = {
|
||||
showToolbarLabels: true,
|
||||
animationsEnabled: true,
|
||||
emailAlwaysLightMode: false,
|
||||
autoSelectReplyIdentity: true,
|
||||
attachmentReminderEnabled: true,
|
||||
attachmentReminderKeywords: ['attached', 'attachment', 'attaching', 'enclosed'],
|
||||
swipeLeftAction: 'archive',
|
||||
swipeRightAction: 'read',
|
||||
};
|
||||
|
||||
export interface SettingsState extends PersistedSettings {
|
||||
@@ -54,6 +68,11 @@ export interface SettingsState extends PersistedSettings {
|
||||
setShowToolbarLabels: (enabled: boolean) => void;
|
||||
setAnimationsEnabled: (enabled: boolean) => void;
|
||||
setEmailAlwaysLightMode: (enabled: boolean) => void;
|
||||
setAutoSelectReplyIdentity: (enabled: boolean) => void;
|
||||
setAttachmentReminderEnabled: (enabled: boolean) => void;
|
||||
setAttachmentReminderKeywords: (keywords: string[]) => void;
|
||||
setSwipeLeftAction: (action: SwipeAction) => void;
|
||||
setSwipeRightAction: (action: SwipeAction) => void;
|
||||
addTrustedSender: (email: string) => void;
|
||||
removeTrustedSender: (email: string) => void;
|
||||
isSenderTrusted: (email: string) => boolean;
|
||||
@@ -72,6 +91,11 @@ function snapshot(state: SettingsState): PersistedSettings {
|
||||
showToolbarLabels: state.showToolbarLabels,
|
||||
animationsEnabled: state.animationsEnabled,
|
||||
emailAlwaysLightMode: state.emailAlwaysLightMode,
|
||||
autoSelectReplyIdentity: state.autoSelectReplyIdentity,
|
||||
attachmentReminderEnabled: state.attachmentReminderEnabled,
|
||||
attachmentReminderKeywords: state.attachmentReminderKeywords,
|
||||
swipeLeftAction: state.swipeLeftAction,
|
||||
swipeRightAction: state.swipeRightAction,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -115,6 +139,11 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
showToolbarLabels: typeof parsed.showToolbarLabels === 'boolean' ? parsed.showToolbarLabels : DEFAULT_PERSISTED.showToolbarLabels,
|
||||
animationsEnabled: typeof parsed.animationsEnabled === 'boolean' ? parsed.animationsEnabled : DEFAULT_PERSISTED.animationsEnabled,
|
||||
emailAlwaysLightMode: typeof parsed.emailAlwaysLightMode === 'boolean' ? parsed.emailAlwaysLightMode : DEFAULT_PERSISTED.emailAlwaysLightMode,
|
||||
autoSelectReplyIdentity: typeof parsed.autoSelectReplyIdentity === 'boolean' ? parsed.autoSelectReplyIdentity : DEFAULT_PERSISTED.autoSelectReplyIdentity,
|
||||
attachmentReminderEnabled: typeof parsed.attachmentReminderEnabled === 'boolean' ? parsed.attachmentReminderEnabled : DEFAULT_PERSISTED.attachmentReminderEnabled,
|
||||
attachmentReminderKeywords: Array.isArray(parsed.attachmentReminderKeywords) ? parsed.attachmentReminderKeywords : DEFAULT_PERSISTED.attachmentReminderKeywords,
|
||||
swipeLeftAction: parsed.swipeLeftAction ?? DEFAULT_PERSISTED.swipeLeftAction,
|
||||
swipeRightAction: parsed.swipeRightAction ?? DEFAULT_PERSISTED.swipeRightAction,
|
||||
hydrated: true,
|
||||
});
|
||||
return;
|
||||
@@ -134,6 +163,11 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
setShowToolbarLabels: (enabled) => { set({ showToolbarLabels: enabled }); persist(snapshot(get())); },
|
||||
setAnimationsEnabled: (enabled) => { set({ animationsEnabled: enabled }); persist(snapshot(get())); },
|
||||
setEmailAlwaysLightMode: (enabled) => { set({ emailAlwaysLightMode: enabled }); persist(snapshot(get())); },
|
||||
setAutoSelectReplyIdentity: (enabled) => { set({ autoSelectReplyIdentity: enabled }); persist(snapshot(get())); },
|
||||
setAttachmentReminderEnabled: (enabled) => { set({ attachmentReminderEnabled: enabled }); persist(snapshot(get())); },
|
||||
setAttachmentReminderKeywords: (keywords) => { set({ attachmentReminderKeywords: keywords }); persist(snapshot(get())); },
|
||||
setSwipeLeftAction: (action) => { set({ swipeLeftAction: action }); persist(snapshot(get())); },
|
||||
setSwipeRightAction: (action) => { set({ swipeRightAction: action }); persist(snapshot(get())); },
|
||||
|
||||
addTrustedSender: (email) => {
|
||||
const normalized = email.toLowerCase().trim();
|
||||
|
||||
Reference in New Issue
Block a user