diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index fd56ee8ca5ac..75cda4778548 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -33,6 +33,8 @@ import { } from "../panelAnimations"; import LegacyThreadSidebar from "./LegacySidebar"; import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration"; +import { useIsPhoneViewport } from "../hooks/useMediaQuery"; +import { useVisualViewportSync } from "../hooks/useVisualViewportSync"; import ThreadSidebar from "./Sidebar"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; import { SidebarBrandWidthProbe, SidebarChromeHeader } from "./sidebar/SidebarChrome"; @@ -236,6 +238,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { // sidebar is active. // Seeds server-side visited tracking from this browser's localStorage the useThreadVisitedMigration(); + useVisualViewportSync(useIsPhoneViewport()); const pathname = useLocation({ select: (location) => location.pathname }); const panelAnimationsSuppressed = usePanelNavigationSuppression(pathname); const routePanelAnimationsActive = panelAnimationsActive && !panelAnimationsSuppressed; @@ -314,7 +317,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { return (
diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index ae05529374c3..1cc60d53dfac 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -57,7 +57,7 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({ > {backdropVariant ? : null} {/* Match the fixed control's inset and border compensation inside the sheet's safe area. */} -
+
{ + let renderer: ReactTestRenderer | null; + let viewport: { width: number; height: number; coarse: boolean }; + let orientation: (EventTarget & { type: string }) | undefined; + let queries: Array; + + function evaluate(media: string): boolean { + if (media === "(orientation: portrait)") return viewport.height >= viewport.width; + return viewport.width <= 639 && viewport.coarse; + } + + function Probe() { + return createElement("output", { value: String(useIsPhoneViewport()) }); + } + + function phone(): boolean { + return renderer?.root.findByType("output").props.value === "true"; + } + + async function render() { + await act(() => { + renderer = create(createElement(Probe)); + }); + } + + async function rotate(width: number, height: number, type?: string) { + await act(() => { + Object.assign(viewport, { width, height }); + for (const query of queries) { + query.matches = evaluate(query.media); + query.dispatchEvent(new Event("change")); + } + if (orientation && type) { + orientation.type = type; + orientation.dispatchEvent(new Event("change")); + } + }); + } + + beforeEach(() => { + renderer = null; + viewport = { width: 390, height: 844, coarse: true }; + orientation = Object.assign(new EventTarget(), { type: "portrait-primary" }); + queries = []; + vi.stubGlobal("window", { + get screen() { + return { orientation }; + }, + matchMedia: (media: string) => { + const query = Object.assign(new EventTarget(), { media, matches: evaluate(media) }); + queries.push(query); + return query; + }, + }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + }); + + afterEach(async () => { + await act(() => renderer?.unmount()); + vi.unstubAllGlobals(); + }); + + it("matches a portrait touch phone", async () => { + await render(); + expect(phone()).toBe(true); + }); + + it("does not match a narrow window with a mouse", async () => { + viewport.coarse = false; + await render(); + expect(phone()).toBe(false); + }); + + it("turns off for a small phone on its side and back on when it is upright", async () => { + viewport = { width: 360, height: 600, coarse: true }; + await render(); + await rotate(600, 360, "landscape-primary"); + expect(phone()).toBe(false); + await rotate(360, 600, "portrait-primary"); + expect(phone()).toBe(true); + }); + + it("stays on when a resizes-content keyboard makes the viewport wider than tall", async () => { + viewport = { width: 412, height: 915, coarse: true }; + await render(); + await rotate(412, 380); + expect(phone()).toBe(true); + }); + + it("falls back to the orientation query without screen.orientation", async () => { + orientation = undefined; + viewport = { width: 360, height: 600, coarse: true }; + await render(); + expect(phone()).toBe(true); + await rotate(600, 360); + expect(phone()).toBe(false); + }); +}); diff --git a/apps/web/src/hooks/useMediaQuery.ts b/apps/web/src/hooks/useMediaQuery.ts index e7e007b0b7c6..47a92b36b056 100644 --- a/apps/web/src/hooks/useMediaQuery.ts +++ b/apps/web/src/hooks/useMediaQuery.ts @@ -85,3 +85,35 @@ export function useMediaQuery(query: BreakpointQuery | MediaQueryInput | (string export function useIsMobile(): boolean { return useMediaQuery("max-md"); } + +function subscribeToScreenOrientation(onChange: () => void): () => void { + const orientation = window.screen?.orientation; + if (orientation) { + orientation.addEventListener("change", onChange); + return () => orientation.removeEventListener("change", onChange); + } + const mql = window.matchMedia("(orientation: portrait)"); + mql.addEventListener("change", onChange); + return () => mql.removeEventListener("change", onChange); +} + +/** The device's orientation, not the viewport's: under interactive-widget=resizes-content an + Android keyboard shrinks the viewport until it is wider than tall, which an + `(orientation: portrait)` query would read as landscape mid-typing. Safari before 16.4 has no + screen.orientation, and iOS never resizes the layout viewport, so the query is safe there. */ +function readScreenIsPortrait(): boolean { + const type = window.screen?.orientation?.type; + if (type) return type.startsWith("portrait"); + return window.matchMedia("(orientation: portrait)").matches; +} + +/** A portrait touch phone, as opposed to a narrow desktop window or a small phone on its side. */ +export function useIsPhoneViewport(): boolean { + const isPhoneSized = useMediaQuery("(max-width: 639px) and (pointer: coarse)"); + const isPortrait = useSyncExternalStore( + subscribeToScreenOrientation, + readScreenIsPortrait, + getServerSnapshot, + ); + return isPhoneSized && isPortrait; +} diff --git a/apps/web/src/hooks/useVisualViewportSync.test.ts b/apps/web/src/hooks/useVisualViewportSync.test.ts new file mode 100644 index 000000000000..9b9c31e6e81a --- /dev/null +++ b/apps/web/src/hooks/useVisualViewportSync.test.ts @@ -0,0 +1,171 @@ +import { act, createElement } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { measureVisualViewport, useVisualViewportSync } from "./useVisualViewportSync"; + +describe("measureVisualViewport", () => { + it("reports no keyboard when the visible area fills the layout viewport", () => { + expect(measureVisualViewport(844, { height: 844, offsetTop: 0, scale: 1 })).toEqual({ + height: 844, + offsetTop: 0, + keyboardInset: 0, + keyboardOpen: false, + }); + }); + + it("measures an iOS keyboard that overlays the page without panning", () => { + expect(measureVisualViewport(844, { height: 508, offsetTop: 0, scale: 1 })).toEqual({ + height: 508, + offsetTop: 0, + keyboardInset: 336, + keyboardOpen: true, + }); + }); + + it("keeps the shell on the visible area after iOS pans to reveal the field", () => { + expect(measureVisualViewport(844, { height: 508, offsetTop: 336, scale: 1 })).toEqual({ + height: 508, + offsetTop: 336, + keyboardInset: 0, + keyboardOpen: true, + }); + }); + + it("treats a Chromium resizes-content keyboard as a plain smaller viewport", () => { + expect(measureVisualViewport(444, { height: 444, offsetTop: 0, scale: 1 })).toMatchObject({ + height: 444, + keyboardInset: 0, + }); + }); + + it("ignores toolbar collapse that is too small to be a keyboard", () => { + expect(measureVisualViewport(844, { height: 770, offsetTop: 0, scale: 1 })?.keyboardOpen).toBe( + false, + ); + }); + + it("stands down while the user is pinch-zoomed", () => { + expect(measureVisualViewport(844, { height: 300, offsetTop: 120, scale: 2 })).toBeNull(); + }); +}); + +describe("useVisualViewportSync lifecycle", () => { + let renderer: ReactTestRenderer | null; + let viewport: EventTarget & { height: number; offsetTop: number; scale: number }; + let browser: EventTarget & { + innerHeight: number; + innerWidth: number; + visualViewport: typeof viewport; + }; + let root: { style: CSSStyleDeclaration; dataset: DOMStringMap }; + + function Probe({ enabled }: { enabled: boolean }) { + useVisualViewportSync(enabled); + return null; + } + + async function render(enabled = true) { + await act(() => { + if (renderer) renderer.update(createElement(Probe, { enabled })); + else renderer = create(createElement(Probe, { enabled })); + }); + } + + function resize(height: number, offsetTop = 0, layoutHeight = 844) { + Object.assign(viewport, { height, offsetTop }); + browser.innerHeight = layoutHeight; + viewport.dispatchEvent(new Event("resize")); + browser.dispatchEvent(new Event("resize")); + } + + beforeEach(() => { + renderer = null; + viewport = Object.assign(new EventTarget(), { height: 844, offsetTop: 0, scale: 1 }); + browser = Object.assign(new EventTarget(), { + innerHeight: 844, + innerWidth: 390, + visualViewport: viewport, + }); + const properties = new Map(); + root = { + style: { + setProperty: (key: string, value: string) => properties.set(key, value), + removeProperty: (key: string) => properties.delete(key), + getPropertyValue: (key: string) => properties.get(key) ?? "", + } as unknown as CSSStyleDeclaration, + dataset: {}, + }; + vi.stubGlobal("window", browser); + vi.stubGlobal("document", { documentElement: root }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + }); + + afterEach(async () => { + await act(() => renderer?.unmount()); + vi.unstubAllGlobals(); + }); + + it.each(["overlay", "resize"])("survives repeated %s keyboard cycles", async (mode) => { + await render(); + for (let cycle = 0; cycle < 5; cycle++) { + resize(444, mode === "overlay" ? 200 : 0, mode === "resize" ? 444 : 844); + expect(root.dataset.keyboardOpen).toBe(mode === "overlay" ? "true" : undefined); + expect(root.style.getPropertyValue("--app-viewport-height")).toBe("444px"); + resize(844); + expect(root.dataset.keyboardOpen).toBeUndefined(); + expect(root.style.getPropertyValue("--app-viewport-offset-top")).toBe("0px"); + } + }); + + it("follows rotation with a Chromium keyboard already open", async () => { + await render(); + resize(444, 0, 444); + browser.innerWidth = 844; + resize(190, 0, 190); + expect(root.style.getPropertyValue("--app-viewport-height")).toBe("190px"); + expect(root.style.getPropertyValue("--app-viewport-offset-top")).toBe("0px"); + resize(390, 0, 390); + expect(root.style.getPropertyValue("--app-viewport-height")).toBe("390px"); + expect(root.dataset.keyboardOpen).toBeUndefined(); + }); + + it("follows a Safari pan that only fires a visual viewport scroll", async () => { + await render(); + resize(508); + viewport.offsetTop = 336; + viewport.dispatchEvent(new Event("scroll")); + expect(root.style.getPropertyValue("--app-viewport-offset-top")).toBe("336px"); + expect(root.style.getPropertyValue("--keyboard-inset")).toBe("0px"); + }); + + it("updates keyboard state even when the published geometry does not change", async () => { + browser.innerHeight = 600; + Object.assign(viewport, { height: 500, offsetTop: 200 }); + await render(); + expect(root.dataset.keyboardOpen).toBeUndefined(); + resize(500, 200, 650); + expect(root.dataset.keyboardOpen).toBe("true"); + }); + + it("releases the document lock for zoom, route unmount, and desktop", async () => { + await render(); + viewport.scale = 2; + resize(300, 120); + expect(root.dataset.viewportSync).toBeUndefined(); + expect(root.style.getPropertyValue("--app-viewport-height")).toBe(""); + viewport.scale = 1; + resize(508, 336); + expect(root.dataset.viewportSync).toBe("true"); + await render(false); + expect(root.dataset).toEqual({}); + resize(444); + expect(root.dataset).toEqual({}); + await render(); + await act(() => renderer?.unmount()); + renderer = null; + resize(844); + expect(root.dataset).toEqual({}); + expect(root.style.getPropertyValue("--app-viewport-height")).toBe(""); + }); +}); diff --git a/apps/web/src/hooks/useVisualViewportSync.ts b/apps/web/src/hooks/useVisualViewportSync.ts new file mode 100644 index 000000000000..15000343cf2a --- /dev/null +++ b/apps/web/src/hooks/useVisualViewportSync.ts @@ -0,0 +1,101 @@ +import { useEffect } from "react"; + +/** Below this much lost height we treat the change as browser chrome, not a keyboard. */ +export const KEYBOARD_OPEN_THRESHOLD_PX = 120; + +export interface VisualViewportMetrics { + /** Height of the area the user can actually see, keyboard excluded. */ + height: number; + /** How far the browser has panned the visible area down the layout viewport (iOS does this on focus). */ + offsetTop: number; + /** Layout height hidden under the on-screen keyboard. */ + keyboardInset: number; + keyboardOpen: boolean; +} + +interface VisualViewportLike { + height: number; + offsetTop: number; + scale: number; +} + +/** + * Measures the visible area against the layout viewport. Returns null while the user is + * pinch-zoomed, where the visual viewport says nothing about the keyboard. + * + * iOS Safari ignores `interactive-widget=resizes-content`, so its keyboard shrinks only the + * visual viewport and pans it (`offsetTop`) to reveal the focused field. Chromium honours the + * meta tag and shrinks the layout viewport too, which yields a zero inset here. + */ +export function measureVisualViewport( + layoutHeight: number, + viewport: VisualViewportLike, +): VisualViewportMetrics | null { + if (viewport.scale > 1.01) return null; + const height = Math.round(viewport.height); + const offsetTop = Math.max(0, Math.round(viewport.offsetTop)); + const lostHeight = Math.max(0, Math.round(layoutHeight) - height); + return { + height, + offsetTop, + keyboardInset: Math.max(0, lostHeight - offsetTop), + keyboardOpen: lostHeight > KEYBOARD_OPEN_THRESHOLD_PX, + }; +} + +const HEIGHT_VAR = "--app-viewport-height"; +const OFFSET_VAR = "--app-viewport-offset-top"; +const INSET_VAR = "--keyboard-inset"; + +function clearViewportSync(root: HTMLElement) { + root.style.removeProperty(HEIGHT_VAR); + root.style.removeProperty(OFFSET_VAR); + root.style.removeProperty(INSET_VAR); + delete root.dataset.keyboardOpen; + delete root.dataset.viewportSync; +} + +/** + * Pins the app shell to the visible area on phones: the header stays at the top, the composer + * sits directly on the keyboard, and the page itself never scrolls. + * Writes `--app-viewport-height` and `--app-viewport-offset-top` for the shell, `--keyboard-inset` + * for anything that needs it, and `data-viewport-sync` / `data-keyboard-open` on ``. + */ +export function useVisualViewportSync(enabled: boolean) { + useEffect(() => { + if (!enabled || typeof window === "undefined") return; + const viewport = window.visualViewport; + if (!viewport) return; + const root = document.documentElement; + let last = ""; + + const apply = () => { + const metrics = measureVisualViewport(window.innerHeight, viewport); + if (!metrics) { + last = ""; + clearViewportSync(root); + return; + } + const key = `${metrics.height}:${metrics.offsetTop}:${metrics.keyboardInset}:${metrics.keyboardOpen}`; + if (key === last) return; + last = key; + root.dataset.viewportSync = "true"; + root.style.setProperty(HEIGHT_VAR, `${metrics.height}px`); + root.style.setProperty(OFFSET_VAR, `${metrics.offsetTop}px`); + root.style.setProperty(INSET_VAR, `${metrics.keyboardInset}px`); + if (metrics.keyboardOpen) root.dataset.keyboardOpen = "true"; + else delete root.dataset.keyboardOpen; + }; + + apply(); + viewport.addEventListener("resize", apply); + viewport.addEventListener("scroll", apply); + window.addEventListener("resize", apply); + return () => { + viewport.removeEventListener("resize", apply); + viewport.removeEventListener("scroll", apply); + window.removeEventListener("resize", apply); + clearViewportSync(root); + }; + }, [enabled]); +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 90f204d201fe..6cbe3fb6ad8e 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -705,6 +705,14 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil body { @apply text-foreground relative; } + /* While the phone shell is pinned to the visual viewport (useVisualViewportSync), focusing the + composer must not scroll or rubber-band the document behind it. Pages outside the shell, + such as pairing, keep normal document scrolling. */ + html[data-viewport-sync], + html[data-viewport-sync] body { + overflow: hidden; + overscroll-behavior: none; + } } @layer components { @@ -2367,3 +2375,44 @@ code { } } } + +/* While useVisualViewportSync is active, the shell is pinned to the visible area below the top + safe area, so the keyboard never pans the header away or covers the composer. Route pages size + their inset with h-dvh/h-svh, which iOS Safari does not shrink for the keyboard. Unlayered so + these rules outrank those utilities without !important. */ +html[data-viewport-sync] { + --app-shell-height: calc(var(--app-viewport-height) - env(safe-area-inset-top)); + /* The header toggles now sit below the safe area too, so the drawer's copy needs no offset. */ + --workspace-controls-safe-top: 0px; +} + +html[data-viewport-sync] [data-slot="sidebar-wrapper"] { + position: fixed; + inset-inline: 0; + top: calc(var(--app-viewport-offset-top) + env(safe-area-inset-top)); + /* Makes the shell the containing block of the header's fixed sidebar and panel toggles. */ + contain: layout; +} + +html[data-viewport-sync] [data-slot="sidebar-inset"] { + height: var(--app-shell-height); +} + +/* The thread drawer is portaled outside the shell's containing block. */ +html[data-viewport-sync] + [data-slot="sheet-viewport"]:has([data-sidebar="sidebar"][data-mobile="true"]) { + top: var(--app-viewport-offset-top); + bottom: auto; + height: var(--app-viewport-height); + contain: layout; +} + +/* Long prompts scroll inside the editor instead of pushing the header off a short screen. */ +html[data-viewport-sync] .composer-tiptap { + max-height: min(13rem, calc(var(--app-shell-height) * 0.4)); +} + +/* The keyboard covers the home indicator, so the safe-area gap below the composer is not needed. */ +html[data-viewport-sync][data-keyboard-open] [data-composer-bottom-spacer] { + height: 0.5rem; +}