diff --git a/apps/readest-app/src/__tests__/utils/viewport.test.ts b/apps/readest-app/src/__tests__/utils/viewport.test.ts new file mode 100644 index 00000000..2cd5f1a4 --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/viewport.test.ts @@ -0,0 +1,38 @@ +import { describe, test, expect } from 'vitest'; +import { getAndroidPatchedViewportContent } from '@/utils/viewport'; + +const ANDROID_UA = + 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36'; +const IOS_UA = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1'; +const MACOS_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15'; +const BASE_VIEWPORT = + 'width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover'; + +describe('getAndroidPatchedViewportContent', () => { + test('returns null on macOS Safari', () => { + expect(getAndroidPatchedViewportContent(MACOS_UA, BASE_VIEWPORT)).toBeNull(); + }); + + test('returns null on iOS Safari', () => { + expect(getAndroidPatchedViewportContent(IOS_UA, BASE_VIEWPORT)).toBeNull(); + }); + + test('appends interactive-widget on Android Chrome', () => { + expect(getAndroidPatchedViewportContent(ANDROID_UA, BASE_VIEWPORT)).toBe( + `${BASE_VIEWPORT}, interactive-widget=resizes-content`, + ); + }); + + test('returns null when interactive-widget is already present', () => { + const existing = `${BASE_VIEWPORT}, interactive-widget=resizes-visual`; + expect(getAndroidPatchedViewportContent(ANDROID_UA, existing)).toBeNull(); + }); + + test('handles empty current content without leading comma', () => { + expect(getAndroidPatchedViewportContent(ANDROID_UA, '')).toBe( + 'interactive-widget=resizes-content', + ); + }); +}); diff --git a/apps/readest-app/src/app/layout.tsx b/apps/readest-app/src/app/layout.tsx index f83fdbc8..48bcf826 100644 --- a/apps/readest-app/src/app/layout.tsx +++ b/apps/readest-app/src/app/layout.tsx @@ -67,15 +67,9 @@ export const viewport: Viewport = { maximumScale: 1, userScalable: false, viewportFit: 'cover', - // Make Android Chrome's layout viewport shrink when the on-screen - // keyboard opens (matches iOS behavior). Without this, Android's - // default `interactive-widget=resizes-visual` keeps the layout - // viewport at full screen height while only the visual viewport - // shrinks — causing fixed `inset-0`-centered modals (passphrase - // prompt, group picker, etc.) to render under the keyboard. With - // `resizes-content`, `100vh` / flex centering naturally targets - // the available space above the keyboard. - interactiveWidget: 'resizes-content', + // `interactive-widget=resizes-content` is appended client-side on + // Android only — see Providers.tsx. Other browsers warn about the + // unrecognized key on every page load, so we keep it out of SSR. }; export default function RootLayout({ children }: { children: React.ReactNode }) { diff --git a/apps/readest-app/src/components/Providers.tsx b/apps/readest-app/src/components/Providers.tsx index 6b5908bb..80ef3bc7 100644 --- a/apps/readest-app/src/components/Providers.tsx +++ b/apps/readest-app/src/components/Providers.tsx @@ -16,6 +16,7 @@ import { useBackgroundTexture } from '@/hooks/useBackgroundTexture'; import { useEinkMode } from '@/hooks/useEinkMode'; import { getLocale } from '@/utils/misc'; import { getDirFromUILanguage } from '@/utils/rtl'; +import { getAndroidPatchedViewportContent } from '@/utils/viewport'; import { DropdownProvider } from '@/context/DropdownContext'; import { CommandPaletteProvider, CommandPalette } from '@/components/command-palette'; import AtmosphereOverlay from '@/components/AtmosphereOverlay'; @@ -102,6 +103,13 @@ const Providers = ({ children }: { children: React.ReactNode }) => { })(); }, []); + useEffect(() => { + const meta = document.querySelector('meta[name="viewport"]'); + if (!meta) return; + const updated = getAndroidPatchedViewportContent(navigator.userAgent, meta.content); + if (updated) meta.content = updated; + }, []); + // Subscribe the bundled-settings publisher to settingsStore changes. // After this fires, every setSettings call diffs the whitelist // against the last-published snapshot and emits a single replica diff --git a/apps/readest-app/src/pages/_app.tsx b/apps/readest-app/src/pages/_app.tsx index 8c707fc5..9ea9f702 100644 --- a/apps/readest-app/src/pages/_app.tsx +++ b/apps/readest-app/src/pages/_app.tsx @@ -10,15 +10,13 @@ function MyApp({ Component, pageProps }: AppProps) { <> {/* - * `interactive-widget=resizes-content` makes Android Chrome - * shrink the layout viewport when the on-screen keyboard - * opens (matches iOS default behavior). Without it, the - * layout viewport stays full-height and `fixed inset-0`- - * centered modals render under the keyboard. + * `interactive-widget=resizes-content` is appended client-side + * on Android only (Providers.tsx) so other browsers don't + * warn about the unrecognized key on every page load. */} diff --git a/apps/readest-app/src/utils/viewport.ts b/apps/readest-app/src/utils/viewport.ts new file mode 100644 index 00000000..614a5973 --- /dev/null +++ b/apps/readest-app/src/utils/viewport.ts @@ -0,0 +1,17 @@ +// `interactive-widget=resizes-content` makes Android Chrome shrink the +// layout viewport when the on-screen keyboard opens, matching iOS +// default behavior so `fixed inset-0`-centered modals (passphrase +// prompt, group picker, etc.) sit above the keyboard. Other browsers +// (Safari on macOS/iOS, desktop Chrome, Firefox) emit a console warning +// when they encounter the unrecognized key on every page load — so we +// only attach it on Android. +export const getAndroidPatchedViewportContent = ( + userAgent: string, + currentContent: string, +): string | null => { + if (!/android/i.test(userAgent)) return null; + if (currentContent.includes('interactive-widget=')) return null; + const trimmed = currentContent.trim().replace(/,\s*$/, ''); + if (!trimmed) return 'interactive-widget=resizes-content'; + return `${trimmed}, interactive-widget=resizes-content`; +};