feat(FN-3750): stabilize mailbox mobile keyboard and wake handling
- Wire mobile keyboard viewport signals into mailbox view and modal containers for anchored mobile layout - Keep reply composer focused and visible while keyboard is open, with updated component styles and regression tests - Make messaging wake dispatch fire-and-forget to avoid request hangs and expand route tests for async behavior - Add desktop vitest workspace aliases for @fusion/* imports and include FN-3750/FN-3751 changesets Fusion-Task-Id: FN-3750
This commit is contained in:
5
.changeset/FN-3750-mobile-mailbox-keyboard.md
Normal file
5
.changeset/FN-3750-mobile-mailbox-keyboard.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix mobile mailbox reply: anchor MailboxView/MailboxModal to the visual viewport so the message composer stays visible when the on-screen keyboard appears.
|
||||
@@ -656,6 +656,20 @@
|
||||
padding-bottom: calc(var(--space-md) + env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap));
|
||||
}
|
||||
|
||||
.mailbox-view[style*="--keyboard-overlap"],
|
||||
.mailbox-modal[style*="--keyboard-overlap"] {
|
||||
height: var(--vv-height, 100dvh);
|
||||
max-height: var(--vv-height, 100dvh);
|
||||
transform: translateY(var(--vv-offset-top, 0px));
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.mailbox-view[style*="--keyboard-overlap"] .mailbox-content,
|
||||
.mailbox-modal[style*="--keyboard-overlap"] .mailbox-content {
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.mailbox-modal .mailbox-message-detail-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
@@ -736,6 +750,10 @@
|
||||
padding-bottom: calc(var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap) + var(--space-lg));
|
||||
}
|
||||
|
||||
.mailbox-view[style*="--keyboard-overlap"] .mailbox-content {
|
||||
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + var(--space-md));
|
||||
}
|
||||
|
||||
.mailbox-view .mailbox-split-layout {
|
||||
display: block;
|
||||
height: auto;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "./MailboxModal.css";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo, type CSSProperties } from "react";
|
||||
import {
|
||||
X,
|
||||
Mail,
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
import { MessageComposer } from "./MessageComposer";
|
||||
import type { Agent } from "../api";
|
||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||
import { useViewportMode } from "./Header";
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────
|
||||
@@ -142,6 +144,20 @@ export function MailboxModal({
|
||||
const [replyContextLoading, setReplyContextLoading] = useState<Record<string, boolean>>({});
|
||||
const [replyContextErrors, setReplyContextErrors] = useState<Record<string, string>>({});
|
||||
const [replyContextCache, setReplyContextCache] = useState<Map<string, Message>>(new Map());
|
||||
const viewportMode = useViewportMode();
|
||||
const isMobile = viewportMode === "mobile";
|
||||
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({ enabled: isMobile });
|
||||
const containerKeyboardStyle = useMemo<CSSProperties | undefined>(() => {
|
||||
if (!keyboardOpen) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
"--keyboard-overlap": `${keyboardOverlap}px`,
|
||||
"--vv-offset-top": `${viewportOffsetTop}px`,
|
||||
...(viewportHeight != null ? { "--vv-height": `${viewportHeight}px` } : {}),
|
||||
} as CSSProperties;
|
||||
}, [keyboardOpen, keyboardOverlap, viewportHeight, viewportOffsetTop]);
|
||||
|
||||
// ── Data fetching ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -479,7 +495,7 @@ export function MailboxModal({
|
||||
aria-modal="true"
|
||||
data-testid="mailbox-modal-overlay"
|
||||
>
|
||||
<div className="modal modal-lg mailbox-modal" data-testid="mailbox-modal">
|
||||
<div className="modal modal-lg mailbox-modal" style={containerKeyboardStyle} data-testid="mailbox-modal">
|
||||
{/* Header */}
|
||||
<div className="modal-header mailbox-header">
|
||||
<div className="mailbox-title">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "./MailboxModal.css";
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo, type CSSProperties } from "react";
|
||||
import {
|
||||
Mail,
|
||||
Send,
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { MessageComposer } from "./MessageComposer";
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
import { useViewportMode } from "../hooks/useViewportMode";
|
||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -155,6 +156,18 @@ export function MailboxView({
|
||||
const viewportMode = useViewportMode();
|
||||
const isMobile = viewportMode === "mobile";
|
||||
const isSplitPane = !isMobile;
|
||||
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({ enabled: isMobile });
|
||||
const containerKeyboardStyle = useMemo<CSSProperties | undefined>(() => {
|
||||
if (!keyboardOpen) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
"--keyboard-overlap": `${keyboardOverlap}px`,
|
||||
"--vv-offset-top": `${viewportOffsetTop}px`,
|
||||
...(viewportHeight != null ? { "--vv-height": `${viewportHeight}px` } : {}),
|
||||
} as CSSProperties;
|
||||
}, [keyboardOpen, keyboardOverlap, viewportHeight, viewportOffsetTop]);
|
||||
|
||||
// ── Data fetching ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -714,7 +727,7 @@ export function MailboxView({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mailbox-view" data-testid="mailbox-view">
|
||||
<div className="mailbox-view" style={containerKeyboardStyle} data-testid="mailbox-view">
|
||||
{/* Header */}
|
||||
<div className="mailbox-header">
|
||||
<div className="mailbox-title">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
||||
import { X, Send, Loader2, Bot, AlertCircle } from "lucide-react";
|
||||
import type { ParticipantType, MessageType } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
@@ -46,6 +46,7 @@ export function MessageComposer({
|
||||
const [wakeRecipient, setWakeRecipient] = useState(false);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const selectedAgent = useMemo(() => agents.find((agent) => agent.id === toId), [agents, toId]);
|
||||
const recipientIsAgent = toType === "agent";
|
||||
@@ -93,6 +94,29 @@ export function MessageComposer({
|
||||
setToType("agent");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!replyContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
textareaRef.current?.focus();
|
||||
}, [replyContext]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!replyContext || typeof window === "undefined" || window.visualViewport == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleVisualViewportResize = () => {
|
||||
textareaRef.current?.scrollIntoView({ block: "center", behavior: "auto" });
|
||||
};
|
||||
|
||||
window.visualViewport.addEventListener("resize", handleVisualViewportResize);
|
||||
return () => {
|
||||
window.visualViewport?.removeEventListener("resize", handleVisualViewportResize);
|
||||
};
|
||||
}, [replyContext]);
|
||||
|
||||
return (
|
||||
<div className="message-composer" data-testid="message-composer">
|
||||
<div className="message-composer-header">
|
||||
@@ -161,6 +185,7 @@ export function MessageComposer({
|
||||
</label>
|
||||
<textarea
|
||||
id="message-content"
|
||||
ref={textareaRef}
|
||||
className="message-composer-textarea"
|
||||
placeholder="Type your message…"
|
||||
value={content}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { loadAllAppCss } from "../../test/cssFixture";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import { MailboxModal } from "../MailboxModal";
|
||||
import * as apiModule from "../../api";
|
||||
import * as mobileKeyboardModule from "../../hooks/useMobileKeyboard";
|
||||
import type { Agent } from "../../api";
|
||||
import type { Message } from "@fusion/core";
|
||||
|
||||
@@ -20,6 +21,14 @@ vi.mock("../../api", () => ({
|
||||
sendMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useMobileKeyboard", () => ({
|
||||
useMobileKeyboard: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../Header", () => ({
|
||||
useViewportMode: vi.fn(() => "mobile"),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock("lucide-react", () => ({
|
||||
X: () => <span data-testid="icon-x">X</span>,
|
||||
@@ -51,6 +60,7 @@ const mockDeleteMessage = vi.mocked(apiModule.deleteMessage);
|
||||
const mockFetchConversation = vi.mocked(apiModule.fetchConversation);
|
||||
const mockFetchMessage = vi.mocked(apiModule.fetchMessage);
|
||||
const mockSendMessage = vi.mocked(apiModule.sendMessage);
|
||||
const mockUseMobileKeyboard = vi.mocked(mobileKeyboardModule.useMobileKeyboard);
|
||||
|
||||
const mockAgents: Agent[] = [
|
||||
{
|
||||
@@ -117,6 +127,12 @@ const defaultProps = {
|
||||
describe("MailboxModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseMobileKeyboard.mockReturnValue({
|
||||
keyboardOverlap: 0,
|
||||
viewportHeight: null,
|
||||
viewportOffsetTop: 0,
|
||||
keyboardOpen: false,
|
||||
});
|
||||
mockFetchInbox.mockResolvedValue({ messages: [mockMessage, mockReadMessage], total: 2, unreadCount: 1 });
|
||||
mockFetchOutbox.mockResolvedValue({ messages: [], total: 0 });
|
||||
mockFetchUnreadCount.mockResolvedValue({ unreadCount: 1 });
|
||||
@@ -138,6 +154,21 @@ describe("MailboxModal", () => {
|
||||
expect(screen.getByTestId("mailbox-modal")).toBeDefined();
|
||||
});
|
||||
|
||||
it("applies visual viewport CSS variables when mobile keyboard is open", async () => {
|
||||
mockUseMobileKeyboard.mockReturnValue({
|
||||
keyboardOverlap: 220,
|
||||
viewportHeight: 460,
|
||||
viewportOffsetTop: 28,
|
||||
keyboardOpen: true,
|
||||
});
|
||||
|
||||
render(<MailboxModal {...defaultProps} />);
|
||||
|
||||
const modal = await screen.findByTestId("mailbox-modal");
|
||||
expect(modal.getAttribute("style")).toContain("--vv-offset-top: 28px");
|
||||
expect(modal.getAttribute("style")).toContain("--vv-height: 460px");
|
||||
});
|
||||
|
||||
it("shows the Mailbox title with unread count badge", async () => {
|
||||
render(<MailboxModal {...defaultProps} />);
|
||||
expect(screen.getByText("Mailbox")).toBeDefined();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"
|
||||
import { MailboxView } from "../MailboxView";
|
||||
import * as apiModule from "../../api";
|
||||
import * as viewportModule from "../../hooks/useViewportMode";
|
||||
import * as mobileKeyboardModule from "../../hooks/useMobileKeyboard";
|
||||
import type { Agent } from "../../api";
|
||||
import type { Message } from "@fusion/core";
|
||||
|
||||
@@ -25,6 +26,10 @@ vi.mock("../../hooks/useViewportMode", () => ({
|
||||
useViewportMode: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useMobileKeyboard", () => ({
|
||||
useMobileKeyboard: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock("lucide-react", () => ({
|
||||
X: () => <span data-testid="icon-x">X</span>,
|
||||
@@ -55,6 +60,7 @@ const mockDeleteMessage = vi.mocked(apiModule.deleteMessage);
|
||||
const mockFetchConversation = vi.mocked(apiModule.fetchConversation);
|
||||
const mockSendMessage = vi.mocked(apiModule.sendMessage);
|
||||
const mockUseViewportMode = vi.mocked(viewportModule.useViewportMode);
|
||||
const mockUseMobileKeyboard = vi.mocked(mobileKeyboardModule.useMobileKeyboard);
|
||||
|
||||
const mockAgents: Agent[] = [
|
||||
{
|
||||
@@ -148,6 +154,12 @@ describe("MailboxView", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseViewportMode.mockReturnValue("desktop");
|
||||
mockUseMobileKeyboard.mockReturnValue({
|
||||
keyboardOverlap: 0,
|
||||
viewportHeight: null,
|
||||
viewportOffsetTop: 0,
|
||||
keyboardOpen: false,
|
||||
});
|
||||
mockFetchUnreadCount.mockResolvedValue({ unreadCount: 2 });
|
||||
mockFetchAgents.mockResolvedValue(mockAgents);
|
||||
mockSendMessage.mockResolvedValue({ ...mockMessage, id: "msg-sent" });
|
||||
@@ -453,6 +465,27 @@ describe("MailboxView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("applies visual viewport CSS variables when mobile keyboard is open", async () => {
|
||||
mockUseViewportMode.mockReturnValue("mobile");
|
||||
mockUseMobileKeyboard.mockReturnValue({
|
||||
keyboardOverlap: 240,
|
||||
viewportHeight: 480,
|
||||
viewportOffsetTop: 32,
|
||||
keyboardOpen: true,
|
||||
});
|
||||
mockFetchInbox.mockResolvedValue({
|
||||
messages: [],
|
||||
unreadCount: 0,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
render(<MailboxView {...defaultProps} />);
|
||||
|
||||
const mailboxView = await screen.findByTestId("mailbox-view");
|
||||
expect(mailboxView.getAttribute("style")).toContain("--vv-offset-top: 32px");
|
||||
expect(mailboxView.getAttribute("style")).toContain("--vv-height: 480px");
|
||||
});
|
||||
|
||||
it("keeps mobile single-pane flow for detail open and back navigation", async () => {
|
||||
mockUseViewportMode.mockReturnValue("mobile");
|
||||
mockFetchInbox.mockResolvedValue({
|
||||
@@ -1388,6 +1421,15 @@ describe("MailboxView", () => {
|
||||
expect(css).not.toMatch(/\.mailbox-agent-subtab\s*\{[^}]*background:\s*transparent;[^}]*\}/);
|
||||
});
|
||||
|
||||
it("includes keyboard-overlap viewport anchoring rules for mailbox containers", async () => {
|
||||
const css = loadAllAppCss();
|
||||
|
||||
expect(css).toContain('.mailbox-view[style*="--keyboard-overlap"],');
|
||||
expect(css).toContain('.mailbox-modal[style*="--keyboard-overlap"]');
|
||||
expect(css).toContain("height: var(--vv-height, 100dvh);");
|
||||
expect(css).toContain("transform: translateY(var(--vv-offset-top, 0px));");
|
||||
});
|
||||
|
||||
it("renders structural elements that mobile CSS targets", async () => {
|
||||
mockFetchInbox.mockResolvedValue({
|
||||
messages: [mockMessage],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { MessageComposer } from "../MessageComposer";
|
||||
import * as apiModule from "../../api";
|
||||
@@ -41,6 +41,8 @@ const defaultProps = {
|
||||
};
|
||||
|
||||
describe("MessageComposer", () => {
|
||||
const originalVisualViewport = window.visualViewport;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSendMessage.mockResolvedValue({
|
||||
@@ -57,6 +59,14 @@ describe("MessageComposer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
configurable: true,
|
||||
value: originalVisualViewport,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the composer with header", () => {
|
||||
render(<MessageComposer {...defaultProps} />);
|
||||
expect(screen.getByText("New Message")).toBeDefined();
|
||||
@@ -171,6 +181,80 @@ describe("MessageComposer", () => {
|
||||
expect(defaultProps.onCancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("auto-focuses textarea when reply context is provided", () => {
|
||||
render(
|
||||
<MessageComposer
|
||||
{...defaultProps}
|
||||
recipient={{ id: "agent-001", type: "agent" }}
|
||||
replyContext={{ messageId: "m1", preview: "Previous" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(document.activeElement).toBe(screen.getByTestId("message-composer-content"));
|
||||
});
|
||||
|
||||
it("scrolls textarea into view on visualViewport resize when replying", () => {
|
||||
const addEventListener = vi.fn();
|
||||
const removeEventListener = vi.fn();
|
||||
let resizeHandler: (() => void) | undefined;
|
||||
|
||||
addEventListener.mockImplementation((event: string, handler: () => void) => {
|
||||
if (event === "resize") {
|
||||
resizeHandler = handler;
|
||||
}
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
configurable: true,
|
||||
value: {
|
||||
addEventListener,
|
||||
removeEventListener,
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
if (!("scrollIntoView" in HTMLElement.prototype)) {
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: () => undefined,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
const scrollIntoViewSpy = vi.spyOn(HTMLElement.prototype, "scrollIntoView").mockImplementation(() => undefined);
|
||||
|
||||
render(
|
||||
<MessageComposer
|
||||
{...defaultProps}
|
||||
recipient={{ id: "agent-001", type: "agent" }}
|
||||
replyContext={{ messageId: "m1", preview: "Previous" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(addEventListener).toHaveBeenCalledWith("resize", expect.any(Function));
|
||||
resizeHandler?.();
|
||||
expect(scrollIntoViewSpy).toHaveBeenCalledWith({ block: "center", behavior: "auto" });
|
||||
|
||||
scrollIntoViewSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("does not throw when visualViewport is unavailable", () => {
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<MessageComposer
|
||||
{...defaultProps}
|
||||
recipient={{ id: "agent-001", type: "agent" }}
|
||||
replyContext={{ messageId: "m1", preview: "Previous" }}
|
||||
/>,
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("pre-fills recipient when provided", () => {
|
||||
render(
|
||||
<MessageComposer
|
||||
|
||||
Reference in New Issue
Block a user