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; package chat.vojo.app;
import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.view.View;
import androidx.activity.EdgeToEdge; import androidx.activity.EdgeToEdge;
import androidx.core.graphics.Insets;
import androidx.core.splashscreen.SplashScreen; import androidx.core.splashscreen.SplashScreen;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowCompat; import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.WindowInsetsControllerCompat; import androidx.core.view.WindowInsetsControllerCompat;
import com.getcapacitor.BridgeActivity; import com.getcapacitor.BridgeActivity;
@ -86,6 +91,60 @@ public class MainActivity extends BridgeActivity {
WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView()); WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView());
controller.setAppearanceLightStatusBars(false); controller.setAppearanceLightStatusBars(false);
controller.setAppearanceLightNavigationBars(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 @Override

View file

@ -238,17 +238,8 @@ export const handleBar = style({
// up over the inline form. The DirectSelfRow ending up immediately // up over the inline form. The DirectSelfRow ending up immediately
// above the keyboard would block the user's view of the form they're // above the keyboard would block the user's view of the form they're
// typing into. // 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({ export const bottomPinnedSlot = style({
flexShrink: 0, flexShrink: 0,
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
}); });
// Segment button (Direct / Channels / Bots). // 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 // Top-anchored — handle + viewer body reveal top-down as silhouette
// grows. `padding-bottom: env(safe-area-inset-bottom)` keeps the // grows. Android nav-bar clearance is owned by `MainActivity.java`'s
// download / share row clear of the Android nav bar. // 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({ export const panelContent = style({
position: 'absolute', position: 'absolute',
top: 0, top: 0,
@ -65,7 +68,6 @@ export const panelContent = style({
boxSizing: 'border-box', boxSizing: 'border-box',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
}); });
// 20px drag-to-close band. The ONLY drag-to-close origin — touches // 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 // slide/fade transition driven by the `data-hidden` attribute set from
// React state. CSS class (not inline `transition`) so the // React state. CSS class (not inline `transition`) so the
// `prefers-reduced-motion` media query can disable the motion. // `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({ export const ComposerOverlay = style({
position: 'absolute', position: 'absolute',
left: 0, left: 0,

View file

