FN-8231: dismiss mobile mailbox details on Back
Mobile mailbox overlays now close through the shared navigation history before leaving the message list. - Register mobile message, composer, and approval overlays as navigation modals. - Remove matching history entries when mailbox overlays close or change state. - Add mobile back-navigation coverage and document the behavior. Files changed: docs/dashboard-guide.md | 3 +- packages/dashboard/app/components/MailboxView.tsx | 117 ++++++++++++---- .../app/components/__tests__/MailboxView.test.tsx | 148 +++++++++++++++++++++ 3 files changed, 241 insertions(+), 27 deletions(-) Fusion-Task-Id: FN-8231 Fusion-Task-Lineage: b6f06ed9-b03f-45d9-b90b-28b565231bcc Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -74,8 +74,9 @@ The installed mobile/PWA home-screen icons are generated from `packages/dashboar
|
||||
|
||||
The dashboard now handles browser back navigation consistently on desktop and mobile.
|
||||
Using Back will first dismiss open modals and then step back through in-app view changes before leaving the app.
|
||||
On mobile, an open navigation-bar **More** sheet is dismissed by one browser Back action, iOS edge-swipe, or Android Back action before the current dashboard view changes.
|
||||
On mobile, an open navigation-bar **More** sheet or mailbox message detail is dismissed by one browser Back action, iOS edge-swipe, or Android Back action before the current dashboard view changes.
|
||||
<!-- FNXC:MobileNavBackDocs 2026-07-16-14:45: The mobile More sheet registers as a navigation modal, so every Back delivery mechanism dismisses it before navigating away. -->
|
||||
<!-- FNXC:MailboxMobileBackDocs 2026-07-16-16:15: A mobile mailbox message detail registers as a navigation modal, so Back returns to the message list instead of navigating away. -->
|
||||
When task detail is open from a board card, task popup, mobile list row, right-dock/activity/onboarding link, deep link, or another task detail link, one browser, iOS edge-swipe, or Android Back action closes the current detail first and restores the prior dashboard context (for example, nested task detail → previous task detail, or task detail → board/list).
|
||||
<!-- FNXC:TaskDetailSwipeBackDocs 2026-07-15-10:36: Mobile task popups now register the same navigation entry as modal and full-panel task detail, so every Back delivery mechanism dismisses the popup before it can leave the originating Board or List. -->
|
||||
On mobile board-card detail, **Back to board** also restores the prior board/card scroll position so the same lane context remains visible.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "./MailboxModal.css";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef, type CSSProperties } from "react";
|
||||
import { useState, useEffect, useCallback, useContext, useMemo, useRef, type CSSProperties } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -46,6 +46,7 @@ import { GatedActionApprovalDetails } from "./GatedActionApprovalDetails";
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
import { useViewportMode } from "../hooks/useViewportMode";
|
||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||
import { NavigationHistoryContext } from "../hooks/useNavigationHistory";
|
||||
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
import { getRelativeTimeBucket } from "../utils/relativeTimeAgo";
|
||||
|
||||
@@ -263,6 +264,7 @@ export function MailboxView({
|
||||
);
|
||||
const viewportMode = useViewportMode();
|
||||
const isMobile = viewportMode === "mobile";
|
||||
const navigationHistory = useContext(NavigationHistoryContext);
|
||||
const isSplitPane = !isMobile;
|
||||
const [sidebarWidth, setSidebarWidth] = useState<number>(() => readMailboxSidebarWidth(projectId));
|
||||
const splitLayoutRef = useRef<HTMLDivElement>(null);
|
||||
@@ -682,6 +684,11 @@ export function MailboxView({
|
||||
setConversationMessages([]);
|
||||
}, [consumeCurrentDeepLink]);
|
||||
|
||||
const dismissMessage = useCallback(() => {
|
||||
navigationHistory?.removeNav(handleCloseMessage);
|
||||
handleCloseMessage();
|
||||
}, [handleCloseMessage, navigationHistory]);
|
||||
|
||||
const handleMarkAllRead = useCallback(async () => {
|
||||
try {
|
||||
const result = await markAllMessagesRead(projectId);
|
||||
@@ -706,8 +713,7 @@ export function MailboxView({
|
||||
consumeCurrentDeepLink();
|
||||
try {
|
||||
await deleteMessage(id, projectId);
|
||||
setSelectedMessage(null);
|
||||
setConversationMessages([]);
|
||||
dismissMessage();
|
||||
// Refresh current tab
|
||||
if (activeTab === "inbox") loadInbox();
|
||||
else if (activeTab === "outbox") loadOutbox();
|
||||
@@ -717,31 +723,44 @@ export function MailboxView({
|
||||
} catch {
|
||||
addToast?.("Failed to delete message", "error");
|
||||
}
|
||||
}, [projectId, activeTab, selectedAgentId, loadInbox, loadOutbox, loadAgentMailbox, loadAllAgentsMailbox, addToast, consumeCurrentDeepLink]);
|
||||
}, [projectId, activeTab, selectedAgentId, loadInbox, loadOutbox, loadAgentMailbox, loadAllAgentsMailbox, addToast, consumeCurrentDeepLink, dismissMessage]);
|
||||
|
||||
const handleReply = useCallback((message: Message) => {
|
||||
consumeCurrentDeepLink();
|
||||
dismissMessage();
|
||||
setComposeRecipient({ id: message.fromId, type: message.fromType });
|
||||
setComposeReplyContext({
|
||||
messageId: message.id,
|
||||
preview: messagePreview(message.content, 120),
|
||||
});
|
||||
setShowComposer(true);
|
||||
}, [consumeCurrentDeepLink]);
|
||||
}, [dismissMessage]);
|
||||
|
||||
const handleMessageSent = useCallback(() => {
|
||||
const handleCloseComposer = useCallback(() => {
|
||||
consumeCurrentDeepLink();
|
||||
setShowComposer(false);
|
||||
setComposeRecipient(null);
|
||||
setComposeReplyContext(null);
|
||||
}, [consumeCurrentDeepLink]);
|
||||
|
||||
const dismissComposer = useCallback(() => {
|
||||
navigationHistory?.removeNav(handleCloseComposer);
|
||||
handleCloseComposer();
|
||||
}, [handleCloseComposer, navigationHistory]);
|
||||
|
||||
const handleMessageSent = useCallback(() => {
|
||||
dismissComposer();
|
||||
addToast?.("Message sent", "success");
|
||||
// Refresh current tab
|
||||
if (activeTab === "outbox") loadOutbox();
|
||||
else if (activeTab === "agents" && selectedAgentId === ALL_AGENTS_MAILBOX_ID) loadAllAgentsMailbox();
|
||||
else if (activeTab === "agents" && selectedAgentId) loadAgentMailbox(selectedAgentId);
|
||||
refreshUnreadCount();
|
||||
}, [activeTab, loadOutbox, selectedAgentId, loadAgentMailbox, loadAllAgentsMailbox, addToast, refreshUnreadCount]);
|
||||
}, [activeTab, loadOutbox, selectedAgentId, loadAgentMailbox, loadAllAgentsMailbox, addToast, refreshUnreadCount, dismissComposer]);
|
||||
|
||||
const handleOpenCompose = useCallback(() => {
|
||||
if (isMobile && selectedMessage) {
|
||||
dismissMessage();
|
||||
}
|
||||
consumeCurrentDeepLink();
|
||||
// Pre-fill recipient from selected agent if available
|
||||
if (activeTab === "agents" && selectedAgentId && selectedAgentId !== ALL_AGENTS_MAILBOX_ID) {
|
||||
@@ -751,14 +770,9 @@ export function MailboxView({
|
||||
}
|
||||
setComposeReplyContext(null);
|
||||
setShowComposer(true);
|
||||
}, [activeTab, selectedAgentId, consumeCurrentDeepLink]);
|
||||
}, [activeTab, selectedAgentId, consumeCurrentDeepLink, dismissMessage, isMobile, selectedMessage]);
|
||||
|
||||
const handleComposeCancel = useCallback(() => {
|
||||
consumeCurrentDeepLink();
|
||||
setShowComposer(false);
|
||||
setComposeRecipient(null);
|
||||
setComposeReplyContext(null);
|
||||
}, [consumeCurrentDeepLink]);
|
||||
const handleComposeCancel = dismissComposer;
|
||||
|
||||
const handleOpenApproval = useCallback(async (request: ApprovalRequestSummary) => {
|
||||
consumeCurrentDeepLink();
|
||||
@@ -771,6 +785,15 @@ export function MailboxView({
|
||||
}
|
||||
}, [projectId, addToast, consumeCurrentDeepLink]);
|
||||
|
||||
const handleCloseApproval = useCallback(() => {
|
||||
setSelectedApproval(null);
|
||||
}, []);
|
||||
|
||||
const dismissApproval = useCallback(() => {
|
||||
navigationHistory?.removeNav(handleCloseApproval);
|
||||
handleCloseApproval();
|
||||
}, [handleCloseApproval, navigationHistory]);
|
||||
|
||||
const handleApprovalDecision = useCallback(async (decision: "approve" | "deny") => {
|
||||
if (!selectedApproval || approvalDecisionLoading) return;
|
||||
setApprovalDecisionLoading(decision);
|
||||
@@ -788,6 +811,48 @@ export function MailboxView({
|
||||
}
|
||||
}, [selectedApproval, approvalDecisionLoading, approvalComment, projectId, loadApprovals, approvalSubTab, addToast]);
|
||||
|
||||
/*
|
||||
FNXC:MailboxMobile 2026-07-16-16:00:
|
||||
Mobile mailbox overlays must register modal history entries so iOS swipe-back,
|
||||
Android native Back, and browser Back dismiss the current overlay before leaving
|
||||
the mailbox. Programmatic closers remove their matching entries; nullable context
|
||||
keeps the provider-less MailboxView test and embedded renders operational.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isMobile || !selectedMessage || showComposer || !navigationHistory) return;
|
||||
navigationHistory.pushNav({ type: "modal", close: handleCloseMessage });
|
||||
}, [handleCloseMessage, isMobile, navigationHistory, selectedMessage, showComposer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile || !showComposer || !navigationHistory) return;
|
||||
navigationHistory.pushNav({ type: "modal", close: handleCloseComposer });
|
||||
}, [handleCloseComposer, isMobile, navigationHistory, showComposer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile || !selectedApproval || !navigationHistory) return;
|
||||
navigationHistory.pushNav({ type: "modal", close: handleCloseApproval });
|
||||
}, [handleCloseApproval, isMobile, navigationHistory, selectedApproval]);
|
||||
|
||||
const handleSelectTab = useCallback((tab: MailboxTab) => {
|
||||
consumeCurrentDeepLink();
|
||||
dismissMessage();
|
||||
dismissApproval();
|
||||
setActiveTab(tab);
|
||||
}, [consumeCurrentDeepLink, dismissApproval, dismissMessage]);
|
||||
|
||||
const handleAgentSelection = useCallback((agentId: string) => {
|
||||
consumeCurrentDeepLink();
|
||||
dismissMessage();
|
||||
setSelectedAgentId(agentId);
|
||||
setAgentSubTab("inbox");
|
||||
}, [consumeCurrentDeepLink, dismissMessage]);
|
||||
|
||||
const handleAgentSubTab = useCallback((tab: "inbox" | "outbox") => {
|
||||
consumeCurrentDeepLink();
|
||||
dismissMessage();
|
||||
setAgentSubTab(tab);
|
||||
}, [consumeCurrentDeepLink, dismissMessage]);
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────
|
||||
|
||||
const renderMessageDetail = () => {
|
||||
@@ -801,7 +866,7 @@ export function MailboxView({
|
||||
{isMobile && (
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={handleCloseMessage}
|
||||
onClick={dismissMessage}
|
||||
data-testid="mailbox-back-to-list"
|
||||
>
|
||||
← {t("mailbox.back", "Back")}
|
||||
@@ -993,14 +1058,14 @@ export function MailboxView({
|
||||
<div className="mailbox-approval-filters" data-testid="mailbox-approval-filters">
|
||||
<button
|
||||
className={`btn btn-sm btn-secondary mailbox-agent-subtab ${approvalSubTab === "pending" ? "active" : ""}`}
|
||||
onClick={() => { setApprovalSubTab("pending"); setSelectedApproval(null); }}
|
||||
onClick={() => { setApprovalSubTab("pending"); dismissApproval(); }}
|
||||
data-testid="mailbox-approval-filter-pending"
|
||||
>
|
||||
{t("mailbox.pending", "Pending")}
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-sm btn-secondary mailbox-agent-subtab ${approvalSubTab === "history" ? "active" : ""}`}
|
||||
onClick={() => { setApprovalSubTab("history"); setSelectedApproval(null); }}
|
||||
onClick={() => { setApprovalSubTab("history"); dismissApproval(); }}
|
||||
data-testid="mailbox-approval-filter-history"
|
||||
>
|
||||
{t("mailbox.history", "History")}
|
||||
@@ -1049,7 +1114,7 @@ export function MailboxView({
|
||||
<select
|
||||
className="message-composer-select mailbox-agent-select"
|
||||
value={selectedAgentId}
|
||||
onChange={(e) => { consumeCurrentDeepLink(); setSelectedAgentId(e.target.value); setAgentSubTab("inbox"); setSelectedMessage(null); }}
|
||||
onChange={(e) => handleAgentSelection(e.target.value)}
|
||||
data-testid="mailbox-agent-select"
|
||||
>
|
||||
<option value={ALL_AGENTS_MAILBOX_ID}>{t("mailbox.allAgents", "All agents")}</option>
|
||||
@@ -1074,7 +1139,7 @@ export function MailboxView({
|
||||
<div className="mailbox-agent-subtabs" data-testid="mailbox-agent-subtabs">
|
||||
<button
|
||||
className={`btn btn-sm btn-secondary mailbox-agent-subtab ${agentSubTab === "inbox" ? "active" : ""}`}
|
||||
onClick={() => { consumeCurrentDeepLink(); setAgentSubTab("inbox"); setSelectedMessage(null); }}
|
||||
onClick={() => handleAgentSubTab("inbox")}
|
||||
data-testid="mailbox-agent-subtab-inbox"
|
||||
>
|
||||
<InboxIcon size={12} />
|
||||
@@ -1085,7 +1150,7 @@ export function MailboxView({
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-sm btn-secondary mailbox-agent-subtab ${agentSubTab === "outbox" ? "active" : ""}`}
|
||||
onClick={() => { consumeCurrentDeepLink(); setAgentSubTab("outbox"); setSelectedMessage(null); }}
|
||||
onClick={() => handleAgentSubTab("outbox")}
|
||||
data-testid="mailbox-agent-subtab-outbox"
|
||||
>
|
||||
<Send size={12} />
|
||||
@@ -1213,7 +1278,7 @@ export function MailboxView({
|
||||
return (
|
||||
<div className="mailbox-message-detail mailbox-approval-detail" data-testid="mailbox-approval-detail">
|
||||
{isMobile && (
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => setSelectedApproval(null)} data-testid="mailbox-approval-back-to-list">← {t("mailbox.back", "Back")}</button>
|
||||
<button className="btn btn-sm btn-secondary" onClick={dismissApproval} data-testid="mailbox-approval-back-to-list">← {t("mailbox.back", "Back")}</button>
|
||||
)}
|
||||
<div className="mailbox-message-detail-header">
|
||||
<div className="mailbox-message-detail-meta">
|
||||
@@ -1342,7 +1407,7 @@ export function MailboxView({
|
||||
<div className="mailbox-tabs" data-testid="mailbox-tabs">
|
||||
<button
|
||||
className={`btn btn-sm btn-secondary mailbox-tab ${activeTab === "inbox" ? "active" : ""}`}
|
||||
onClick={() => { consumeCurrentDeepLink(); setActiveTab("inbox"); setSelectedMessage(null); setSelectedApproval(null); }}
|
||||
onClick={() => handleSelectTab("inbox")}
|
||||
data-testid="mailbox-tab-inbox"
|
||||
>
|
||||
<InboxIcon size={14} />
|
||||
@@ -1351,7 +1416,7 @@ export function MailboxView({
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-sm btn-secondary mailbox-tab ${activeTab === "outbox" ? "active" : ""}`}
|
||||
onClick={() => { consumeCurrentDeepLink(); setActiveTab("outbox"); setSelectedMessage(null); setSelectedApproval(null); }}
|
||||
onClick={() => handleSelectTab("outbox")}
|
||||
data-testid="mailbox-tab-outbox"
|
||||
>
|
||||
<Send size={14} />
|
||||
@@ -1359,7 +1424,7 @@ export function MailboxView({
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-sm btn-secondary mailbox-tab ${activeTab === "agents" ? "active" : ""}`}
|
||||
onClick={() => { consumeCurrentDeepLink(); setActiveTab("agents"); setSelectedMessage(null); setSelectedApproval(null); }}
|
||||
onClick={() => handleSelectTab("agents")}
|
||||
data-testid="mailbox-tab-agents"
|
||||
>
|
||||
<Bot size={14} />
|
||||
@@ -1367,7 +1432,7 @@ export function MailboxView({
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-sm btn-secondary mailbox-tab ${activeTab === "approvals" ? "active" : ""}`}
|
||||
onClick={() => { consumeCurrentDeepLink(); setActiveTab("approvals"); setSelectedMessage(null); setSelectedApproval(null); }}
|
||||
onClick={() => handleSelectTab("approvals")}
|
||||
data-testid="mailbox-tab-approvals"
|
||||
>
|
||||
<CheckCheck size={14} />
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { loadAllAppCss } from "../../test/cssFixture";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import { MailboxView } from "../MailboxView";
|
||||
import { NavigationHistoryProvider, useNavigationHistory, type UseNavigationHistoryResult } from "../../hooks/useNavigationHistory";
|
||||
import * as apiModule from "../../api";
|
||||
import * as viewportModule from "../../hooks/useViewportMode";
|
||||
import * as mobileKeyboardModule from "../../hooks/useMobileKeyboard";
|
||||
@@ -175,6 +177,14 @@ function makeOutboxResponse(messages: Message[]) {
|
||||
return { messages, total: messages.length };
|
||||
}
|
||||
|
||||
function HistoryHarness({ children, historyRef }: { children: ReactNode; historyRef?: { current: UseNavigationHistoryResult | null } }) {
|
||||
const history = useNavigationHistory({ enabled: true });
|
||||
useEffect(() => {
|
||||
if (historyRef) historyRef.current = history;
|
||||
}, [history, historyRef]);
|
||||
return <NavigationHistoryProvider value={history}>{children}</NavigationHistoryProvider>;
|
||||
}
|
||||
|
||||
describe("MailboxView", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
@@ -1007,6 +1017,144 @@ describe("MailboxView", () => {
|
||||
expect(mailboxView.getAttribute("style")).toContain("--vv-height: 480px");
|
||||
});
|
||||
|
||||
it("dismisses a mobile message detail on browser popstate and drains its nav entry", async () => {
|
||||
mockUseViewportMode.mockReturnValue("mobile");
|
||||
mockFetchInbox.mockResolvedValue(makeInboxResponse([mockMessage], 1));
|
||||
mockFetchConversation.mockResolvedValue([mockMessage]);
|
||||
mockMarkMessageRead.mockResolvedValue({ ...mockMessage, read: true });
|
||||
|
||||
render(<HistoryHarness><MailboxView {...defaultProps} /></HistoryHarness>);
|
||||
await screen.findByTestId("mailbox-item-msg-001");
|
||||
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
|
||||
await screen.findByTestId("mailbox-message-detail");
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("mailbox-message-detail")).toBeNull();
|
||||
expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
|
||||
});
|
||||
expect(screen.queryByTestId("mailbox-message-detail")).toBeNull();
|
||||
});
|
||||
|
||||
it("routes Android native Back through popstate before dismissing a mobile message", async () => {
|
||||
mockUseViewportMode.mockReturnValue("mobile");
|
||||
mockFetchInbox.mockResolvedValue(makeInboxResponse([mockMessage], 1));
|
||||
mockFetchConversation.mockResolvedValue([mockMessage]);
|
||||
mockMarkMessageRead.mockResolvedValue({ ...mockMessage, read: true });
|
||||
const backSpy = vi.spyOn(window.history, "back").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
render(<HistoryHarness><MailboxView {...defaultProps} /></HistoryHarness>);
|
||||
await screen.findByTestId("mailbox-item-msg-001");
|
||||
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
|
||||
await screen.findByTestId("mailbox-message-detail");
|
||||
|
||||
const nativeBack = new CustomEvent("fusion:native-back", { cancelable: true });
|
||||
expect(window.dispatchEvent(nativeBack)).toBe(false);
|
||||
expect(backSpy).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId("mailbox-message-detail")).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByTestId("mailbox-message-detail")).toBeNull());
|
||||
} finally {
|
||||
backSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["the in-pane Back button", async () => fireEvent.click(screen.getByTestId("mailbox-back-to-list"))],
|
||||
["delete", async () => fireEvent.click(screen.getByTestId("mailbox-delete"))],
|
||||
["an agent tab switch", async () => fireEvent.click(screen.getByTestId("mailbox-tab-outbox"))],
|
||||
])("consumes the message entry before %s", async (_label, close) => {
|
||||
mockUseViewportMode.mockReturnValue("mobile");
|
||||
mockFetchInbox.mockResolvedValue(makeInboxResponse([mockMessage], 1));
|
||||
mockFetchConversation.mockResolvedValue([mockMessage]);
|
||||
mockMarkMessageRead.mockResolvedValue({ ...mockMessage, read: true });
|
||||
mockDeleteMessage.mockResolvedValue(undefined);
|
||||
const historyRef: { current: UseNavigationHistoryResult | null } = { current: null };
|
||||
const sentinel = vi.fn();
|
||||
const backSpy = vi.spyOn(window.history, "back").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
render(<HistoryHarness historyRef={historyRef}><MailboxView {...defaultProps} /></HistoryHarness>);
|
||||
await screen.findByTestId("mailbox-item-msg-001");
|
||||
await waitFor(() => expect(historyRef.current).not.toBeNull());
|
||||
historyRef.current?.pushNav({ type: "modal", close: sentinel });
|
||||
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
|
||||
await screen.findByTestId("mailbox-message-detail");
|
||||
|
||||
await close();
|
||||
await waitFor(() => expect(screen.queryByTestId("mailbox-message-detail")).toBeNull());
|
||||
expect(backSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 1 } }));
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
|
||||
});
|
||||
expect(sentinel).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
backSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("consumes the message entry before reply replaces it with the composer", async () => {
|
||||
mockUseViewportMode.mockReturnValue("mobile");
|
||||
mockFetchInbox.mockResolvedValue(makeInboxResponse([mockMessage], 1));
|
||||
mockFetchConversation.mockResolvedValue([mockMessage]);
|
||||
mockMarkMessageRead.mockResolvedValue({ ...mockMessage, read: true });
|
||||
const historyRef: { current: UseNavigationHistoryResult | null } = { current: null };
|
||||
const sentinel = vi.fn();
|
||||
const backSpy = vi.spyOn(window.history, "back").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
render(<HistoryHarness historyRef={historyRef}><MailboxView {...defaultProps} /></HistoryHarness>);
|
||||
await screen.findByTestId("mailbox-item-msg-001");
|
||||
await waitFor(() => expect(historyRef.current).not.toBeNull());
|
||||
historyRef.current?.pushNav({ type: "modal", close: sentinel });
|
||||
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
|
||||
await screen.findByTestId("mailbox-message-detail");
|
||||
fireEvent.click(screen.getByTestId("mailbox-reply"));
|
||||
await screen.findByTestId("message-composer");
|
||||
fireEvent.click(screen.getByTestId("message-composer-cancel"));
|
||||
await waitFor(() => expect(screen.queryByTestId("message-composer")).toBeNull());
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 1 } }));
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
|
||||
});
|
||||
expect(sentinel).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
backSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not push a nav entry for desktop split-pane message selection", async () => {
|
||||
mockUseViewportMode.mockReturnValue("desktop");
|
||||
mockFetchInbox.mockResolvedValue(makeInboxResponse([mockMessage], 1));
|
||||
mockFetchConversation.mockResolvedValue([mockMessage]);
|
||||
mockMarkMessageRead.mockResolvedValue({ ...mockMessage, read: true });
|
||||
const pushStateSpy = vi.spyOn(window.history, "pushState");
|
||||
|
||||
try {
|
||||
render(<HistoryHarness><MailboxView {...defaultProps} /></HistoryHarness>);
|
||||
await screen.findByTestId("mailbox-item-msg-001");
|
||||
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
|
||||
await screen.findByTestId("mailbox-message-detail");
|
||||
expect(pushStateSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
pushStateSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps mobile single-pane flow for detail open and back navigation", async () => {
|
||||
mockUseViewportMode.mockReturnValue("mobile");
|
||||
mockFetchInbox.mockResolvedValue({
|
||||
|
||||
Reference in New Issue
Block a user