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}
/>
-