feat: implement undo functionality for email actions with snackbar notification

This commit is contained in:
Linus Rath
2026-05-02 14:31:50 +02:00
parent 329c8a2f0b
commit 2090480bf4
4 changed files with 240 additions and 1 deletions
+21
View File
@@ -111,6 +111,27 @@ export async function moveEmail(
]);
}
// Restore each email's mailboxIds to the snapshot supplied. Used by undo to
// reverse a move/archive/spam in one round-trip. JMAP "mailboxIds" replaces
// the entire map, so we don't need to compute a diff against the current state.
export async function restoreEmailMailboxes(
items: Array<{ id: string; mailboxIds: Record<string, boolean> }>,
): Promise<void> {
if (items.length === 0) return;
const accountId = jmapClient.accountId;
const update: Record<string, { mailboxIds: Record<string, true> }> = {};
for (const item of items) {
const onlyTrue: Record<string, true> = {};
for (const [id, present] of Object.entries(item.mailboxIds)) {
if (present) onlyTrue[id] = true;
}
update[item.id] = { mailboxIds: onlyTrue };
}
await jmapClient.request([
['Email/set', { accountId, update }, '0'],
]);
}
// Archive one or more emails into the archive mailbox, optionally auto-sorting
// into year or year/month subfolders. Mirrors the webmail implementation in
// lib/jmap/client.ts so behavior stays in sync between platforms.
+120
View File
@@ -0,0 +1,120 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useEmailStore } from '../stores/email-store';
const VISIBLE_MS = 5000;
export function UndoSnackbar() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const entry = useEmailStore((s) => s.pendingUndo);
const undoLast = useEmailStore((s) => s.undoLast);
const clearUndo = useEmailStore((s) => s.clearUndo);
const slideY = React.useRef(new Animated.Value(120)).current;
const opacity = React.useRef(new Animated.Value(0)).current;
// Remember the last non-null entry so the bar's text/handlers stay valid
// while it animates out after pendingUndo flips to null.
const [shown, setShown] = React.useState(entry);
React.useEffect(() => {
if (entry) setShown(entry);
}, [entry]);
// Drive the auto-dismiss off the entry's createdAt so re-renders during
// animation don't reset the timer, and so consecutive actions reset it.
React.useEffect(() => {
if (!entry) {
Animated.parallel([
Animated.timing(slideY, { toValue: 120, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(opacity, { toValue: 0, duration: 180, useNativeDriver: true }),
]).start(({ finished }) => {
if (finished) setShown(null);
});
return;
}
Animated.parallel([
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(opacity, { toValue: 1, duration: 220, useNativeDriver: true }),
]).start();
const elapsed = Date.now() - entry.createdAt;
const remaining = Math.max(0, VISIBLE_MS - elapsed);
const id = setTimeout(() => {
// Only clear if the entry is still the same one we scheduled for.
if (useEmailStore.getState().pendingUndo === entry) {
clearUndo();
}
}, remaining);
return () => clearTimeout(id);
}, [entry, slideY, opacity, clearUndo]);
if (!shown) return null;
return (
<Animated.View
pointerEvents="box-none"
style={[
styles.wrap,
{ bottom: insets.bottom + spacing.md, opacity, transform: [{ translateY: slideY }] },
]}
>
<View style={styles.bar}>
<Text style={styles.label} numberOfLines={1}>{shown.label}</Text>
<Pressable
onPress={() => { void undoLast(); }}
hitSlop={8}
style={({ pressed }) => [styles.undoBtn, pressed && styles.undoBtnPressed]}
>
<Text style={styles.undoText}>UNDO</Text>
</Pressable>
</View>
</Animated.View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
wrap: {
position: 'absolute',
left: spacing.md,
right: spacing.md,
},
bar: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.sm + 2,
paddingLeft: spacing.lg,
paddingRight: spacing.sm,
borderRadius: radius.md,
backgroundColor: c.text,
shadowColor: '#000',
shadowOpacity: 0.3,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
elevation: 8,
},
label: {
...typography.body,
color: c.background,
flex: 1,
},
undoBtn: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.sm,
},
undoBtnPressed: {
backgroundColor: 'rgba(255,255,255,0.1)',
},
undoText: {
...typography.bodySemibold,
color: c.primary,
letterSpacing: 0.5,
},
});
}
+3
View File
@@ -12,6 +12,7 @@ import SidebarDrawer from '../components/SidebarDrawer';
import SenderAvatar from '../components/SenderAvatar';
import { SwipeableRow } from '../components/SwipeableRow';
import { MoveSheet } from '../components/MoveSheet';
import { UndoSnackbar } from '../components/UndoSnackbar';
import { OfflineBanner } from '../components/OfflineBanner';
import { useNetworkStore } from '../stores/network-store';
import { useEmailStore, type EmailFilters } from '../stores/email-store';
@@ -840,6 +841,8 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
}
}}
/>
<UndoSnackbar />
</SafeAreaView>
);
}
+96 -1
View File
@@ -11,6 +11,7 @@ import {
moveEmail,
archiveEmails as apiArchiveEmails,
deleteEmail as apiDeleteEmail,
restoreEmailMailboxes,
searchEmails as apiSearchEmails,
} from '../api/email';
import { toWildcardQuery } from '../lib/search-utils';
@@ -27,6 +28,19 @@ export interface EmailFilters {
isUnread?: boolean;
}
// Snapshot of an action that can still be reversed via the undo snackbar.
// We store the full email object so undo can re-insert it into the visible list
// optimistically without waiting for a refetch.
export interface UndoEntry {
kind: 'archive' | 'delete' | 'move' | 'spam';
/** Human-readable label shown in the snackbar (e.g. "Email archived"). */
label: string;
/** Time the entry was created - the snackbar uses this to drive its timer. */
createdAt: number;
/** Each item is one email's pre-action mailboxIds, used to restore it. */
items: Array<{ email: Email; originalMailboxIds: Record<string, boolean> }>;
}
export interface EmailState {
mailboxes: Mailbox[];
currentMailboxId: string | null;
@@ -36,6 +50,7 @@ export interface EmailState {
error: string | null;
searchQuery: string;
filters: EmailFilters;
pendingUndo: UndoEntry | null;
fetchMailboxes: () => Promise<void>;
selectMailbox: (mailboxId: string) => Promise<void>;
@@ -49,6 +64,8 @@ export interface EmailState {
moveToMailbox: (emailId: string, fromMailboxId: string, toMailboxId: string) => Promise<void>;
archiveEmail: (emailId: string) => Promise<void>;
deleteEmail: (emailId: string, trashMailboxId: string, currentMailboxId: string) => Promise<void>;
undoLast: () => Promise<void>;
clearUndo: () => void;
searchEmails: (query: string) => Promise<Email[]>;
setSearchQuery: (query: string) => void;
setFilters: (filters: EmailFilters) => void;
@@ -106,6 +123,7 @@ export const useEmailStore = create<EmailState>()(
error: null,
searchQuery: '',
filters: {},
pendingUndo: null,
fetchMailboxes: async () => {
try {
@@ -119,6 +137,8 @@ export const useEmailStore = create<EmailState>()(
selectMailbox: async (mailboxId) => {
// Reset search/filters when switching mailbox - matches webmail behavior.
// Switching mailboxes invalidates any pending undo: the snackbar would be
// stale and the restored email would re-appear in a different view.
set({
currentMailboxId: mailboxId,
loading: true,
@@ -127,6 +147,7 @@ export const useEmailStore = create<EmailState>()(
totalEmails: 0,
searchQuery: '',
filters: {},
pendingUndo: null,
});
try {
const filter = buildJmapFilter(mailboxId, '', {});
@@ -254,8 +275,23 @@ export const useEmailStore = create<EmailState>()(
},
moveToMailbox: async (emailId, fromMailboxId, toMailboxId) => {
const email = get().emails.find((e) => e.id === emailId);
const original = email ? { ...email.mailboxIds } : null;
await moveEmail(emailId, fromMailboxId, toMailboxId);
set({ emails: get().emails.filter((e) => e.id !== emailId) });
if (email && original) {
const targetName = get().mailboxes.find((m) => m.id === toMailboxId)?.name;
set({
pendingUndo: {
kind: 'move',
label: targetName ? `Email moved to ${targetName}` : 'Email moved',
createdAt: Date.now(),
items: [{ email, originalMailboxIds: original }],
},
});
}
},
archiveEmail: async (emailId) => {
@@ -270,6 +306,7 @@ export const useEmailStore = create<EmailState>()(
if (email.mailboxIds?.[archiveMailbox.id]) return;
const mode = useSettingsStore.getState().archiveMode;
const original = { ...email.mailboxIds };
await apiArchiveEmails(
[{ id: email.id, receivedAt: email.receivedAt }],
@@ -278,7 +315,15 @@ export const useEmailStore = create<EmailState>()(
mailboxes,
);
set({ emails: get().emails.filter((e) => e.id !== emailId) });
set({
emails: get().emails.filter((e) => e.id !== emailId),
pendingUndo: {
kind: 'archive',
label: 'Email archived',
createdAt: Date.now(),
items: [{ email, originalMailboxIds: original }],
},
});
// Auto-sort modes may have created new year/month folders - refresh the
// mailbox list so the sidebar picks them up on the next render.
@@ -288,10 +333,60 @@ export const useEmailStore = create<EmailState>()(
},
deleteEmail: async (emailId, trashMailboxId, currentMailboxId) => {
const email = get().emails.find((e) => e.id === emailId);
const original = email ? { ...email.mailboxIds } : null;
const isPermanent = currentMailboxId === trashMailboxId;
await apiDeleteEmail(emailId, trashMailboxId, currentMailboxId);
set({ emails: get().emails.filter((e) => e.id !== emailId) });
// Permanent destroy can't be undone - skip the snackbar so we don't
// promise an undo we can't deliver.
if (email && original && !isPermanent) {
set({
pendingUndo: {
kind: 'delete',
label: 'Email moved to Trash',
createdAt: Date.now(),
items: [{ email, originalMailboxIds: original }],
},
});
}
},
undoLast: async () => {
const entry = get().pendingUndo;
if (!entry) return;
set({ pendingUndo: null });
try {
await restoreEmailMailboxes(
entry.items.map((it) => ({ id: it.email.id, mailboxIds: it.originalMailboxIds })),
);
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Undo failed' });
return;
}
// Re-insert each restored email into the visible list if its original
// mailboxIds include the current view. Server is the source of truth for
// ordering, but local re-insertion gives the user instant feedback.
const { currentMailboxId, emails } = get();
if (currentMailboxId) {
const restored = entry.items
.filter((it) => it.originalMailboxIds[currentMailboxId])
.map((it) => ({ ...it.email, mailboxIds: it.originalMailboxIds }));
if (restored.length > 0) {
const merged = [...restored, ...emails].sort(
(a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime(),
);
set({ emails: merged });
}
}
},
clearUndo: () => set({ pendingUndo: null }),
searchEmails: async (query) => {
const ids = await apiSearchEmails(query);
if (ids.length === 0) return [];