mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 08:07:11 +00:00
feat: implement undo functionality for email actions with snackbar notification
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
Reference in New Issue
Block a user