diff --git a/web/src/App.tsx b/web/src/App.tsx index daf6531d..7ab0798c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -9,6 +9,7 @@ import { useServerUrl } from '@/hooks/useServerUrl' import { useSSE } from '@/hooks/useSSE' import { useSyncingState } from '@/hooks/useSyncingState' import { usePushNotifications } from '@/hooks/usePushNotifications' +import { useViewportHeight } from '@/hooks/useViewportHeight' import { useVisibilityReporter } from '@/hooks/useVisibilityReporter' import { queryKeys } from '@/lib/query-keys' import { AppContextProvider } from '@/lib/app-context' @@ -58,6 +59,9 @@ function AppInner() { initializeTheme() }, []) + // Track visual viewport height for mobile keyboard avoidance (see useViewportHeight.ts) + useViewportHeight() + useEffect(() => { const preventDefault = (event: Event) => { event.preventDefault() diff --git a/web/src/hooks/useViewportHeight.test.ts b/web/src/hooks/useViewportHeight.test.ts new file mode 100644 index 00000000..8262af94 --- /dev/null +++ b/web/src/hooks/useViewportHeight.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * Unit tests for the useViewportHeight hook logic. + * + * Because the hook depends on window.visualViewport (not available in jsdom), + * we test the core update logic directly rather than rendering the hook. + */ +describe('useViewportHeight update logic', () => { + const root = document.documentElement + + beforeEach(() => { + root.style.removeProperty('--app-viewport-height') + }) + + afterEach(() => { + root.style.removeProperty('--app-viewport-height') + }) + + it('sets --app-viewport-height when visual viewport is smaller than window', () => { + // Simulate the update logic from the hook + const viewportHeight = 400 + const windowHeight = 800 + const diff = windowHeight - viewportHeight + if (diff > 1) { + root.style.setProperty('--app-viewport-height', `${viewportHeight}px`) + } else { + root.style.removeProperty('--app-viewport-height') + } + + expect(root.style.getPropertyValue('--app-viewport-height')).toBe('400px') + }) + + it('removes --app-viewport-height when viewports match', () => { + // First set it + root.style.setProperty('--app-viewport-height', '400px') + + // Then simulate keyboard close + const viewportHeight = 800 + const windowHeight = 800 + const diff = windowHeight - viewportHeight + if (diff > 1) { + root.style.setProperty('--app-viewport-height', `${viewportHeight}px`) + } else { + root.style.removeProperty('--app-viewport-height') + } + + expect(root.style.getPropertyValue('--app-viewport-height')).toBe('') + }) + + it('ignores sub-pixel differences (threshold of 1px)', () => { + const viewportHeight = 799.5 + const windowHeight = 800 + const diff = windowHeight - viewportHeight + if (diff > 1) { + root.style.setProperty('--app-viewport-height', `${viewportHeight}px`) + } else { + root.style.removeProperty('--app-viewport-height') + } + + expect(root.style.getPropertyValue('--app-viewport-height')).toBe('') + }) +}) diff --git a/web/src/hooks/useViewportHeight.ts b/web/src/hooks/useViewportHeight.ts new file mode 100644 index 00000000..b8c500c8 --- /dev/null +++ b/web/src/hooks/useViewportHeight.ts @@ -0,0 +1,47 @@ +import { useEffect } from 'react' +import { isTelegramApp } from '@/hooks/useTelegram' + +/** + * Sets a CSS custom property `--app-viewport-height` on that tracks the + * visual viewport height. This is a fallback for browsers that do not support + * the `interactive-widget=resizes-content` viewport meta attribute — on those + * browsers `100dvh` does NOT shrink when the virtual keyboard opens, so the + * composer input is hidden behind the keyboard. + * + * The hook listens to `window.visualViewport.resize` and writes the viewport + * height into the CSS variable. The CSS height chain is: + * var(--tg-viewport-stable-height, var(--app-viewport-height, 100dvh)) + * + * Skipped in Telegram Mini Apps (Telegram SDK provides its own height variable). + */ +export function useViewportHeight(): void { + useEffect(() => { + // Telegram Mini App has its own viewport management via --tg-viewport-stable-height + if (isTelegramApp()) return + + const viewport = window.visualViewport + if (!viewport) return + + const root = document.documentElement + + function update() { + if (!viewport) return + // Only apply when the visual viewport is meaningfully smaller than + // the window (keyboard is open). A small threshold (1px) avoids + // false positives from sub-pixel rounding. + const diff = window.innerHeight - viewport.height + if (diff > 1) { + root.style.setProperty('--app-viewport-height', `${viewport.height}px`) + } else { + root.style.removeProperty('--app-viewport-height') + } + } + + viewport.addEventListener('resize', update) + + return () => { + viewport.removeEventListener('resize', update) + root.style.removeProperty('--app-viewport-height') + } + }, []) +} diff --git a/web/src/index.css b/web/src/index.css index 41bc4487..6fcd43bf 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -97,7 +97,8 @@ html, body { height: 100vh; height: 100dvh; - height: var(--tg-viewport-stable-height, 100dvh); + height: var(--app-viewport-height, 100dvh); + height: var(--tg-viewport-stable-height, var(--app-viewport-height, 100dvh)); touch-action: pan-x pan-y; overscroll-behavior: none; -webkit-text-size-adjust: 100%;