diff --git a/src/api/email.ts b/src/api/email.ts index 093604ea..01a5c009 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -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 }>, +): Promise { + if (items.length === 0) return; + const accountId = jmapClient.accountId; + const update: Record }> = {}; + for (const item of items) { + const onlyTrue: Record = {}; + 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. diff --git a/src/components/UndoSnackbar.tsx b/src/components/UndoSnackbar.tsx new file mode 100644 index 00000000..5a494e68 --- /dev/null +++ b/src/components/UndoSnackbar.tsx @@ -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 ( + + + {shown.label} + { void undoLast(); }} + hitSlop={8} + style={({ pressed }) => [styles.undoBtn, pressed && styles.undoBtnPressed]} + > + UNDO + + + + ); +} + +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, + }, + }); +} diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index b8a213bc..474a5b9a 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -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 } }} /> + + ); } diff --git a/src/stores/email-store.ts b/src/stores/email-store.ts index fd00da3f..78f3bd45 100644 --- a/src/stores/email-store.ts +++ b/src/stores/email-store.ts @@ -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 }>; +} + 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; selectMailbox: (mailboxId: string) => Promise; @@ -49,6 +64,8 @@ export interface EmailState { moveToMailbox: (emailId: string, fromMailboxId: string, toMailboxId: string) => Promise; archiveEmail: (emailId: string) => Promise; deleteEmail: (emailId: string, trashMailboxId: string, currentMailboxId: string) => Promise; + undoLast: () => Promise; + clearUndo: () => void; searchEmails: (query: string) => Promise; setSearchQuery: (query: string) => void; setFilters: (filters: EmailFilters) => void; @@ -106,6 +123,7 @@ export const useEmailStore = create()( error: null, searchQuery: '', filters: {}, + pendingUndo: null, fetchMailboxes: async () => { try { @@ -119,6 +137,8 @@ export const useEmailStore = create()( 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()( totalEmails: 0, searchQuery: '', filters: {}, + pendingUndo: null, }); try { const filter = buildJmapFilter(mailboxId, '', {}); @@ -254,8 +275,23 @@ export const useEmailStore = create()( }, 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()( 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()( 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()( }, 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 [];