feat: sync mail view to browser history for back/forward navigation
This commit is contained in:
+106
-2
@@ -19,6 +19,7 @@ import { useUIStore } from "@/stores/ui-store";
|
||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { playNotificationSound } from "@/lib/notification-sound";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -143,6 +144,102 @@ export default function Home() {
|
||||
fetchEmailContent,
|
||||
} = useEmailStore();
|
||||
|
||||
// Browser back / forward integration. The restore handler reads the
|
||||
// latest values from a ref so we don't have to recreate the callback on
|
||||
// every render (and so the popstate listener is never stale).
|
||||
const navRestoreStateRef = useRef({
|
||||
client,
|
||||
emails,
|
||||
mailboxes,
|
||||
selectedMailbox,
|
||||
selectedEmailId: selectedEmail?.id ?? null,
|
||||
conversationThreadId: null as string | null,
|
||||
});
|
||||
navRestoreStateRef.current.client = client;
|
||||
navRestoreStateRef.current.emails = emails;
|
||||
navRestoreStateRef.current.mailboxes = mailboxes;
|
||||
navRestoreStateRef.current.selectedMailbox = selectedMailbox;
|
||||
navRestoreStateRef.current.selectedEmailId = selectedEmail?.id ?? null;
|
||||
navRestoreStateRef.current.conversationThreadId = conversationThread?.threadId ?? null;
|
||||
|
||||
const handleNavRestore = useCallback(async (state: NavSnapshot) => {
|
||||
const ctx = navRestoreStateRef.current;
|
||||
|
||||
// Restore sidebar overlay state.
|
||||
setSidebarOpen(state.sidebarOpen);
|
||||
|
||||
// Restore composer visibility.
|
||||
if (!state.composerOpen) {
|
||||
setShowComposer(false);
|
||||
}
|
||||
|
||||
// Derive the mobile view from the saved snapshot. The view is a
|
||||
// function of which content the user is looking at: an email, a
|
||||
// thread, the composer, or the bare list.
|
||||
const derivedView: "list" | "viewer" =
|
||||
state.emailId || state.threadId || state.composerOpen ? "viewer" : "list";
|
||||
setActiveView(derivedView);
|
||||
|
||||
// Restore mailbox selection. selectMailbox clears the current email,
|
||||
// which is fine because we re-apply the saved email below.
|
||||
if (state.mailboxId && state.mailboxId !== ctx.selectedMailbox) {
|
||||
selectMailbox(state.mailboxId);
|
||||
if (ctx.client) {
|
||||
try {
|
||||
await fetchEmails(ctx.client, state.mailboxId);
|
||||
} catch (error) {
|
||||
debug.error('Failed to fetch emails on history restore:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore conversation thread (mobile only). We can clear it directly,
|
||||
// but reopening requires the thread group; if the user pressed forward
|
||||
// to return to a thread, we silently skip — back navigation always works.
|
||||
if ((state.threadId ?? null) !== ctx.conversationThreadId) {
|
||||
if (state.threadId === null) {
|
||||
setConversationThread(null);
|
||||
setConversationEmails([]);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore email selection.
|
||||
if (state.emailId !== ctx.selectedEmailId) {
|
||||
if (state.emailId === null) {
|
||||
selectEmail(null);
|
||||
} else {
|
||||
// Try the in-memory list first; the existing useEffect will fetch
|
||||
// body content if it's missing.
|
||||
const found = ctx.emails.find(e => e.id === state.emailId);
|
||||
if (found) {
|
||||
selectEmail(found);
|
||||
} else if (ctx.client) {
|
||||
// Email isn't in the current list (e.g. mailbox just changed).
|
||||
// Fetch it directly.
|
||||
try {
|
||||
const mailbox = ctx.mailboxes.find(mb => mb.id === state.mailboxId);
|
||||
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
||||
const fullEmail = await ctx.client.getEmail(state.emailId, accountId);
|
||||
if (fullEmail) selectEmail(fullEmail);
|
||||
} catch (error) {
|
||||
debug.error('Failed to fetch email on history restore:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useBrowserNavigation({
|
||||
mailboxId: selectedMailbox,
|
||||
emailId: selectedEmail?.id ?? null,
|
||||
threadId: conversationThread?.threadId ?? null,
|
||||
composerOpen: showComposer,
|
||||
sidebarOpen,
|
||||
onRestore: handleNavRestore,
|
||||
enabled: isAuthenticated && mailboxes.length > 0,
|
||||
});
|
||||
|
||||
// Keyboard shortcuts handlers
|
||||
const keyboardHandlers = useMemo(() => ({
|
||||
onNextEmail: () => {
|
||||
@@ -1018,9 +1115,16 @@ export default function Home() {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle back navigation from viewer on mobile
|
||||
// Handle back navigation from viewer on mobile.
|
||||
// Delegate to the browser history stack so this button is equivalent to
|
||||
// the OS back button / mouse back button — popstate then restores the
|
||||
// previous snapshot via handleNavRestore. The viewer is only reachable
|
||||
// from a state that pushed history, so back() always lands on an app entry.
|
||||
const handleMobileBack = () => {
|
||||
// If in conversation view, clear it
|
||||
if (typeof window !== 'undefined') {
|
||||
window.history.back();
|
||||
return;
|
||||
}
|
||||
if (conversationThread) {
|
||||
setConversationThread(null);
|
||||
setConversationEmails([]);
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export interface NavSnapshot {
|
||||
mailboxId: string | null;
|
||||
emailId: string | null;
|
||||
threadId: string | null;
|
||||
composerOpen: boolean;
|
||||
sidebarOpen: boolean;
|
||||
}
|
||||
|
||||
interface StoredNavState extends NavSnapshot {
|
||||
navId: number;
|
||||
}
|
||||
|
||||
interface UseBrowserNavigationOptions extends NavSnapshot {
|
||||
onRestore: (state: NavSnapshot) => void | Promise<void>;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
const STATE_KEY = "__mailNav";
|
||||
let navIdCounter = 0;
|
||||
|
||||
function snapshotsEqual(
|
||||
a: NavSnapshot | undefined | null,
|
||||
b: NavSnapshot,
|
||||
): boolean {
|
||||
if (!a) return false;
|
||||
return (
|
||||
a.mailboxId === b.mailboxId &&
|
||||
a.emailId === b.emailId &&
|
||||
a.threadId === b.threadId &&
|
||||
a.composerOpen === b.composerOpen &&
|
||||
a.sidebarOpen === b.sidebarOpen
|
||||
);
|
||||
}
|
||||
|
||||
function readStoredState(): StoredNavState | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
const raw = window.history.state as Record<string, unknown> | null;
|
||||
if (!raw) return undefined;
|
||||
return raw[STATE_KEY] as StoredNavState | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs in-app navigation state to the browser history stack so the browser
|
||||
* back / forward buttons (mouse buttons on desktop, gesture / hardware button
|
||||
* on mobile) navigate within the mail UI.
|
||||
*
|
||||
* - Pushes a new history entry whenever the captured snapshot changes from
|
||||
* user action.
|
||||
* - Listens for popstate and calls onRestore so the page can apply the
|
||||
* previous snapshot (mailbox, email, view, sidebar, conversation thread).
|
||||
*
|
||||
* The URL is left untouched so Next.js routes (e.g. /calendar, /settings)
|
||||
* continue to behave normally.
|
||||
*/
|
||||
export function useBrowserNavigation({
|
||||
mailboxId,
|
||||
emailId,
|
||||
threadId,
|
||||
composerOpen,
|
||||
sidebarOpen,
|
||||
onRestore,
|
||||
enabled = true,
|
||||
}: UseBrowserNavigationOptions) {
|
||||
// Counter so overlapping restores don't accidentally clear the flag
|
||||
// belonging to a later restore.
|
||||
const popDepthRef = useRef(0);
|
||||
const isApplyingPopRef = useRef(false);
|
||||
const restoreRef = useRef(onRestore);
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
// Always keep the latest restore callback in a ref so the popstate
|
||||
// listener never sees a stale closure.
|
||||
restoreRef.current = onRestore;
|
||||
|
||||
// Install the popstate listener once.
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const handlePop = (event: PopStateEvent) => {
|
||||
const raw = event.state as Record<string, unknown> | null;
|
||||
const state = raw ? (raw[STATE_KEY] as StoredNavState | undefined) : undefined;
|
||||
if (!state) return;
|
||||
|
||||
// Hold the "applying pop" flag for the entire restore — including any
|
||||
// async work like fetching email content — so the resulting state
|
||||
// updates don't trigger a fresh history push that would undo the
|
||||
// user's back / forward navigation.
|
||||
popDepthRef.current += 1;
|
||||
isApplyingPopRef.current = true;
|
||||
|
||||
const settle = () => {
|
||||
popDepthRef.current -= 1;
|
||||
if (popDepthRef.current === 0) {
|
||||
// One extra macrotask so React has flushed any state updates
|
||||
// dispatched at the very end of the restore.
|
||||
setTimeout(() => {
|
||||
if (popDepthRef.current === 0) {
|
||||
isApplyingPopRef.current = false;
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
let result: void | Promise<void>;
|
||||
try {
|
||||
result = restoreRef.current(state);
|
||||
} catch (error) {
|
||||
settle();
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (result && typeof (result as Promise<void>).then === "function") {
|
||||
(result as Promise<void>).finally(settle);
|
||||
} else {
|
||||
settle();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("popstate", handlePop);
|
||||
return () => window.removeEventListener("popstate", handlePop);
|
||||
}, []);
|
||||
|
||||
// Push a new history entry whenever the navigation snapshot changes.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (typeof window === "undefined") return;
|
||||
if (isApplyingPopRef.current) return;
|
||||
|
||||
const snapshot: NavSnapshot = {
|
||||
mailboxId,
|
||||
emailId,
|
||||
threadId,
|
||||
composerOpen,
|
||||
sidebarOpen,
|
||||
};
|
||||
|
||||
const existing = readStoredState();
|
||||
if (snapshotsEqual(existing, snapshot)) return;
|
||||
|
||||
const stored: StoredNavState = { ...snapshot, navId: ++navIdCounter };
|
||||
const baseState = (window.history.state ?? {}) as Record<string, unknown>;
|
||||
const newState = { ...baseState, [STATE_KEY]: stored };
|
||||
|
||||
if (!initializedRef.current) {
|
||||
initializedRef.current = true;
|
||||
// Replace the current entry on the very first run so we don't
|
||||
// create an extra step the user has to back through to leave the app.
|
||||
window.history.replaceState(newState, "");
|
||||
} else {
|
||||
window.history.pushState(newState, "");
|
||||
}
|
||||
}, [enabled, mailboxId, emailId, threadId, composerOpen, sidebarOpen]);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user