mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
fix: offer undo for replies sent from the viewer
A quick reply held by the undo-send delay could not be undone: the undo bar lived only on the mail list, and the quick reply never recorded the held send for it. - The undo bar is now hosted on every stack screen through the navigator's `screenLayout`, next to the toasts, and only the focused screen's renders, so it shows wherever the send started and never twice. The list's own instance is gone; archive, delete, move and spam undo use the same bar as before. The composer gets none, since its Undo reopens the composer. - The quick reply records a held send through the same store action as the composer, including the account it went out of, so Undo and Send now reach a shared account's submission and Undo restores the reply into that account's Drafts. - The quick reply dismisses the keyboard after sending, which would otherwise cover the bar.
This commit is contained in:
@@ -67,6 +67,7 @@ import { useUpdatesStore } from './src/stores/updates-store';
|
||||
import { UpdateBanner } from './src/components/UpdateBanner';
|
||||
import { PushOnboardingPrompt } from './src/components/PushOnboardingPrompt';
|
||||
import { ToastHost } from './src/components/ToastHost';
|
||||
import { UndoSnackbar } from './src/components/UndoSnackbar';
|
||||
import { AppIconBadge } from './src/components/AppIconBadge';
|
||||
import { getEmails } from './src/api/email';
|
||||
import { handleDeepLink, parseDeepLink, shareToDeepLink, type DeepLink } from './src/navigation/linking';
|
||||
@@ -150,20 +151,29 @@ async function openDeepLink(link: DeepLink): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// Toasts show on whichever stack screen is up, so a failure that lands after
|
||||
// leaving the screen it came from (a viewer action finishing on the Unified
|
||||
// Inbox or a contact) is still seen. Every screen carries a host, and only
|
||||
// the focused one renders, so a toast never shows twice (the screen below
|
||||
// the top one is kept live).
|
||||
function FocusedToastHost() {
|
||||
return useIsFocused() ? <ToastHost /> : null;
|
||||
// Toasts and the undo bar show on whichever stack screen is up, so a failure
|
||||
// that lands after leaving the screen it came from (a viewer action finishing
|
||||
// on the Unified Inbox or a contact) is still seen, and a reply held by the
|
||||
// undo-send delay can be undone from the viewer it was sent from. Every
|
||||
// screen carries the hosts, and only the focused one renders them, so
|
||||
// neither ever shows twice (the screen below the top one is kept live).
|
||||
// The composer gets no undo bar: its Undo would hand the open composer
|
||||
// another draft. A held send's bar shows on the screen below once it closes.
|
||||
function FocusedHosts({ undo }: { undo: boolean }) {
|
||||
if (!useIsFocused()) return null;
|
||||
return (
|
||||
<>
|
||||
{undo && <UndoSnackbar />}
|
||||
<ToastHost />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function withToastHost({ children }: { children: React.ReactElement }) {
|
||||
function withHosts({ route, children }: { route: { name: string }; children: React.ReactElement }) {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<FocusedToastHost />
|
||||
<FocusedHosts undo={route.name !== 'Compose'} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -709,7 +719,7 @@ export default function App() {
|
||||
<StatusBar style={statusBarStyle} />
|
||||
{/* On Fabric, native-stack keeps the screen right below the top live and
|
||||
freezes the ones further down. */}
|
||||
<Stack.Navigator screenOptions={{ headerShown: false, freezeOnBlur: true }} screenLayout={withToastHost}>
|
||||
<Stack.Navigator screenOptions={{ headerShown: false, freezeOnBlur: true }} screenLayout={withHosts}>
|
||||
<Stack.Screen name="MainTabs" component={MainTabsNavigator} />
|
||||
<Stack.Screen name="EmailThread" component={EmailThreadScreen} />
|
||||
<Stack.Screen name="EmailSource" component={EmailSourceScreen} />
|
||||
|
||||
@@ -6,14 +6,13 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { useEmailStore } from '../stores/email-store';
|
||||
import { useSendUndoStore, type PendingUndoSend } from '../stores/send-undo-store';
|
||||
import { useSendUndoStore, restoreUndoneSend, type PendingUndoSend } from '../stores/send-undo-store';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { getFullEmail, restoreEmailToDraft } from '../api/email';
|
||||
import { ownMailboxes } from '../lib/mailbox-tree';
|
||||
import { draftContextFromEmail } from '../lib/draft-context';
|
||||
import type { RootStackParamList } from '../navigation/types';
|
||||
|
||||
const VISIBLE_MS = 5000;
|
||||
// Clears the tab bar and the viewer's action bar, like the toasts (ToastHost).
|
||||
const BOTTOM_CLEARANCE = 64;
|
||||
|
||||
interface Shown {
|
||||
label: string;
|
||||
@@ -23,6 +22,12 @@ interface Shown {
|
||||
send?: PendingUndoSend;
|
||||
}
|
||||
|
||||
/**
|
||||
* The undo bar for the last list action (archive, delete, move, spam) and
|
||||
* for a send held by the undo-send delay. App.tsx gives every stack screen
|
||||
* one and only the focused screen's renders, so a reply sent from the viewer
|
||||
* can be undone there and the bar never shows twice.
|
||||
*/
|
||||
export function UndoSnackbar() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
@@ -105,12 +110,8 @@ export function UndoSnackbar() {
|
||||
if (!ok) return;
|
||||
// Bring the message back as an editable draft and reopen the composer.
|
||||
try {
|
||||
const own = ownMailboxes(useEmailStore.getState().mailboxes);
|
||||
const drafts = own.find((m) => m.role === 'drafts');
|
||||
const sent = own.find((m) => m.role === 'sent');
|
||||
if (drafts) await restoreEmailToDraft(send.emailId, drafts.id, sent?.id);
|
||||
const email = await getFullEmail(send.emailId);
|
||||
navigation.navigate('Compose', { draft: draftContextFromEmail(email) });
|
||||
const draft = await restoreUndoneSend(send, useEmailStore.getState().mailboxes);
|
||||
navigation.navigate('Compose', { draft });
|
||||
} catch (err) {
|
||||
console.warn('[send-undo] reopen failed', err);
|
||||
}
|
||||
@@ -127,7 +128,7 @@ export function UndoSnackbar() {
|
||||
pointerEvents="box-none"
|
||||
style={[
|
||||
styles.wrap,
|
||||
{ bottom: insets.bottom + spacing.md, opacity, transform: [{ translateY: slideY }] },
|
||||
{ bottom: insets.bottom + BOTTOM_CLEARANCE, opacity, transform: [{ translateY: slideY }] },
|
||||
]}
|
||||
>
|
||||
<View style={styles.bar}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TextInput, Pressable, ActivityIndicator, Alert } from 'react-native';
|
||||
import { View, Text, StyleSheet, TextInput, Pressable, ActivityIndicator, Alert, Keyboard } from 'react-native';
|
||||
import { Send, Maximize2 } from 'lucide-react-native';
|
||||
import type { Email } from '../../api/types';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
@@ -8,6 +8,7 @@ import { useSettingsStore } from '../../stores/settings-store';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { useEmailStore } from '../../stores/email-store';
|
||||
import { toast } from '../../stores/toast-store';
|
||||
import { useSendUndoStore } from '../../stores/send-undo-store';
|
||||
import { sendEmail, patchKeywordsForEmails } from '../../api/email';
|
||||
import { jmapClient } from '../../api/jmap-client';
|
||||
import { buildReplyRecipients } from '../../lib/reply-recipients';
|
||||
@@ -88,6 +89,7 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent }: P
|
||||
const quoted = original.split('\n').map((l) => `> ${l}`).join('\n');
|
||||
const header = `${formatFullDateTime(emailDisplayDate(email), timeFormat, locale)}, ${from.name ? `${from.name} <${from.email}>` : from.email}:`;
|
||||
const threading = computeReplyThreadingHeaders(email);
|
||||
const holdFor = jmapClient.undoSendHold(sendDelaySeconds, jmapAccountId);
|
||||
const result = await sendEmail(
|
||||
{
|
||||
from: [{ name: identity.name, email: identity.email }],
|
||||
@@ -105,14 +107,23 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent }: P
|
||||
},
|
||||
identity.id,
|
||||
sent.originalId ?? sent.id,
|
||||
jmapClient.undoSendHold(sendDelaySeconds, jmapAccountId),
|
||||
holdFor,
|
||||
{ draftsMailboxId: drafts ? (drafts.originalId ?? drafts.id) : undefined, accountId: jmapAccountId },
|
||||
);
|
||||
// Held for the undo-send delay: the undo bar offers Undo / Send now.
|
||||
// Recorded before the flag below so its round trip doesn't eat the window.
|
||||
useSendUndoStore.getState().recordHeldSend(result, holdFor, {
|
||||
identityId: identity.id,
|
||||
accountId: jmapAccountId,
|
||||
from: [{ name: identity.name, email: identity.email }],
|
||||
});
|
||||
try {
|
||||
await patchKeywordsForEmails([email.id], { $answered: true }, jmapAccountId);
|
||||
} catch { /* the reply is out; the flag is cosmetic */ }
|
||||
onSent?.({ ...email, keywords: { ...email.keywords, $answered: true } });
|
||||
setText('');
|
||||
// The keyboard would cover the undo bar or the toast.
|
||||
Keyboard.dismiss();
|
||||
// A reply held for the undo-send delay has not gone out yet (webmail b03a0c1d).
|
||||
if (!result.scheduled) toast.success(t('notifications.email_sent', 'Email sent successfully'));
|
||||
} catch (err) {
|
||||
|
||||
@@ -2151,19 +2151,14 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
t('email_composer.scheduled_title', 'Scheduled'),
|
||||
t('email_composer.scheduled_body', 'Your message will be sent at {time}.', { time: formatWhen(when) }),
|
||||
);
|
||||
} else if (holdForSeconds && result.scheduled && result.emailSubmissionId && result.emailId) {
|
||||
// Undo-send window: the mail list's snackbar offers Undo / Send now.
|
||||
useSendUndoStore.getState().setPending({
|
||||
emailSubmissionId: result.emailSubmissionId,
|
||||
emailId: result.emailId,
|
||||
} else if (result.scheduled) {
|
||||
// Undo-send window: the undo bar offers Undo / Send now.
|
||||
useSendUndoStore.getState().recordHeldSend(result, holdForSeconds, {
|
||||
identityId: primaryIdentity.id,
|
||||
from: outgoing.from,
|
||||
to: [...outgoing.to, ...(outgoing.cc ?? []), ...(outgoing.bcc ?? [])],
|
||||
sendAt: result.sendAt,
|
||||
delaySeconds: holdForSeconds,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
} else if (!result.scheduled) {
|
||||
} else {
|
||||
// Held sends get the undo bar instead (webmail b03a0c1d).
|
||||
toast.success(t('notifications.email_sent', 'Email sent successfully'));
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import SenderAvatar from '../components/SenderAvatar';
|
||||
import { SwipeableRow } from '../components/SwipeableRow';
|
||||
import { MoveSheet } from '../components/MoveSheet';
|
||||
import { TagSheet } from '../components/TagSheet';
|
||||
import { UndoSnackbar } from '../components/UndoSnackbar';
|
||||
import { OfflineBanner } from '../components/OfflineBanner';
|
||||
import {
|
||||
ListAttachmentChips, ListAttachmentOpener, useListRowAttachments,
|
||||
@@ -1721,8 +1720,6 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
selectedEmails={selectedEmails}
|
||||
onToggle={handleBatchTagToggle}
|
||||
/>
|
||||
|
||||
<UndoSnackbar />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../../api/email', () => ({
|
||||
cancelScheduledSend: vi.fn(async () => undefined),
|
||||
rescheduleScheduledSend: vi.fn(async () => ({ emailSubmissionId: 'sub-2' })),
|
||||
restoreEmailToDraft: vi.fn(async () => undefined),
|
||||
getFullEmail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/jmap-client', () => ({ jmapClient: { accountId: 'primary' } }));
|
||||
|
||||
import * as emailApi from '../../api/email';
|
||||
import type { Email, Mailbox } from '../../api/types';
|
||||
import { useSendUndoStore, restoreUndoneSend, type PendingUndoSend } from '../send-undo-store';
|
||||
|
||||
const api = vi.mocked(emailApi);
|
||||
|
||||
const HELD = { scheduled: true, emailSubmissionId: 'sub-1', emailId: 'mail-1', sendAt: '2026-09-24T10:00:10Z' };
|
||||
const SENDER = { name: 'Me', email: 'me@example.com' };
|
||||
|
||||
function mailbox(role: string, id: string, shared?: { accountId: string }): Mailbox {
|
||||
return {
|
||||
id: shared ? `${shared.accountId}:${id}` : id,
|
||||
originalId: shared ? id : undefined,
|
||||
name: role,
|
||||
role,
|
||||
totalEmails: 0,
|
||||
unreadEmails: 0,
|
||||
totalThreads: 0,
|
||||
unreadThreads: 0,
|
||||
myRights: {} as Mailbox['myRights'],
|
||||
accountId: shared?.accountId,
|
||||
isShared: !!shared,
|
||||
};
|
||||
}
|
||||
|
||||
const MAILBOXES = [
|
||||
mailbox('drafts', 'own-drafts'),
|
||||
mailbox('sent', 'own-sent'),
|
||||
mailbox('drafts', 'team-drafts', { accountId: 'team' }),
|
||||
mailbox('sent', 'team-sent', { accountId: 'team' }),
|
||||
];
|
||||
|
||||
function entry(overrides: Partial<PendingUndoSend> = {}): PendingUndoSend {
|
||||
return {
|
||||
emailSubmissionId: 'sub-1',
|
||||
emailId: 'mail-1',
|
||||
identityId: 'id-1',
|
||||
from: [SENDER],
|
||||
delaySeconds: 10,
|
||||
createdAt: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useSendUndoStore.setState({ pending: null, busy: false, restoredEmailId: null });
|
||||
api.getFullEmail.mockResolvedValue({ id: 'mail-1', subject: 'Re: hi' } as Email);
|
||||
});
|
||||
|
||||
describe('send-undo-store', () => {
|
||||
describe('recordHeldSend', () => {
|
||||
it('offers undo for a send held by the delay', () => {
|
||||
const ok = useSendUndoStore.getState().recordHeldSend(HELD, 10, {
|
||||
identityId: 'id-1',
|
||||
accountId: 'team',
|
||||
from: [SENDER],
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(useSendUndoStore.getState().pending).toMatchObject({
|
||||
emailSubmissionId: 'sub-1',
|
||||
emailId: 'mail-1',
|
||||
identityId: 'id-1',
|
||||
accountId: 'team',
|
||||
from: [SENDER],
|
||||
sendAt: HELD.sendAt,
|
||||
delaySeconds: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('records nothing for a send that went out at once', () => {
|
||||
const store = useSendUndoStore.getState();
|
||||
expect(store.recordHeldSend({ scheduled: false, emailSubmissionId: 'sub-1', emailId: 'mail-1' }, undefined, { identityId: 'id-1' })).toBe(false);
|
||||
expect(store.recordHeldSend(HELD, undefined, { identityId: 'id-1' })).toBe(false);
|
||||
expect(store.recordHeldSend(HELD, 0, { identityId: 'id-1' })).toBe(false);
|
||||
expect(useSendUndoStore.getState().pending).toBeNull();
|
||||
});
|
||||
|
||||
it('records nothing when the server named no submission or message', () => {
|
||||
const store = useSendUndoStore.getState();
|
||||
expect(store.recordHeldSend({ ...HELD, emailSubmissionId: undefined }, 10, { identityId: 'id-1' })).toBe(false);
|
||||
expect(store.recordHeldSend({ ...HELD, emailId: undefined }, 10, { identityId: 'id-1' })).toBe(false);
|
||||
expect(useSendUndoStore.getState().pending).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('undo and send now', () => {
|
||||
it('cancels the submission in the account holding it', async () => {
|
||||
useSendUndoStore.getState().setPending(entry({ accountId: 'team' }));
|
||||
await expect(useSendUndoStore.getState().undo()).resolves.toBe(true);
|
||||
expect(api.cancelScheduledSend).toHaveBeenCalledWith('sub-1', 'team');
|
||||
expect(useSendUndoStore.getState()).toMatchObject({ pending: null, busy: false, restoredEmailId: 'mail-1' });
|
||||
});
|
||||
|
||||
it('cancels an own-account send in the primary account', async () => {
|
||||
useSendUndoStore.getState().setPending(entry());
|
||||
await useSendUndoStore.getState().undo();
|
||||
expect(api.cancelScheduledSend).toHaveBeenCalledWith('sub-1', undefined);
|
||||
});
|
||||
|
||||
it('keeps the entry when the cancel fails', async () => {
|
||||
api.cancelScheduledSend.mockRejectedValueOnce(new Error('gone'));
|
||||
const pending = entry();
|
||||
useSendUndoStore.getState().setPending(pending);
|
||||
await expect(useSendUndoStore.getState().undo()).resolves.toBe(false);
|
||||
expect(useSendUndoStore.getState()).toMatchObject({ pending, busy: false });
|
||||
});
|
||||
|
||||
it('releases the held message from the account holding it', async () => {
|
||||
useSendUndoStore.getState().setPending(entry({ accountId: 'team' }));
|
||||
await expect(useSendUndoStore.getState().sendNow()).resolves.toBe(true);
|
||||
expect(api.rescheduleScheduledSend).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ emailSubmissionId: 'sub-1', emailId: 'mail-1', identityId: 'id-1', accountId: 'team' }),
|
||||
0,
|
||||
);
|
||||
expect(useSendUndoStore.getState().pending).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreUndoneSend', () => {
|
||||
it('moves an own-account message back into the own Drafts', async () => {
|
||||
const draft = await restoreUndoneSend(entry(), MAILBOXES);
|
||||
expect(api.restoreEmailToDraft).toHaveBeenCalledWith('mail-1', 'own-drafts', 'own-sent', undefined);
|
||||
expect(api.getFullEmail).toHaveBeenCalledWith('mail-1', undefined);
|
||||
expect(draft).toMatchObject({ id: 'mail-1', jmapAccountId: undefined });
|
||||
});
|
||||
|
||||
it('moves a shared-account reply back into that account\'s Drafts', async () => {
|
||||
const draft = await restoreUndoneSend(entry({ accountId: 'team' }), MAILBOXES);
|
||||
expect(api.restoreEmailToDraft).toHaveBeenCalledWith('mail-1', 'team-drafts', 'team-sent', 'team');
|
||||
expect(api.getFullEmail).toHaveBeenCalledWith('mail-1', 'team');
|
||||
expect(draft).toMatchObject({ id: 'mail-1', jmapAccountId: 'team' });
|
||||
});
|
||||
|
||||
it('treats the primary account named explicitly as the own account', async () => {
|
||||
const draft = await restoreUndoneSend(entry({ accountId: 'primary' }), MAILBOXES);
|
||||
expect(api.restoreEmailToDraft).toHaveBeenCalledWith('mail-1', 'own-drafts', 'own-sent', undefined);
|
||||
expect(draft.jmapAccountId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still opens the message when the account has no Drafts folder', async () => {
|
||||
const draft = await restoreUndoneSend(entry({ accountId: 'team' }), MAILBOXES.slice(0, 2));
|
||||
expect(api.restoreEmailToDraft).not.toHaveBeenCalled();
|
||||
expect(draft.id).toBe('mail-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,26 @@
|
||||
// A message sent with the undo-send delay: the submission is held by the
|
||||
// server (HOLDFOR) and can still be cancelled or released early. The composer
|
||||
// records it here after a delayed send; the UndoSnackbar on the mail list
|
||||
// offers "Undo" / "Send now" for the length of the window (webmail
|
||||
// `pendingUndoSend`, changelog 1.7.0).
|
||||
// and the viewer's quick reply record it here after a delayed send; the
|
||||
// UndoSnackbar, which every screen hosts, offers "Undo" / "Send now" for the
|
||||
// length of the window (webmail `pendingUndoSend`, changelog 1.7.0).
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { cancelScheduledSend, rescheduleScheduledSend } from '../api/email';
|
||||
import type { EmailAddress } from '../api/types';
|
||||
import {
|
||||
cancelScheduledSend, getFullEmail, rescheduleScheduledSend, restoreEmailToDraft,
|
||||
type SendEmailResult,
|
||||
} from '../api/email';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
import type { EmailAddress, Mailbox } from '../api/types';
|
||||
import { draftContextFromEmail } from '../lib/draft-context';
|
||||
import { mailboxesOfAccount } from '../lib/mailbox-tree';
|
||||
import type { ComposeDraftContext } from '../navigation/types';
|
||||
|
||||
export interface PendingUndoSend {
|
||||
emailSubmissionId: string;
|
||||
emailId: string;
|
||||
identityId: string;
|
||||
/** Account holding the submission: a shared account for a send from its identity. */
|
||||
accountId?: string;
|
||||
from?: EmailAddress[];
|
||||
to?: EmailAddress[];
|
||||
/** ISO time the server will release the message. */
|
||||
@@ -21,12 +30,25 @@ export interface PendingUndoSend {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/** What the sender knows about a held send beyond the server's answer. */
|
||||
export type HeldSendDetails = Pick<PendingUndoSend, 'identityId' | 'accountId' | 'from' | 'to'>;
|
||||
|
||||
interface SendUndoState {
|
||||
pending: PendingUndoSend | null;
|
||||
busy: boolean;
|
||||
/** Set after an undo so the composer can be reopened with the draft. */
|
||||
restoredEmailId: string | null;
|
||||
setPending: (entry: PendingUndoSend) => void;
|
||||
/**
|
||||
* Offer undo for a send held by the undo-send delay. Records nothing, and
|
||||
* returns false, when the message went out at once or the server named no
|
||||
* submission to cancel.
|
||||
*/
|
||||
recordHeldSend: (
|
||||
result: SendEmailResult,
|
||||
holdForSeconds: number | undefined,
|
||||
details: HeldSendDetails,
|
||||
) => boolean;
|
||||
clear: () => void;
|
||||
/** Cancel delivery. The message stays in Sent; the caller may re-open it. */
|
||||
undo: () => Promise<boolean>;
|
||||
@@ -40,6 +62,18 @@ export const useSendUndoStore = create<SendUndoState>((set, get) => ({
|
||||
restoredEmailId: null,
|
||||
|
||||
setPending: (entry) => set({ pending: entry, restoredEmailId: null }),
|
||||
recordHeldSend: (result, holdForSeconds, details) => {
|
||||
if (!holdForSeconds || !result.scheduled || !result.emailSubmissionId || !result.emailId) return false;
|
||||
get().setPending({
|
||||
...details,
|
||||
emailSubmissionId: result.emailSubmissionId,
|
||||
emailId: result.emailId,
|
||||
sendAt: result.sendAt,
|
||||
delaySeconds: holdForSeconds,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
clear: () => set({ pending: null, busy: false }),
|
||||
|
||||
undo: async () => {
|
||||
@@ -47,7 +81,7 @@ export const useSendUndoStore = create<SendUndoState>((set, get) => ({
|
||||
if (!entry || get().busy) return false;
|
||||
set({ busy: true });
|
||||
try {
|
||||
await cancelScheduledSend(entry.emailSubmissionId);
|
||||
await cancelScheduledSend(entry.emailSubmissionId, entry.accountId);
|
||||
set({ pending: null, busy: false, restoredEmailId: entry.emailId });
|
||||
return true;
|
||||
} catch (err) {
|
||||
@@ -69,6 +103,7 @@ export const useSendUndoStore = create<SendUndoState>((set, get) => ({
|
||||
identityId: entry.identityId,
|
||||
from: entry.from,
|
||||
to: entry.to,
|
||||
accountId: entry.accountId,
|
||||
},
|
||||
0,
|
||||
);
|
||||
@@ -81,3 +116,28 @@ export const useSendUndoStore = create<SendUndoState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* After an undo: move the held message back into Drafts and load it for the
|
||||
* composer, in the account it was sent from (a quick reply to shared mail
|
||||
* goes out of the shared account). Webmail `cancelUndoSend`.
|
||||
*/
|
||||
export async function restoreUndoneSend(
|
||||
entry: PendingUndoSend,
|
||||
mailboxes: Mailbox[],
|
||||
): Promise<ComposeDraftContext> {
|
||||
const shared = !!entry.accountId && entry.accountId !== jmapClient.accountId;
|
||||
const accountId = shared ? entry.accountId : undefined;
|
||||
const scope = mailboxesOfAccount(mailboxes, accountId);
|
||||
const drafts = scope.find((m) => m.role === 'drafts');
|
||||
const sent = scope.find((m) => m.role === 'sent');
|
||||
if (drafts) {
|
||||
await restoreEmailToDraft(
|
||||
entry.emailId,
|
||||
drafts.originalId ?? drafts.id,
|
||||
sent ? (sent.originalId ?? sent.id) : undefined,
|
||||
accountId,
|
||||
);
|
||||
}
|
||||
return draftContextFromEmail(await getFullEmail(entry.emailId, accountId), accountId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user