feat(send): 'Send now' on the send-delay toast

The post-send undo toast ('scheduled to send' + Cancel send) now also offers a
'Send now' action that reschedules the delayed submission for immediate release,
so you can skip the undo window without waiting it out. Adds an optional
secondaryAction to the toast component and carries identityId on the pending
undo-send state so the reschedule can target the right identity.
This commit is contained in:
Shuki Vaknin
2026-07-21 20:58:27 +02:00
committed by Linus Rath
parent a3f9055541
commit f15edd336b
4 changed files with 68 additions and 21 deletions
+21
View File
@@ -1428,6 +1428,27 @@ export default function Home() {
toast.success(t('email_viewer.scheduled_send_created'), { toast.success(t('email_viewer.scheduled_send_created'), {
duration: undoDurationMs, duration: undoDurationMs,
secondaryAction: (pending.emailId && pending.identityId)
? {
label: t('email_viewer.send_now'),
onClick: () => {
void (async () => {
try {
await client.rescheduleEmailSubmission(
pending.submissionId,
pending.emailId!,
pending.identityId!,
new Date(Date.now() + 1000).toISOString(),
);
clearPendingUndoSend();
if (isScheduledView) await fetchScheduledEmails(client);
} catch (error) {
console.error('Failed to send now:', error);
}
})();
},
}
: undefined,
action: { action: {
label: t('email_viewer.undo_send'), label: t('email_viewer.undo_send'),
onClick: () => { onClick: () => {
+42 -18
View File
@@ -21,6 +21,7 @@ export interface Toast {
onClick?: () => void; onClick?: () => void;
icon?: React.ReactNode; icon?: React.ReactNode;
action?: ToastAction; action?: ToastAction;
secondaryAction?: ToastAction;
} }
interface ToastProps { interface ToastProps {
@@ -117,25 +118,48 @@ export function ToastItem({ toast, onClose }: ToastProps) {
{toast.message && ( {toast.message && (
<p className="text-[12px] mt-1 text-muted-foreground leading-snug">{toast.message}</p> <p className="text-[12px] mt-1 text-muted-foreground leading-snug">{toast.message}</p>
)} )}
{toast.action && ( {(toast.action || toast.secondaryAction) && (
<button <div className="mt-2 flex items-center gap-2">
onClick={(e) => { {toast.secondaryAction && (
e.stopPropagation(); <button
try { onClick={(e) => {
toast.action!.onClick(); e.stopPropagation();
dismiss(); try {
} catch { toast.secondaryAction!.onClick();
// Don't close toast on error so user can retry dismiss();
} } catch {
}} // Don't close toast on error so user can retry
className={cn( }
"mt-2 text-[12px] font-semibold px-2.5 py-1 rounded-md transition-colors", }}
"bg-foreground/5 hover:bg-foreground/10 dark:bg-white/10 dark:hover:bg-white/15", className={cn(
"text-foreground" "text-[12px] font-semibold px-2.5 py-1 rounded-md transition-colors",
"bg-primary text-primary-foreground hover:bg-primary/90"
)}
>
{toast.secondaryAction.label}
</button>
)} )}
> {toast.action && (
{toast.action.label} <button
</button> onClick={(e) => {
e.stopPropagation();
try {
toast.action!.onClick();
dismiss();
} catch {
// Don't close toast on error so user can retry
}
}}
className={cn(
"text-[12px] font-semibold px-2.5 py-1 rounded-md transition-colors",
"bg-foreground/5 hover:bg-foreground/10 dark:bg-white/10 dark:hover:bg-white/15",
"text-foreground"
)}
>
{toast.action.label}
</button>
)}
</div>
)} )}
</div> </div>
+3 -3
View File
@@ -20,7 +20,7 @@ type ScheduledSubmissionMetadata = {
const VIRTUAL_SCHEDULED_MAILBOX_ID = '__scheduled__'; const VIRTUAL_SCHEDULED_MAILBOX_ID = '__scheduled__';
type PendingUndoSend = { submissionId: string; emailId?: string; sendAt: string; isSmime: boolean }; type PendingUndoSend = { submissionId: string; emailId?: string; identityId?: string; sendAt: string; isSmime: boolean };
interface EmailStore { interface EmailStore {
emails: Email[]; emails: Email[];
@@ -1287,7 +1287,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
set({ set({
isLoading: false, isLoading: false,
pendingUndoSend: result.scheduled && result.emailSubmissionId && result.sendAt pendingUndoSend: result.scheduled && result.emailSubmissionId && result.sendAt
? { submissionId: result.emailSubmissionId, emailId: result.emailId, sendAt: result.sendAt, isSmime: false } ? { submissionId: result.emailSubmissionId, emailId: result.emailId, identityId, sendAt: result.sendAt, isSmime: false }
: get().pendingUndoSend, : get().pendingUndoSend,
}); });
return result; return result;
@@ -1311,7 +1311,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
set({ set({
isLoading: false, isLoading: false,
pendingUndoSend: result.scheduled && result.emailSubmissionId && result.sendAt pendingUndoSend: result.scheduled && result.emailSubmissionId && result.sendAt
? { submissionId: result.emailSubmissionId, emailId: result.emailId, sendAt: result.sendAt, isSmime: true } ? { submissionId: result.emailSubmissionId, emailId: result.emailId, identityId, sendAt: result.sendAt, isSmime: true }
: get().pendingUndoSend, : get().pendingUndoSend,
}); });
return result; return result;
+2
View File
@@ -38,6 +38,7 @@ export const useToastStore = create<ToastStore>((set) => ({
interface ToastOptions { interface ToastOptions {
message?: string; message?: string;
action?: ToastAction; action?: ToastAction;
secondaryAction?: ToastAction;
duration?: number; duration?: number;
} }
@@ -48,6 +49,7 @@ function showToast(type: Toast["type"], title: string, options?: string | ToastO
title, title,
message: opts?.message, message: opts?.message,
action: opts?.action, action: opts?.action,
secondaryAction: opts?.secondaryAction,
duration: opts?.duration ?? defaultDuration, duration: opts?.duration ?? defaultDuration,
}); });
} }