@ -10,7 +10,6 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useEditor } from '../../components/editor'; import { useEditor } from '../../components/editor';
import { RoomInputPlaceholder } from './RoomInputPlaceholder'; import { RoomInputPlaceholder } from './RoomInputPlaceholder';
import { RoomTimeline } from './RoomTimeline'; import { RoomTimeline } from './RoomTimeline';
import { RoomViewTyping } from './RoomViewTyping';
import { RoomTombstone } from './RoomTombstone'; import { RoomTombstone } from './RoomTombstone';
import { RoomInput } from './RoomInput'; import { RoomInput } from './RoomInput';
import { Page } from '../../components/page'; import { Page } from '../../components/page';
@ -157,7 +156,6 @@ export function RoomView({ eventId }: { eventId?: string }) {
bottomOverlayHeight={composerHeight} bottomOverlayHeight={composerHeight}
onComposerHiddenChange={setComposerHidden} onComposerHiddenChange={setComposerHidden}
/> />
<RoomViewTyping room={room} />
</Box> </Box>
{!threadDrawerOpen && ( {!threadDrawerOpen && (
<div <div
@ -174,13 +172,8 @@ export function RoomView({ eventId }: { eventId?: string }) {
> >
<div <div
className={css.ChatComposer} 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={{ style={{
padding: `0 ${toRem(VOJO_HORSESHOE_GAP_PX)} calc(${toRem( padding: `0 ${toRem(VOJO_HORSESHOE_GAP_PX)} ${toRem(VOJO_HORSESHOE_GAP_PX)}`,
VOJO_HORSESHOE_GAP_PX
)} + env(safe-area-inset-bottom, 0px))`,
}} }}
> >
{tombstoneEvent ? ( {tombstoneEvent ? (

View file

@ -228,15 +228,9 @@ export const ThreadCounterText = style({
// reapplied to the inner wrap in `ThreadDrawer.tsx` so the same // reapplied to the inner wrap in `ThreadDrawer.tsx` so the same
// `globalStyle` rules (`Surface.Container` bg, 32px radius, dark // `globalStyle` rules (`Surface.Container` bg, 32px radius, dark
// touch-hover gate) reach the Editor inside. // 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({ export const ThreadComposer = style({
flexShrink: 0, flexShrink: 0,
padding: `0 ${toRem(VOJO_HORSESHOE_GAP_PX)} calc(${toRem( padding: `0 ${toRem(VOJO_HORSESHOE_GAP_PX)} ${toRem(VOJO_HORSESHOE_GAP_PX)}`,
VOJO_HORSESHOE_GAP_PX
)} + env(safe-area-inset-bottom, 0px))`,
}); });
// Bubble chrome itself lives in `Channel.css.ts` and applies via the // 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 // Background: `SurfaceVariant.Container` (Dawn bg = #181a20) — the
// chat-pane tone, same as the Settings PageNav inside (set via // chat-pane tone, same as the Settings PageNav inside (set via
// `surface="surfaceVariant"`). Load-bearing: the silhouette's bg // `surface="surfaceVariant"`). Load-bearing: the silhouette's bg
// shows through every panel gap that Settings's PageNav doesn't // shows through the 20px drag-handle band at the top so the seam
// cover — the 20px drag-handle band at the top and the // matches the PageNav tone. With `Background.Container` (#0d0e11)
// `env(safe-area-inset-bottom)` padding inside `panelContent` at the // here, the user saw a dark stripe at that seam on Samsung S24
// gesture-pill strip. With `Background.Container` (#0d0e11) here, the // edge-to-edge; matching silhouette to the PageNav tone closes it.
// user saw dark stripes at both seams on Samsung S24 edge-to-edge; // Same idea as commit 77bb72d which dynamically retunes
// matching silhouette to the PageNav tone closes them. Same idea as // `--vojo-safe-area-bg` while a Room is mounted to keep the
// commit 77bb72d which dynamically retunes `--vojo-safe-area-bg` // system-bar strips and the chat surface in lockstep.
// while a Room is mounted to keep the system-bar strips and the chat
// surface in lockstep.
export const silhouette = style({ export const silhouette = style({
position: 'absolute', position: 'absolute',
bottom: 0, bottom: 0,
@ -144,21 +142,17 @@ export const silhouette = style({
// are visible at the very first pixel of drag — gives the user // are visible at the very first pixel of drag — gives the user
// immediate "I'm opening Settings" feedback. // immediate "I'm opening Settings" feedback.
// //
// `padding-bottom: env(safe-area-inset-bottom)` keeps the panel // Android nav-bar clearance is owned by `MainActivity.java`'s
// content (Settings menu) clear of the Android nav bar in edge-to-edge // WindowInsets listener (the WebView is already sized above the nav
// mode. `box-sizing: border-box` makes the inline `height: railHeightPx` // bar). No `padding-bottom: env(...)` here — that would stack on top
// include the inset, so the panel's measured height includes the // of the native padding and clip the Settings menu's bottom rows.
// reserved inset and the bottom of the visible content sits above the
// system icons.
export const panelContent = style({ export const panelContent = style({
position: 'absolute', position: 'absolute',
top: 0, top: 0,
left: 0, left: 0,
right: 0, right: 0,
boxSizing: 'border-box',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
}); });
// Drag handle band — sits at the TOP of `panelContent` so it appears // 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 // 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 // clipped by `overflow: hidden`. The bar itself sits flush to the bottom
// edge of the safe-area inset (the 26px above is a transparent // edge of the WebView's visible area (the 26px above is a transparent
// pointer-events: none zone where only the glow renders). // 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({ export const root = style({
position: 'fixed', position: 'fixed',
left: 'env(safe-area-inset-left, 0px)', left: 'env(safe-area-inset-left, 0px)',
right: 'env(safe-area-inset-right, 0px)', right: 'env(safe-area-inset-right, 0px)',
bottom: 'env(safe-area-inset-bottom, 0px)', bottom: 0,
height: '28px', height: '28px',
pointerEvents: 'none', pointerEvents: 'none',
// Z400 sits above app chrome and below folds modals/popouts (which use // 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', willChange: 'height, border-top-left-radius, border-top-right-radius',
}); });
// Top-anchored panel content. Padding-bottom reserves Android nav-bar // Top-anchored panel content. Android nav-bar clearance is owned by
// inset so the create-space row never tucks under the gesture pill. // `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({ export const panelContent = style({
position: 'absolute', position: 'absolute',
top: 0, top: 0,
@ -99,7 +102,6 @@ export const panelContent = style({
boxSizing: 'border-box', boxSizing: 'border-box',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
}); });
// 20px drag-to-close band at the top of the silhouette. The ONLY // 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 // headers don't double-pad. On web `env()` resolves to `0px`, so the
// var is a no-op there. // var is a no-op there.
// //
// Bottom inset is owned per-component: surfaces that anchor // Bottom inset is owned by `MainActivity.java::onCreate`'s
// interactive content at the screen bottom and need 3-button-nav / // `OnApplyWindowInsetsListener` — it reads
// home-indicator clearance read `env(safe-area-inset-bottom)` // `WindowInsetsCompat.Type.tappableElement()` (= 0 in gesture mode,
// themselves (e.g. `SyncIndicator.css.ts`, // = nav-bar height in 3-button mode) and applies it as bottom
// `StreamHeader.bottomPinnedSlot` for DirectSelfRow / WorkspaceFooter, // padding on the activity's content root (NOT on the WebView; that
// `RoomView.tsx` ChatComposer inner div, the `panelContent` rules in // would replace WebView's internal listener and break
// MobileSettings / ChannelsWorkspace / MobileMediaViewer horseshoes). // `env(safe-area-inset-top)`). The web side stays unaware: in
// The chat overlay wrapper (`ComposerOverlay`) itself stays `bottom: 0` // gesture mode the WebView extends edge-to-edge, in 3-button mode
// flush — the inset lives on the inner card padding so // it is sized above the nav bar and the activity windowBackground
// `ResizeObserver.contentRect` keeps `composerHeight` in sync with // strip below paints the system icons over a tone-matched bg.
// the visible overlay height for `RoomTimeline.bottomOverlayHeight`. // Don't add `padding-bottom: env(safe-area-inset-bottom)` to any
// Bot widgets are responsible for their own gesture-pill clearance // component — Chromium maps that env() to `systemBars()` which is
// (see `BotShell.css.ts::Shell`). // non-zero in gesture mode too, and it stacks on top of the native
// padding, double-lifting the UI.
globalStyle(':root', { globalStyle(':root', {
vars: { vars: {
'--vojo-safe-top': 'env(safe-area-inset-top, 0px)', '--vojo-safe-top': 'env(safe-area-inset-top, 0px)',