feat: add more actions and move functionality to EmailThreadScreen

This commit is contained in:
Linus Rath
2026-04-26 17:40:53 +02:00
parent a8a0d475db
commit fe7d9bdf09
+440 -12
View File
@@ -1,20 +1,37 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, ActivityIndicator,
Modal, useWindowDimensions, Animated, Easing,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import {
ArrowLeft, Star, Trash2, MoreVertical, Reply, ReplyAll, Forward,
ChevronLeft, ChevronRight, Paperclip,
ChevronLeft, ChevronRight, Paperclip, Archive, Mail, MailOpen,
FolderInput, ShieldAlert, ShieldCheck, X, Check,
Inbox, Send, File as FileIcon, Ban, Folder,
} from 'lucide-react-native';
import type { LucideIcon } from 'lucide-react-native';
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
import EmailBodyView from '../components/EmailBodyView';
import SenderAvatar from '../components/SenderAvatar';
import { useEmailStore } from '../stores/email-store';
import type { Email } from '../api/types';
import { setEmailKeywords } from '../api/email';
import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree';
import type { Email, Mailbox } from '../api/types';
import type { RootStackParamList } from '../navigation/types';
function moveTargetIcon(role: string | null | undefined, name: string): LucideIcon {
const lower = name.toLowerCase();
if (role === 'inbox' || lower.includes('inbox')) return Inbox;
if (role === 'sent' || lower.includes('sent')) return Send;
if (role === 'drafts' || lower.includes('draft')) return FileIcon;
if (role === 'trash' || lower.includes('trash') || lower.includes('deleted')) return Trash2;
if (role === 'junk' || role === 'spam' || lower.includes('junk') || lower.includes('spam')) return Ban;
if (role === 'archive' || lower.includes('archive')) return Archive;
return Folder;
}
type Props = NativeStackScreenProps<RootStackParamList, 'EmailThread'>;
function formatHeaderDate(iso: string): string {
@@ -43,16 +60,19 @@ function plainTextBody(email: Email): string {
export default function EmailThreadScreen({ route, navigation }: Props) {
const { emailId, subject: subjectParam } = route.params;
const insets = useSafeAreaInsets();
const { width: windowWidth } = useWindowDimensions();
const getEmailDetail = useEmailStore((s) => s.getEmailDetail);
const markRead = useEmailStore((s) => s.markRead);
const toggleStar = useEmailStore((s) => s.toggleStar);
const deleteEmail = useEmailStore((s) => s.deleteEmail);
const moveToMailbox = useEmailStore((s) => s.moveToMailbox);
const mailboxes = useEmailStore((s) => s.mailboxes);
const currentMailboxId = useEmailStore((s) => s.currentMailboxId);
const [email, setEmail] = React.useState<Email | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [moreMenuOpen, setMoreMenuOpen] = React.useState(false);
const [moveMenuOpen, setMoveMenuOpen] = React.useState(false);
React.useEffect(() => {
let cancelled = false;
@@ -78,16 +98,46 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
}, [emailId, getEmailDetail, markRead]);
const starred = !!email?.keywords?.$flagged;
const unread = !!email && !email.keywords?.$seen;
const archiveMailbox = React.useMemo(
() => mailboxes.find((m) => m.role === 'archive'),
[mailboxes],
);
const junkMailbox = React.useMemo(
() => mailboxes.find((m) => m.role === 'junk' || m.role === 'spam'),
[mailboxes],
);
const inboxMailbox = React.useMemo(
() => mailboxes.find((m) => m.role === 'inbox'),
[mailboxes],
);
const isInJunk = !!(junkMailbox && email?.mailboxIds?.[junkMailbox.id]);
const updateLocalKeywords = (next: Record<string, boolean>) => {
setEmail((prev) => (prev ? { ...prev, keywords: next } : prev));
};
const onToggleStar = () => {
if (!email) return;
void toggleStar(email.id, !starred);
setEmail({
...email,
keywords: {
...email.keywords,
...(starred ? {} : { $flagged: true }),
},
});
const next = { ...email.keywords };
if (starred) delete next.$flagged;
else next.$flagged = true;
updateLocalKeywords(next);
void setEmailKeywords(email.id, next);
};
const onToggleUnread = () => {
if (!email) return;
if (unread) {
void markRead(email.id);
updateLocalKeywords({ ...email.keywords, $seen: true });
} else {
const next = { ...email.keywords };
delete next.$seen;
updateLocalKeywords(next);
void setEmailKeywords(email.id, next);
}
};
const onDelete = () => {
@@ -98,6 +148,35 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
navigation.goBack();
};
const onArchive = () => {
if (!email || !currentMailboxId || !archiveMailbox) return;
if (currentMailboxId === archiveMailbox.id) return;
void moveToMailbox(email.id, currentMailboxId, archiveMailbox.id);
navigation.goBack();
};
const onToggleSpam = () => {
if (!email || !currentMailboxId) return;
setMoreMenuOpen(false);
if (isInJunk) {
const target = inboxMailbox ?? mailboxes.find((m) => m.id !== currentMailboxId);
if (!target) return;
void moveToMailbox(email.id, currentMailboxId, target.id);
} else {
if (!junkMailbox) return;
void moveToMailbox(email.id, currentMailboxId, junkMailbox.id);
}
navigation.goBack();
};
const onMoveToMailbox = (toId: string) => {
if (!email || !currentMailboxId || toId === currentMailboxId) return;
setMoveMenuOpen(false);
setMoreMenuOpen(false);
void moveToMailbox(email.id, currentMailboxId, toId);
navigation.goBack();
};
const navigateCompose = (mode: 'reply' | 'replyAll' | 'forward') => {
if (!email) return;
const from = email.from?.[0];
@@ -119,6 +198,11 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
const from = email?.from?.[0];
const bottomBarHeight = 60 + Math.max(insets.bottom, 4);
// Show extras in priority order. Each toolbar button is ~64px wide (icon+label+padding);
// back arrow + side padding reserves ~64px. Drop optional buttons on narrow screens.
const showMarkUnread = windowWidth >= 340;
const showArchive = windowWidth >= 400 && !!archiveMailbox;
return (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Toolbar */}
@@ -132,6 +216,26 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
label="Delete"
onPress={onDelete}
/>
{showArchive && (
<ToolbarButton
icon={<Archive size={18} color={colors.textSecondary} />}
label="Archive"
onPress={onArchive}
/>
)}
{showMarkUnread && (
<ToolbarButton
icon={
unread ? (
<MailOpen size={18} color={colors.textSecondary} />
) : (
<Mail size={18} color={colors.textSecondary} />
)
}
label={unread ? 'Read' : 'Unread'}
onPress={onToggleUnread}
/>
)}
<ToolbarButton
icon={
<Star
@@ -146,6 +250,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
<ToolbarButton
icon={<MoreVertical size={18} color={colors.textSecondary} />}
label="More"
onPress={() => setMoreMenuOpen(true)}
/>
</View>
</View>
@@ -262,10 +367,243 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
</View>
</>
) : null}
<MoreMenuSheet
visible={moreMenuOpen}
onClose={() => setMoreMenuOpen(false)}
unread={unread}
canArchive={!!archiveMailbox && !showArchive && currentMailboxId !== archiveMailbox?.id}
canMarkUnread={!showMarkUnread}
canMove={mailboxes.length > 0}
showSpam={!!junkMailbox || isInJunk}
isInJunk={isInJunk}
onArchive={() => { setMoreMenuOpen(false); onArchive(); }}
onToggleUnread={() => { setMoreMenuOpen(false); onToggleUnread(); }}
onMove={() => { setMoreMenuOpen(false); setMoveMenuOpen(true); }}
onToggleSpam={onToggleSpam}
/>
<MoveMenuSheet
visible={moveMenuOpen}
onClose={() => setMoveMenuOpen(false)}
mailboxes={mailboxes}
currentMailboxId={currentMailboxId}
onPick={onMoveToMailbox}
/>
</SafeAreaView>
);
}
interface MoreMenuSheetProps {
visible: boolean;
onClose: () => void;
unread: boolean;
canArchive: boolean;
canMarkUnread: boolean;
canMove: boolean;
showSpam: boolean;
isInJunk: boolean;
onArchive: () => void;
onToggleUnread: () => void;
onMove: () => void;
onToggleSpam: () => void;
}
function MoreMenuSheet({
visible, onClose, unread, canArchive, canMarkUnread, canMove,
showSpam, isInJunk, onArchive, onToggleUnread, onMove, onToggleSpam,
}: MoreMenuSheetProps) {
const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(400)).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: 400, 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}>More actions</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
<X size={18} color={colors.textSecondary} />
</Pressable>
</View>
{canArchive && (
<MoreMenuItem
icon={<Archive size={18} color={colors.textSecondary} />}
label="Archive"
onPress={onArchive}
/>
)}
{canMarkUnread && (
<MoreMenuItem
icon={
unread ? (
<MailOpen size={18} color={colors.textSecondary} />
) : (
<Mail size={18} color={colors.textSecondary} />
)
}
label={unread ? 'Mark as read' : 'Mark as unread'}
onPress={onToggleUnread}
/>
)}
{canMove && (
<MoreMenuItem
icon={<FolderInput size={18} color={colors.textSecondary} />}
label="Move to folder…"
onPress={onMove}
trailing={<ChevronRight size={16} color={colors.textMuted} />}
/>
)}
{showSpam && (
<MoreMenuItem
icon={
isInJunk ? (
<ShieldCheck size={18} color={colors.success} />
) : (
<ShieldAlert size={18} color={colors.error} />
)
}
label={isInJunk ? 'Not spam' : 'Mark as spam'}
onPress={onToggleSpam}
/>
)}
</Animated.View>
</Modal>
);
}
interface MoveMenuSheetProps {
visible: boolean;
onClose: () => void;
mailboxes: Mailbox[];
currentMailboxId: string | null;
onPick: (id: string) => void;
}
function MoveMenuSheet({ visible, onClose, mailboxes, currentMailboxId, onPick }: MoveMenuSheetProps) {
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]);
const visibleNodes = React.useMemo(() => {
const tree = buildMailboxTree(mailboxes);
const expanded = new Set<string>();
const collect = (nodes: MailboxNode[]) => {
for (const n of nodes) {
if (n.children.length > 0) {
expanded.add(n.id);
collect(n.children);
}
}
};
collect(tree);
return flattenVisible(tree, expanded);
}, [mailboxes]);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
styles.sheetTall,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
]}
>
<View style={styles.sheetHandle} />
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>Move to folder</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
<X size={18} color={colors.textSecondary} />
</Pressable>
</View>
<ScrollView>
{visibleNodes.map((node) => {
const Icon = moveTargetIcon(node.role, node.name);
const isCurrent = node.id === currentMailboxId;
const canTarget = node.myRights?.mayAddItems !== false && !isCurrent;
return (
<Pressable
key={node.id}
onPress={canTarget ? () => onPick(node.id) : undefined}
disabled={!canTarget}
style={({ pressed }) => [
styles.moveRow,
pressed && canTarget && styles.moveRowPressed,
{ paddingLeft: spacing.lg + node.depth * 16 },
]}
>
<Icon size={16} color={canTarget ? colors.textSecondary : colors.textMuted} />
<Text
style={[styles.moveRowLabel, !canTarget && styles.moveRowLabelDisabled]}
numberOfLines={1}
>
{node.name}
</Text>
{isCurrent && <Check size={14} color={colors.textMuted} />}
</Pressable>
);
})}
</ScrollView>
</Animated.View>
</Modal>
);
}
function MoreMenuItem({
icon, label, onPress, trailing,
}: { icon: React.ReactNode; label: string; onPress?: () => void; trailing?: React.ReactNode }) {
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [styles.moreItem, pressed && styles.moreItemPressed]}
>
<View style={styles.moreItemIcon}>{icon}</View>
<Text style={styles.moreItemLabel}>{label}</Text>
{trailing}
</Pressable>
);
}
function ToolbarButton({
icon, label, onPress,
}: { icon: React.ReactNode; label: string; onPress?: () => void }) {
@@ -324,7 +662,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
gap: 2,
paddingHorizontal: spacing.md,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
minHeight: 44,
},
@@ -487,4 +825,94 @@ const styles = StyleSheet.create({
bottomBarLabelDisabled: {
color: colors.textMuted,
},
// Bottom sheet (More menu / Move folder picker)
sheetOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.5)',
},
sheetOverlayPress: { flex: 1 },
sheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
backgroundColor: colors.popover,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
borderTopWidth: 1,
borderColor: colors.border,
paddingTop: spacing.sm,
},
sheetTall: {
maxHeight: '75%',
},
sheetHandle: {
alignSelf: 'center',
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: colors.border,
marginBottom: spacing.sm,
},
sheetHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingBottom: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
sheetTitle: {
...typography.bodySemibold,
color: colors.text,
},
sheetClose: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.xs,
},
// More menu item
moreItem: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
minHeight: 48,
},
moreItemPressed: { backgroundColor: colors.surfaceHover },
moreItemIcon: {
width: 20,
alignItems: 'center',
justifyContent: 'center',
},
moreItemLabel: {
...typography.body,
color: colors.text,
flex: 1,
},
// Move folder row
moveRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingRight: spacing.lg,
paddingVertical: spacing.sm + 2,
minHeight: 44,
},
moveRowPressed: { backgroundColor: colors.surfaceHover },
moveRowLabel: {
...typography.body,
color: colors.text,
flex: 1,
},
moveRowLabelDisabled: {
color: colors.textMuted,
},
});