fix(pro): prompt to save or discard draft when closing compose tab via tab-bar X
This commit is contained in:
committed by
Linus Rath
parent
16daf6ea03
commit
2a41e73bf9
@@ -139,7 +139,7 @@ export default function ProHome() {
|
|||||||
const focusedPaneId = useProTabStore((s) => s.focusedPaneId);
|
const focusedPaneId = useProTabStore((s) => s.focusedPaneId);
|
||||||
const loadedTabIds = useProTabStore((s) => s.loadedTabIds);
|
const loadedTabIds = useProTabStore((s) => s.loadedTabIds);
|
||||||
const openTab = useProTabStore((s) => s.openTab);
|
const openTab = useProTabStore((s) => s.openTab);
|
||||||
const closeTab = useProTabStore((s) => s.closeTab);
|
const requestCloseTab = useProTabStore((s) => s.requestCloseTab);
|
||||||
const setActiveTab = useProTabStore((s) => s.setActiveTab);
|
const setActiveTab = useProTabStore((s) => s.setActiveTab);
|
||||||
const setFocusedPane = useProTabStore((s) => s.setFocusedPane);
|
const setFocusedPane = useProTabStore((s) => s.setFocusedPane);
|
||||||
const moveTabToPane = useProTabStore((s) => s.moveTabToPane);
|
const moveTabToPane = useProTabStore((s) => s.moveTabToPane);
|
||||||
@@ -354,7 +354,7 @@ export default function ProHome() {
|
|||||||
activeMainTabId={activeMainTabId}
|
activeMainTabId={activeMainTabId}
|
||||||
activeSplitTabId={activeSplitTabId}
|
activeSplitTabId={activeSplitTabId}
|
||||||
onActivate={setActiveTab}
|
onActivate={setActiveTab}
|
||||||
onClose={closeTab}
|
onClose={requestCloseTab}
|
||||||
onDragStateChange={setIsTabDragging}
|
onDragStateChange={setIsTabDragging}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -133,6 +133,13 @@ interface EmailComposerProps {
|
|||||||
}) => void | Promise<void>;
|
}) => void | Promise<void>;
|
||||||
onScheduledSendCreated?: () => void | Promise<void>;
|
onScheduledSendCreated?: () => void | Promise<void>;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
|
/**
|
||||||
|
* When provided, the composer assigns its close handler to `current`. The
|
||||||
|
* handler shows the unsaved-changes dialog when the draft is dirty, so a
|
||||||
|
* host (e.g. the Pro tab bar's close button) can route an external close
|
||||||
|
* request through the same guard instead of discarding silently.
|
||||||
|
*/
|
||||||
|
requestCloseRef?: React.MutableRefObject<(() => void) | null>;
|
||||||
onDiscardDraft?: (draftId: string) => void;
|
onDiscardDraft?: (draftId: string) => void;
|
||||||
onSaveState?: (data: ComposerDraftData) => void;
|
onSaveState?: (data: ComposerDraftData) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -229,6 +236,7 @@ export function EmailComposer({
|
|||||||
onSend,
|
onSend,
|
||||||
onScheduledSendCreated,
|
onScheduledSendCreated,
|
||||||
onClose,
|
onClose,
|
||||||
|
requestCloseRef,
|
||||||
onDiscardDraft,
|
onDiscardDraft,
|
||||||
onSaveState,
|
onSaveState,
|
||||||
className,
|
className,
|
||||||
@@ -1815,6 +1823,17 @@ export function EmailComposer({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Expose the dirty-aware close handler so external hosts (e.g. the Pro tab
|
||||||
|
// bar) can trigger the same "Save or discard draft?" guard. Re-assigned on
|
||||||
|
// every render to capture the latest closure over the live refs/state.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!requestCloseRef) return;
|
||||||
|
requestCloseRef.current = handleClose;
|
||||||
|
return () => {
|
||||||
|
requestCloseRef.current = null;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const handleComposerKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
const handleComposerKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
if (e.defaultPrevented) return;
|
if (e.defaultPrevented) return;
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { ErrorBoundary, ComposerErrorFallback } from "@/components/error";
|
|||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { useProTabStore, type ProComposeTabData } from "@/stores/pro-tab-store";
|
import { useProTabStore, registerProTabCloseInterceptor, type ProComposeTabData } from "@/stores/pro-tab-store";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
|
|
||||||
interface ProComposeTabBodyProps {
|
interface ProComposeTabBodyProps {
|
||||||
@@ -38,6 +38,10 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
|||||||
const tabIdRef = useRef(tabId);
|
const tabIdRef = useRef(tabId);
|
||||||
tabIdRef.current = tabId;
|
tabIdRef.current = tabId;
|
||||||
|
|
||||||
|
// Set by the composer to its dirty-aware close handler. Lets the Pro tab
|
||||||
|
// bar's "X" route through the same "Save or discard draft?" guard.
|
||||||
|
const requestCloseRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
const handleScheduledSendCreated = useCallback(async () => {
|
const handleScheduledSendCreated = useCallback(async () => {
|
||||||
if (client) {
|
if (client) {
|
||||||
await refreshScheduledMetadata(client);
|
await refreshScheduledMetadata(client);
|
||||||
@@ -120,6 +124,20 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
|||||||
closeTab(tabIdRef.current);
|
closeTab(tabIdRef.current);
|
||||||
}, [closeTab]);
|
}, [closeTab]);
|
||||||
|
|
||||||
|
// Register a close interceptor so closing the tab from the tab bar (the
|
||||||
|
// "X" button or middle-click) goes through the composer's unsaved-changes
|
||||||
|
// guard, mirroring the non-Pro inline composer.
|
||||||
|
useEffect(() => {
|
||||||
|
const id = tabIdRef.current;
|
||||||
|
return registerProTabCloseInterceptor(id, () => {
|
||||||
|
if (requestCloseRef.current) {
|
||||||
|
requestCloseRef.current();
|
||||||
|
} else {
|
||||||
|
closeTab(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [closeTab]);
|
||||||
|
|
||||||
const handleDiscardDraft = useCallback(async (draftId: string) => {
|
const handleDiscardDraft = useCallback(async (draftId: string) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
try {
|
try {
|
||||||
@@ -160,6 +178,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
|||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
onScheduledSendCreated={handleScheduledSendCreated}
|
onScheduledSendCreated={handleScheduledSendCreated}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
|
requestCloseRef={requestCloseRef}
|
||||||
onDiscardDraft={handleDiscardDraft}
|
onDiscardDraft={handleDiscardDraft}
|
||||||
onSaveState={handleSaveState}
|
onSaveState={handleSaveState}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
|
|||||||
@@ -87,6 +87,12 @@ interface ProTabState {
|
|||||||
openComposeTab: (data: ProComposeTabData) => string;
|
openComposeTab: (data: ProComposeTabData) => string;
|
||||||
openEmailTab: (data: ProEmailTabData) => string;
|
openEmailTab: (data: ProEmailTabData) => string;
|
||||||
closeTab: (id: string) => void;
|
closeTab: (id: string) => void;
|
||||||
|
/**
|
||||||
|
* Request closing a tab, honouring any registered close interceptor (e.g. a
|
||||||
|
* compose tab with unsaved changes that wants to show the "Save or discard
|
||||||
|
* draft?" dialog first). Falls back to `closeTab` when none is registered.
|
||||||
|
*/
|
||||||
|
requestCloseTab: (id: string) => void;
|
||||||
setActiveTab: (id: string) => void;
|
setActiveTab: (id: string) => void;
|
||||||
setFocusedPane: (paneId: ProPaneId) => void;
|
setFocusedPane: (paneId: ProPaneId) => void;
|
||||||
|
|
||||||
@@ -131,6 +137,21 @@ const HOME_TAB: ProTab = {
|
|||||||
paneId: 'main',
|
paneId: 'main',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module-level registry of tab close interceptors. Kept outside the persisted
|
||||||
|
* Zustand state so functions are never serialised. A compose tab registers a
|
||||||
|
* handler here so an external close request (tab-bar "X", middle-click) routes
|
||||||
|
* through the composer's unsaved-changes guard instead of closing instantly.
|
||||||
|
*/
|
||||||
|
const closeInterceptors = new Map<string, () => void>();
|
||||||
|
|
||||||
|
export function registerProTabCloseInterceptor(id: string, fn: () => void): () => void {
|
||||||
|
closeInterceptors.set(id, fn);
|
||||||
|
return () => {
|
||||||
|
if (closeInterceptors.get(id) === fn) closeInterceptors.delete(id);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function makeId(): string {
|
function makeId(): string {
|
||||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
@@ -265,11 +286,23 @@ export const useProTabStore = create<ProTabState>()(
|
|||||||
return newTab.id;
|
return newTab.id;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
requestCloseTab: (id) => {
|
||||||
|
const interceptor = closeInterceptors.get(id);
|
||||||
|
if (interceptor) {
|
||||||
|
interceptor();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
get().closeTab(id);
|
||||||
|
},
|
||||||
|
|
||||||
closeTab: (id) => {
|
closeTab: (id) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
const tab = state.tabs.find((t) => t.id === id);
|
const tab = state.tabs.find((t) => t.id === id);
|
||||||
if (!tab || !tab.closeable) return;
|
if (!tab || !tab.closeable) return;
|
||||||
|
|
||||||
|
// Drop any registered close interceptor for this tab.
|
||||||
|
closeInterceptors.delete(id);
|
||||||
|
|
||||||
const removedPane = tab.paneId;
|
const removedPane = tab.paneId;
|
||||||
const newTabs = state.tabs.filter((t) => t.id !== id);
|
const newTabs = state.tabs.filter((t) => t.id !== id);
|
||||||
const newLoaded = state.loadedTabIds.filter((tid) => tid !== id);
|
const newLoaded = state.loadedTabIds.filter((tid) => tid !== id);
|
||||||
|
|||||||
Reference in New Issue
Block a user