From 61fdf061263d15136ab766598b668412e1d6c17e Mon Sep 17 00:00:00 2001 From: heaven Date: Thu, 28 May 2026 01:57:13 +0300 Subject: [PATCH] fix(android): pad content root by WindowInsets.tappableElement so 3-button nav clears UI while gesture mode stays edge-to-edge --- .../main/java/chat/vojo/app/MainActivity.java | 59 +++++++++++++++++++ .../stream-header/StreamHeader.css.ts | 9 --- .../room/MobileMediaViewerHorseshoe.css.ts | 8 ++- src/app/features/room/RoomView.css.ts | 7 --- src/app/features/room/RoomView.tsx | 9 +-- src/app/features/room/ThreadDrawer.css.ts | 8 +-- .../settings/MobileSettingsHorseshoe.css.ts | 28 ++++----- src/app/pages/client/SyncIndicator.css.ts | 10 +++- .../ChannelsWorkspaceHorseshoe.css.ts | 8 ++- src/app/styles/global.css.ts | 27 +++++---- 10 files changed, 103 insertions(+), 70 deletions(-) diff --git a/android/app/src/main/java/chat/vojo/app/MainActivity.java b/android/app/src/main/java/chat/vojo/app/MainActivity.java index 7a9c7292..a6e36d50 100644 --- a/android/app/src/main/java/chat/vojo/app/MainActivity.java +++ b/android/app/src/main/java/chat/vojo/app/MainActivity.java @@ -1,11 +1,16 @@ package chat.vojo.app; +import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Looper; +import android.view.View; import androidx.activity.EdgeToEdge; +import androidx.core.graphics.Insets; import androidx.core.splashscreen.SplashScreen; +import androidx.core.view.ViewCompat; import androidx.core.view.WindowCompat; +import androidx.core.view.WindowInsetsCompat; import androidx.core.view.WindowInsetsControllerCompat; import com.getcapacitor.BridgeActivity; @@ -86,6 +91,60 @@ public class MainActivity extends BridgeActivity { WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView()); controller.setAppearanceLightStatusBars(false); controller.setAppearanceLightNavigationBars(false); + + // 3-button nav clearance. Reads `tappableElement` (= 0 in gesture + // mode, = nav-bar height in 3-button mode) and applies it as + // padding on the activity's content root — NOT on the WebView + // itself. The WebView is a child of the content root, so root + // padding shrinks the WebView's layout area; in 3-button mode + // the WebView ends above the nav bar and the activity + // windowBackground strip behind it paints `splash_bg` (#0d0e11) + // which matches the dark body bg, so system icons read as + // continuous with the chat surface. Gesture mode stays + // edge-to-edge (padding = 0). Left/right are included for + // landscape 3-button mode where the nav bar rotates to a side. + // + // CRITICAL: the listener MUST live on the content root, not on + // the WebView. Attaching it to the WebView replaces WebView's + // internal `OnApplyWindowInsetsListener` (the one Chromium uses + // to feed CSS `env(safe-area-inset-*)`), which silently breaks + // `env(safe-area-inset-top)` → `--vojo-safe-top` → every top- + // anchored UI clearance for the status bar. Padding the parent + // leaves WebView's window-insets pipeline untouched while still + // shrinking its visual area. + // + // Why `tappableElement` and not `systemBars()` / `navigationBars()`: + // both report ~24-32 dp in gesture mode too (the pill area), + // which would lift UI in fullscreen — exactly the regression + // commit 443213b4 had. `tappableElement` is the type defined as + // «system UI regions where the user can tap», which means 0 in + // gesture mode and the nav-bar in 3-button mode. + // + // Capacitor 8.3's bundled `SystemBars` plugin attaches its own + // inset listener on `webView.getParent()` (CoordinatorLayout one + // level deeper). Since `index.html` declares `viewport-fit=cover`, + // Capacitor takes the passthrough branch and doesn't pad — no + // compound-padding with our listener. If `viewport-fit=cover` is + // ever removed, set `plugins.SystemBars.insetsHandling = "disable"` + // in `capacitor.config.ts` to avoid double-lift. + // + // Fallback for API < 29 (Android 7-9): `tappableElement` did + // not exist before Q. AndroidX backports the call to API 24+ as + // `getSystemWindowInsets()` (≈ `navigationBars()`); the explicit + // gate makes the intent visible at the call site. + final View contentRoot = findViewById(android.R.id.content); + ViewCompat.setOnApplyWindowInsetsListener(contentRoot, (v, windowInsets) -> { + final int typeMask = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + ? WindowInsetsCompat.Type.tappableElement() + : WindowInsetsCompat.Type.navigationBars(); + Insets ins = windowInsets.getInsets(typeMask); + v.setPadding(ins.left, 0, ins.right, ins.bottom); + // Do NOT consume — propagate to WebView (CSS env() pipeline) + // and Capacitor's SystemBars listener so they still see + // unmodified insets. + return windowInsets; + }); + ViewCompat.requestApplyInsets(contentRoot); } @Override diff --git a/src/app/components/stream-header/StreamHeader.css.ts b/src/app/components/stream-header/StreamHeader.css.ts index 664b2a10..5ea1efd9 100644 --- a/src/app/components/stream-header/StreamHeader.css.ts +++ b/src/app/components/stream-header/StreamHeader.css.ts @@ -238,17 +238,8 @@ export const handleBar = style({ // up over the inline form. The DirectSelfRow ending up immediately // above the keyboard would block the user's view of the form they're // typing into. -// -// `paddingBottom: env(safe-area-inset-bottom)` lifts DirectSelfRow / -// WorkspaceFooter above Android 3-button nav in edge-to-edge mode. -// The inset zone paints in the curtain's `Background.Container` bg -// (curtain has `overflow: hidden`), so the system bar reads as a -// continuation of the curtain tone — no visible seam. The keyboard -// `height: 0; overflow: hidden` collapse above also clips the -// padding region — keyboard handling preserved. export const bottomPinnedSlot = style({ flexShrink: 0, - paddingBottom: 'env(safe-area-inset-bottom, 0px)', }); // Segment button (Direct / Channels / Bots). diff --git a/src/app/features/room/MobileMediaViewerHorseshoe.css.ts b/src/app/features/room/MobileMediaViewerHorseshoe.css.ts index 90978e62..cc554a48 100644 --- a/src/app/features/room/MobileMediaViewerHorseshoe.css.ts +++ b/src/app/features/room/MobileMediaViewerHorseshoe.css.ts @@ -55,8 +55,11 @@ export const silhouette = style({ }); // Top-anchored — handle + viewer body reveal top-down as silhouette -// grows. `padding-bottom: env(safe-area-inset-bottom)` keeps the -// download / share row clear of the Android nav bar. +// grows. Android nav-bar clearance is owned by `MainActivity.java`'s +// WindowInsets listener (the WebView is already sized above the nav +// bar), so no `padding-bottom: env(...)` here — that would stack on +// top of the native padding and lift the download / share row above +// its visible host. export const panelContent = style({ position: 'absolute', top: 0, @@ -65,7 +68,6 @@ export const panelContent = style({ boxSizing: 'border-box', display: 'flex', flexDirection: 'column', - paddingBottom: 'env(safe-area-inset-bottom, 0px)', }); // 20px drag-to-close band. The ONLY drag-to-close origin — touches diff --git a/src/app/features/room/RoomView.css.ts b/src/app/features/room/RoomView.css.ts index d9c1020c..a175701f 100644 --- a/src/app/features/room/RoomView.css.ts +++ b/src/app/features/room/RoomView.css.ts @@ -24,13 +24,6 @@ export const ChatComposer = style({}); // slide/fade transition driven by the `data-hidden` attribute set from // React state. CSS class (not inline `transition`) so the // `prefers-reduced-motion` media query can disable the motion. -// -// `env(safe-area-inset-bottom)` for Android 3-button-nav clearance lives -// on the INNER `ChatComposer` div (see `RoomView.tsx`), not here. The -// wrapper is the `ResizeObserver` target for `composerHeight`, and -// `entry.contentRect` returns content-box (excludes the observed node's -// own padding); padding here would inflate border-box without growing -// the reported height, letting the last message slip behind the inset. export const ComposerOverlay = style({ position: 'absolute', left: 0, diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index 3428055e..c31f552f 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -10,7 +10,6 @@ import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useEditor } from '../../components/editor'; import { RoomInputPlaceholder } from './RoomInputPlaceholder'; import { RoomTimeline } from './RoomTimeline'; -import { RoomViewTyping } from './RoomViewTyping'; import { RoomTombstone } from './RoomTombstone'; import { RoomInput } from './RoomInput'; import { Page } from '../../components/page'; @@ -157,7 +156,6 @@ export function RoomView({ eventId }: { eventId?: string }) { bottomOverlayHeight={composerHeight} onComposerHiddenChange={setComposerHidden} /> - {!threadDrawerOpen && (
{tombstoneEvent ? ( diff --git a/src/app/features/room/ThreadDrawer.css.ts b/src/app/features/room/ThreadDrawer.css.ts index a4572469..862c9ae9 100644 --- a/src/app/features/room/ThreadDrawer.css.ts +++ b/src/app/features/room/ThreadDrawer.css.ts @@ -228,15 +228,9 @@ export const ThreadCounterText = style({ // reapplied to the inner wrap in `ThreadDrawer.tsx` so the same // `globalStyle` rules (`Surface.Container` bg, 32px radius, dark // touch-hover gate) reach the Editor inside. -// -// Bottom side adds `env(safe-area-inset-bottom)` so the Send button -// clears Android 3-button nav in edge-to-edge mode, mirroring the -// main `ChatComposer` in `RoomView.tsx`. export const ThreadComposer = style({ flexShrink: 0, - padding: `0 ${toRem(VOJO_HORSESHOE_GAP_PX)} calc(${toRem( - VOJO_HORSESHOE_GAP_PX - )} + env(safe-area-inset-bottom, 0px))`, + padding: `0 ${toRem(VOJO_HORSESHOE_GAP_PX)} ${toRem(VOJO_HORSESHOE_GAP_PX)}`, }); // Bubble chrome itself lives in `Channel.css.ts` and applies via the diff --git a/src/app/features/settings/MobileSettingsHorseshoe.css.ts b/src/app/features/settings/MobileSettingsHorseshoe.css.ts index af8cf5b4..497b9e42 100644 --- a/src/app/features/settings/MobileSettingsHorseshoe.css.ts +++ b/src/app/features/settings/MobileSettingsHorseshoe.css.ts @@ -117,15 +117,13 @@ export const appBody = style({ // Background: `SurfaceVariant.Container` (Dawn bg = #181a20) — the // chat-pane tone, same as the Settings PageNav inside (set via // `surface="surfaceVariant"`). Load-bearing: the silhouette's bg -// shows through every panel gap that Settings's PageNav doesn't -// cover — the 20px drag-handle band at the top and the -// `env(safe-area-inset-bottom)` padding inside `panelContent` at the -// gesture-pill strip. With `Background.Container` (#0d0e11) here, the -// user saw dark stripes at both seams on Samsung S24 edge-to-edge; -// matching silhouette to the PageNav tone closes them. Same idea as -// commit 77bb72d which dynamically retunes `--vojo-safe-area-bg` -// while a Room is mounted to keep the system-bar strips and the chat -// surface in lockstep. +// shows through the 20px drag-handle band at the top so the seam +// matches the PageNav tone. With `Background.Container` (#0d0e11) +// here, the user saw a dark stripe at that seam on Samsung S24 +// edge-to-edge; matching silhouette to the PageNav tone closes it. +// Same idea as commit 77bb72d which dynamically retunes +// `--vojo-safe-area-bg` while a Room is mounted to keep the +// system-bar strips and the chat surface in lockstep. export const silhouette = style({ position: 'absolute', bottom: 0, @@ -144,21 +142,17 @@ export const silhouette = style({ // are visible at the very first pixel of drag — gives the user // immediate "I'm opening Settings" feedback. // -// `padding-bottom: env(safe-area-inset-bottom)` keeps the panel -// content (Settings menu) clear of the Android nav bar in edge-to-edge -// mode. `box-sizing: border-box` makes the inline `height: railHeightPx` -// include the inset, so the panel's measured height includes the -// reserved inset and the bottom of the visible content sits above the -// system icons. +// Android nav-bar clearance is owned by `MainActivity.java`'s +// WindowInsets listener (the WebView is already sized above the nav +// bar). No `padding-bottom: env(...)` here — that would stack on top +// of the native padding and clip the Settings menu's bottom rows. export const panelContent = style({ position: 'absolute', top: 0, left: 0, right: 0, - boxSizing: 'border-box', display: 'flex', flexDirection: 'column', - paddingBottom: 'env(safe-area-inset-bottom, 0px)', }); // Drag handle band — sits at the TOP of `panelContent` so it appears diff --git a/src/app/pages/client/SyncIndicator.css.ts b/src/app/pages/client/SyncIndicator.css.ts index 70b81fca..1f4c28c0 100644 --- a/src/app/pages/client/SyncIndicator.css.ts +++ b/src/app/pages/client/SyncIndicator.css.ts @@ -12,13 +12,17 @@ const slide = keyframes({ // Container is taller than the visible bar so the drop-shadow halo isn't // clipped by `overflow: hidden`. The bar itself sits flush to the bottom -// edge of the safe-area inset (the 26px above is a transparent -// pointer-events: none zone where only the glow renders). +// edge of the WebView's visible area (the 26px above is a transparent +// pointer-events: none zone where only the glow renders). Bottom inset +// is owned by `MainActivity.java`'s WindowInsets listener — the WebView +// is already sized to clear the 3-button nav bar, so `bottom: 0` lands +// flush above the nav rather than under it. Side insets stay `env(...)` +// for display-cutout clearance in landscape. export const root = style({ position: 'fixed', left: 'env(safe-area-inset-left, 0px)', right: 'env(safe-area-inset-right, 0px)', - bottom: 'env(safe-area-inset-bottom, 0px)', + bottom: 0, height: '28px', pointerEvents: 'none', // Z400 sits above app chrome and below folds modals/popouts (which use diff --git a/src/app/pages/client/channels/ChannelsWorkspaceHorseshoe.css.ts b/src/app/pages/client/channels/ChannelsWorkspaceHorseshoe.css.ts index a7ab82e5..1df66f81 100644 --- a/src/app/pages/client/channels/ChannelsWorkspaceHorseshoe.css.ts +++ b/src/app/pages/client/channels/ChannelsWorkspaceHorseshoe.css.ts @@ -89,8 +89,11 @@ export const silhouette = style({ willChange: 'height, border-top-left-radius, border-top-right-radius', }); -// Top-anchored panel content. Padding-bottom reserves Android nav-bar -// inset so the create-space row never tucks under the gesture pill. +// Top-anchored panel content. Android nav-bar clearance is owned by +// `MainActivity.java`'s WindowInsets listener (the WebView is already +// sized above the nav bar), so no `padding-bottom: env(...)` here — +// that would stack on top of the native padding and clip the +// create-space row. export const panelContent = style({ position: 'absolute', top: 0, @@ -99,7 +102,6 @@ export const panelContent = style({ boxSizing: 'border-box', display: 'flex', flexDirection: 'column', - paddingBottom: 'env(safe-area-inset-bottom, 0px)', }); // 20px drag-to-close band at the top of the silhouette. The ONLY diff --git a/src/app/styles/global.css.ts b/src/app/styles/global.css.ts index 13852b23..62286184 100644 --- a/src/app/styles/global.css.ts +++ b/src/app/styles/global.css.ts @@ -14,19 +14,20 @@ import { color } from 'folds'; // headers don't double-pad. On web `env()` resolves to `0px`, so the // var is a no-op there. // -// Bottom inset is owned per-component: surfaces that anchor -// interactive content at the screen bottom and need 3-button-nav / -// home-indicator clearance read `env(safe-area-inset-bottom)` -// themselves (e.g. `SyncIndicator.css.ts`, -// `StreamHeader.bottomPinnedSlot` for DirectSelfRow / WorkspaceFooter, -// `RoomView.tsx` ChatComposer inner div, the `panelContent` rules in -// MobileSettings / ChannelsWorkspace / MobileMediaViewer horseshoes). -// The chat overlay wrapper (`ComposerOverlay`) itself stays `bottom: 0` -// flush — the inset lives on the inner card padding so -// `ResizeObserver.contentRect` keeps `composerHeight` in sync with -// the visible overlay height for `RoomTimeline.bottomOverlayHeight`. -// Bot widgets are responsible for their own gesture-pill clearance -// (see `BotShell.css.ts::Shell`). +// Bottom inset is owned by `MainActivity.java::onCreate`'s +// `OnApplyWindowInsetsListener` — it reads +// `WindowInsetsCompat.Type.tappableElement()` (= 0 in gesture mode, +// = nav-bar height in 3-button mode) and applies it as bottom +// padding on the activity's content root (NOT on the WebView; that +// would replace WebView's internal listener and break +// `env(safe-area-inset-top)`). The web side stays unaware: in +// gesture mode the WebView extends edge-to-edge, in 3-button mode +// it is sized above the nav bar and the activity windowBackground +// strip below paints the system icons over a tone-matched bg. +// Don't add `padding-bottom: env(safe-area-inset-bottom)` to any +// component — Chromium maps that env() to `systemBars()` which is +// non-zero in gesture mode too, and it stacks on top of the native +// padding, double-lifting the UI. globalStyle(':root', { vars: { '--vojo-safe-top': 'env(safe-area-inset-top, 0px)',