fix(web): prevent mobile keyboard from covering chat input (#403)

This commit is contained in:
Haoqing Wang
2026-04-06 05:39:40 +08:00
committed by GitHub
parent ea09663cdc
commit 1c8cb5c90d
4 changed files with 116 additions and 1 deletions
+4
View File
@@ -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()
+63
View File
@@ -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('')
})
})
+47
View File
@@ -0,0 +1,47 @@
import { useEffect } from 'react'
import { isTelegramApp } from '@/hooks/useTelegram'
/**
* Sets a CSS custom property `--app-viewport-height` on <html> 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')
}
}, [])
}
+2 -1
View File
@@ -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%;