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 { useDeviceDetection } from "@/hooks/use-media-query";
|
||||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||||
|
import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
import { playNotificationSound } from "@/lib/notification-sound";
|
import { playNotificationSound } from "@/lib/notification-sound";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -143,6 +144,102 @@ export default function Home() {
|
|||||||
fetchEmailContent,
|
fetchEmailContent,
|
||||||
} = useEmailStore();
|
} = 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
|
// Keyboard shortcuts handlers
|
||||||
const keyboardHandlers = useMemo(() => ({
|
const keyboardHandlers = useMemo(() => ({
|
||||||
onNextEmail: () => {
|
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 = () => {
|
const handleMobileBack = () => {
|
||||||
// If in conversation view, clear it
|
if (typeof window !== 'undefined') {
|
||||||
|
window.history.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (conversationThread) {
|
if (conversationThread) {
|
||||||
setConversationThread(null);
|
setConversationThread(null);
|
||||||
setConversationEmails([]);
|
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