fix(android): pad content root by WindowInsets.tappableElement so 3-button nav clears UI while gesture mode stays edge-to-edge

This commit is contained in:
heaven 2026-05-28 01:57:13 +03:00
parent 53acca3755
commit 61fdf06126
10 changed files with 103 additions and 70 deletions

View file

@ -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

View file

@ -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).

View file

@ -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

View file

@ -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,

View file

@ -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}
/>
<RoomViewTyping room={room} />
</Box>
{!threadDrawerOpen && (
<div
@ -174,13 +172,8 @@ export function RoomView({ eventId }: { eventId?: string }) {
>
<div
className={css.ChatComposer}
// `calc(GAP + env(safe-area-inset-bottom))` on the bottom —
// see `ComposerOverlay` css for why the inset lives here
// and not on the wrapper.
style={{
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)}`,
}}
>
{tombstoneEvent ? (

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)',