fix(mobile): make the chat layout responsive on phones; release v5.9.2
CodeQL Analysis / Analyze CodeQL (push) Canceled after 0s
Deploy Application / deploy (push) Canceled after 0s
Mirror to Codeberg / mirror (push) Canceled after 0s
Mirror to PrivacyGuides / mirror (push) Canceled after 0s

The chat did not lay out correctly on phones, iPhone worst of all: the header
would not stay at the top, and the layout shifted as you scrolled or opened the
keyboard.

The root cause was a floor, not a position. The app shell and column both carry
.minimal-bg, which sets min-height:100vh — and min-height always beats height. On
iOS 100vh is the URL-bar-retracted *large* viewport, so with the bar showing the
shell was held some 60-100px taller than the visible area. That surplus made the
document scrollable, and a scrolling document is how the header rode off the top.
It also left the visual-viewport tracking inert: forcing --sb-vh to 700px left the
shell at 844px. With the floor removed it follows to 700px and the document does
not scroll.

Also: the app shell is now sized from visualViewport rather than pinned, the
message list is the only scroller and can actually shrink, safe-area padding
applies (viewport-fit=cover) and collapses under the keyboard, and the
online/offline toast no longer sits on top of the header.

Adds ?preview=chat, which renders the chat layout with canned content and no
connection — presentational only, no peer manager, keys, or network.

Verified in an emulated iPhone viewport at rest and with the keyboard open:
header pinned at 0-64, composer flush to the keyboard line, document unscrollable.
This commit is contained in:
lockbitchat
2026-08-09 13:21:44 -04:00
parent 556727eb6f
commit 6a98e2eb00
14 changed files with 545 additions and 63 deletions
+56
View File
@@ -1,5 +1,61 @@
# Changelog
## v5.9.2 — Responsive layout fixed for phones
The chat was not laying out correctly on phones, iPhone worst of all: the header
would not stay at the top, and the layout shifted around as you scrolled or opened
the keyboard. The responsive behaviour is fixed.
### Fixed
- **The header stops scrolling away on iPhone.** The shell and the app column both
carry `.minimal-bg`, which sets `min-height: 100vh` — and `min-height` always
beats `height`. On iOS `100vh` is the URL-bar-retracted *large* viewport, so with
the bar showing the shell was held some 60100px taller than the area you can
actually see. That surplus is what made the document scrollable, and a document
that scrolls is how the header rode off the top. Removing the floor is also what
makes everything below work at all: measured with `--sb-vh` forced to 700px, the
shell stayed 844px before the fix and follows to 700px after.
- **The online/offline toast no longer covers the peer name.** It is `fixed top-4`,
which inside the chat landed on the 64px header; it now sits below it.
- **The chat shell is sized from the visual viewport instead of guessing.** Its
height follows `visualViewport`, with `100dvh` as the fallback — which is
already correct on Android, where `interactive-widget=resizes-content` makes the
layout viewport shrink for the keyboard. WebKit has not implemented
`interactive-widget` at all, which is why the JS fallback exists.
- **The layout no longer resizes while you scroll.** Height was recomputed on
every `visualViewport` scroll event, which on iOS fires while the URL bar
collapses and during rubber-banding, so the whole chat twitched under the
finger. It now tracks resize only.
- **Safe-area padding actually applies, and gets out of the way.** The composer
already padded for the home indicator with `env(safe-area-inset-bottom)`, but
the viewport meta lacked `viewport-fit=cover`, so the value was always 0 on
notched iPhones. It now also collapses while the keyboard is up, where it would
otherwise be dead space between the composer and the keyboard.
- **The message list can shrink.** It is `flex: 1` in a column, which defaults to
`min-height: auto`: it refused to shrink below its content and pushed the
composer off the bottom of the screen in a long conversation. It is also the
only scroller now, and no longer chains its scroll to the document.
- **The chat header stays at the top**, via `position: sticky` on the header and
nothing else. If an ancestor scrolls — which is what iOS does to lift a focused
input above the keyboard — the header holds against the top instead of riding
away with the page.
- Nested containers no longer each claim a full viewport height, which counted
the header twice.
### Added
- `?preview=chat` renders the chat layout with canned content and no connection,
so it can be looked at without standing up a handshake first. Presentational
only: no peer manager, no keys, no network, and the send handlers are no-ops.
The shell is sized, not pinned. An intermediate version of this fix used
`position: fixed` with a JS-chased viewport offset and a body scroll lock; that
makes the layout feel nailed down rather than laid out, and a fixed box is laid
out against the layout viewport — the one iOS never shrinks for the keyboard — so
it has to be chased forever. Driving height and leaving positioning alone is what
the established iOS chat implementations do.
## v5.9.1 — Scanning a one-frame invitation
### Fixed
+1 -1
View File
@@ -9,7 +9,7 @@
No accounts. No servers storing your messages. No installation required.
[![License: MIT](https://img.shields.io/badge/License-MIT-f0892a.svg)](LICENSE)
[![Version](https://img.shields.io/badge/version-5.9.1-3ecf8e.svg)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-5.9.2-3ecf8e.svg)](CHANGELOG.md)
[![PWA](https://img.shields.io/badge/PWA-installable-3ecf8e.svg)](#install-as-an-app)
[![Encryption](https://img.shields.io/badge/crypto-ECDH%20P--384%20%C2%B7%20AES--256--GCM-blue.svg)](#security-model)
[![Forward secrecy](https://img.shields.io/badge/forward%20secrecy-Double%20Ratchet-3ecf8e.svg)](#forward-secrecy)
+1 -1
View File
@@ -21266,7 +21266,7 @@ var SecureMasterKeyManager = class {
var import_NotificationIntegration = __toESM(require_NotificationIntegration());
// package.json
var version = "5.9.1";
var version = "5.9.2";
// src/components/ui/Header.jsx
var APP_VERSION = `v${version}`;
+1 -1
View File
File diff suppressed because one or more lines are too long
Vendored
+111 -14
View File
@@ -2660,7 +2660,7 @@ var EnhancedChatInterface = ({
);
const composer = React.createElement(
"footer",
{ key: "composer", style: { flex: "none", padding: "12px 20px calc(18px + env(safe-area-inset-bottom, 0px))", background: "#0f0f11", borderTop: "1px solid rgba(255,255,255,0.05)" } },
{ key: "composer", style: { flex: "none", padding: "12px 20px calc(18px + var(--sb-safe-bottom, env(safe-area-inset-bottom, 0px)))", background: "#0f0f11", borderTop: "1px solid rgba(255,255,255,0.05)" } },
React.createElement(
"div",
{ style: { maxWidth: "1000px", margin: "0 auto" } },
@@ -2889,7 +2889,7 @@ var SessionsSidebar = ({ chats, collapsed, drawerOpen, onToggleCollapse, onSelec
const inner = collapsed ? collapsedInner : expandedInner;
return h(React.Fragment, null, [
// Responsive behaviour (inline styles can't express media queries).
h("style", { key: "css", dangerouslySetInnerHTML: { __html: "@media (max-width:1023px){.sb-rail{display:none !important;}.sb-burger{display:grid !important;}}@media (min-width:1024px){.sb-drawer-overlay{display:none !important;}}.sb-mobile-drawer .sb-collapse-btn{display:none !important;}html,body{background:#0f0f11 !important;overscroll-behavior:none;}.sb-app-shell{height:var(--sb-vh,100dvh) !important;}.sb-app-col{height:var(--sb-vh,100dvh) !important;}.chat-container{height:var(--sb-vh,100dvh) !important;}@media (max-width:768px){textarea,input,select{font-size:16px !important;}}@media (max-width:768px){.sb-rename-btn{display:none !important;}}" } }),
h("style", { key: "css", dangerouslySetInnerHTML: { __html: "@media (max-width:1023px){.sb-rail{display:none !important;}.sb-burger{display:grid !important;}}@media (min-width:1024px){.sb-drawer-overlay{display:none !important;}}.sb-mobile-drawer .sb-collapse-btn{display:none !important;}html,body{background:#0f0f11 !important;overscroll-behavior:none;}.sb-app-shell{height:var(--sb-vh,100dvh) !important;min-height:0 !important;overflow:hidden;}.sb-chat-header{position:sticky;top:0;z-index:20;}.sb-app-col{height:100% !important;min-height:0 !important;}.chat-container{height:100% !important;min-height:0 !important;}.sb-scroll{min-height:0 !important;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;}@media (max-width:768px){textarea,input,select{font-size:16px !important;}}@media (max-width:768px){.sb-rename-btn{display:none !important;}}" } }),
// Desktop rail
h("aside", { key: "rail", className: "sb-rail", style: railStyle }, inner),
// Mobile drawer overlay
@@ -3052,22 +3052,33 @@ var EnhancedSecureP2PChat = () => {
}, []);
React.useEffect(() => {
const vv = typeof window !== "undefined" ? window.visualViewport : null;
const root = document.documentElement;
let lastH = -1, lastInset = "";
const applyHeight = () => {
const h = Math.round(vv ? vv.height : window.innerHeight || 0);
if (h && h !== lastH) {
lastH = h;
root.style.setProperty("--sb-vh", h + "px");
}
};
const applyInset = () => {
const covered = !!vv && window.innerHeight - vv.height > 120;
const v = covered ? "0px" : "env(safe-area-inset-bottom, 0px)";
if (v !== lastInset) {
lastInset = v;
root.style.setProperty("--sb-safe-bottom", v);
}
};
const apply = () => {
const h = vv ? vv.height : window.innerHeight || 0;
if (h) document.documentElement.style.setProperty("--sb-vh", h + "px");
applyHeight();
applyInset();
};
apply();
if (vv) {
vv.addEventListener("resize", apply);
vv.addEventListener("scroll", apply);
}
if (vv) vv.addEventListener("resize", apply);
window.addEventListener("resize", apply);
window.addEventListener("orientationchange", apply);
return () => {
if (vv) {
vv.removeEventListener("resize", apply);
vv.removeEventListener("scroll", apply);
}
if (vv) vv.removeEventListener("resize", apply);
window.removeEventListener("resize", apply);
window.removeEventListener("orientationchange", apply);
};
@@ -5153,11 +5164,18 @@ var EnhancedSecureP2PChat = () => {
addMessageWithAutoScroll(" Secure connection successfully established and verified! You can now communicate safely with full protection against MITM attacks and Perfect Forward Secrecy..", "system");
}
}, [connectionStatus, isVerified]);
const previewMode = React.useMemo(() => {
try {
return new URLSearchParams(window.location.search).get("preview") === "chat";
} catch {
return false;
}
}, []);
const isConnectedAndVerified = (connectionStatus === "connected" || connectionStatus === "verified" || connectionStatus === "reconnecting") && isVerified;
React.useEffect(() => {
document.body.classList.toggle("sb-in-chat", isConnectedAndVerified);
document.body.classList.toggle("sb-in-chat", isConnectedAndVerified || previewMode);
return () => document.body.classList.remove("sb-in-chat");
}, [isConnectedAndVerified]);
}, [isConnectedAndVerified, previewMode]);
React.useEffect(() => {
if (isConnectedAndVerified && pendingSession && connectionStatus !== "failed") {
setPendingSession(null);
@@ -5253,6 +5271,85 @@ var EnhancedSecureP2PChat = () => {
const s = sessionsState.sessions[id];
return s && s.sas && s.sas.isVerified;
});
if (previewMode) {
const previewMessages = [
{ message: "Preview mode \u2014 no connection is open.", type: "system", id: 1, timestamp: Date.now() - 3e5 },
{ message: "This renders the real chat components so the layout can be checked without a handshake.", type: "received", id: 2, timestamp: Date.now() - 24e4 },
{ message: "Header pinned, list scrolls, composer sits above the keyboard.", type: "sent", id: 3, timestamp: Date.now() - 18e4 },
...Array.from({ length: 30 }, (_, i) => ({
message: "Filler message " + (i + 1) + " \u2014 enough content to make the list scroll.",
type: i % 2 ? "sent" : "received",
id: 10 + i,
timestamp: Date.now() - (30 - i) * 5e3
}))
];
const noop = () => {
};
return React.createElement("div", {
className: "minimal-bg sb-app-shell",
style: { display: "flex", flexDirection: "row", height: "100vh", width: "100%", overflow: "hidden" }
}, [
React.createElement(SessionsSidebar, {
key: "sidebar",
chats: [{ id: "preview", label: "Preview peer", unread: 0, active: true, verified: true }],
collapsed: sidebarCollapsed,
drawerOpen: sidebarDrawerOpen,
onToggleCollapse: () => setSidebarCollapsed((v) => !v),
onSelect: noop,
onNewChat: noop,
onRename: noop,
onCloseDrawer: () => setSidebarDrawerOpen(false),
myStatus,
onSetStatus: setMyStatus
}),
React.createElement("button", {
key: "burger",
className: "sb-burger",
onClick: () => setSidebarDrawerOpen(true),
style: { display: "none", position: "fixed", top: "13px", left: "13px", zIndex: 55, width: "38px", height: "38px", borderRadius: "10px", placeItems: "center", border: "1px solid rgba(255,255,255,0.1)", background: "rgba(18,18,20,0.9)", color: "#cfcfd4", cursor: "pointer" },
dangerouslySetInnerHTML: { __html: SB_SVG.burger }
}),
React.createElement("div", {
key: "col",
className: "minimal-bg sb-app-col",
style: { flex: 1, minWidth: 0, height: "100vh", overflow: "hidden", display: "flex", flexDirection: "column" }
}, React.createElement(
"main",
{ key: "main" },
React.createElement(EnhancedChatInterface, {
title: "Preview peer",
isOffline: false,
peerPresence: "available",
onRenameTitle: noop,
messages: previewMessages,
messageInput,
setMessageInput,
onSendMessage: noop,
onSendVoice: noop,
onDisconnect: noop,
keyFingerprint: "preview",
isVerified: true,
chatMessagesRef,
scrollToBottom: noop,
webrtcManager: null,
status: "connected",
pendingIncomingFiles: [],
onIncomingDecision: noop,
codeMode,
setCodeMode,
viewOnceMode,
setViewOnceMode,
viewOnceTtl,
setViewOnceTtl,
disappearTtl,
setDisappearTtl,
nowTick,
onUnsendMessage: noop,
onMessageExpire: noop
})
))
]);
}
return React.createElement("div", {
className: showSidebar ? "minimal-bg sb-app-shell" : "minimal-bg",
// With the rail visible the app is a fixed-height shell (rail + column
+2 -2
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,7 +7,7 @@ this document describes.
| | |
| --- | --- |
| Release | v5.9.1 |
| Release | v5.9.2 |
| Protocol version | 4.1 |
| Ratchet wire version | 1 |
@@ -238,5 +238,5 @@ worse than one that reports nothing.
## Scope
This describes the browser implementation as it stands in v5.9.1. It is not a
This describes the browser implementation as it stands in v5.9.2. It is not a
substitute for independent cryptographic review.
+27 -23
View File
@@ -2,7 +2,11 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content">
<!-- viewport-fit=cover is what makes env(safe-area-inset-*) resolve to anything
other than 0 on notched iPhones; the composer already pads for it.
interactive-widget=resizes-content is honoured by Chrome/Android; iOS
ignores it, which is why the shell also tracks visualViewport in JS. -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content">
<meta http-equiv="Content-Security-Policy"
content="default-src 'self';
script-src 'self';
@@ -24,7 +28,7 @@
<!-- PWA Manifest -->
<link rel="manifest" href="./manifest.json">
<link rel="icon" type="image/x-icon" href="./logo/favicon.ico?v=1786072827999">
<link rel="icon" type="image/x-icon" href="./logo/favicon.ico?v=1786294476636">
<!-- PWA Meta Tags -->
<meta name="mobile-web-app-capable" content="yes">
@@ -90,7 +94,7 @@
<link rel="apple-touch-startup-image" media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="./logo/splash/splash_screens/8.3__iPad_Mini_portrait.png">
<!-- Apple Touch Icons -->
<link rel="apple-touch-icon" href="./logo/icon-180x180.png?v=1786072827999">
<link rel="apple-touch-icon" href="./logo/icon-180x180.png?v=1786294476636">
<link rel="apple-touch-icon" sizes="57x57" href="./logo/icon-57x57.png">
<link rel="apple-touch-icon" sizes="60x60" href="./logo/icon-60x60.png">
<link rel="apple-touch-icon" sizes="72x72" href="./logo/icon-72x72.png">
@@ -99,7 +103,7 @@
<link rel="apple-touch-icon" sizes="120x120" href="./logo/icon-120x120.png">
<link rel="apple-touch-icon" sizes="144x144" href="./logo/icon-144x144.png">
<link rel="apple-touch-icon" sizes="152x152" href="./logo/icon-152x152.png">
<link rel="apple-touch-icon" sizes="180x180" href="./logo/icon-180x180.png?v=1786072827999">
<link rel="apple-touch-icon" sizes="180x180" href="./logo/icon-180x180.png?v=1786294476636">
<!-- Microsoft Tiles -->
<meta name="msapplication-TileColor" content="#ff6b35">
@@ -183,7 +187,7 @@
<!-- Render-blocking JS is deferred: classic deferred scripts and module scripts
both execute in document order after parsing, so React still runs before the
app modules below, but the parser / first paint is no longer blocked. -->
<script defer src="config/ice-servers.js?v=1786072827999"></script>
<script defer src="config/ice-servers.js?v=1786294476636"></script>
<script defer src="libs/react/react.production.min.js"></script>
<script defer src="libs/react-dom/react-dom.production.min.js"></script>
<!-- Prism syntax highlighting (vendored, offline). Tokenizes code as TEXT only —
@@ -191,8 +195,8 @@
Its CSS is loaded async via load-async-css.js (not paint-critical). -->
<script defer src="libs/prism/prism.js"></script>
<!-- Critical, paint-defining CSS stays render-blocking (avoids FOUC / layout shift). -->
<link rel="stylesheet" href="assets/tailwind.css?v=1786072827999">
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1786072827999">
<link rel="stylesheet" href="assets/tailwind.css?v=1786294476636">
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1786294476636">
<!-- Preload only the fonts needed for first paint. fa-solid covers the bulk of UI
icons; fa-regular/fa-brands are loaded on demand by their CSS (rarely on the
first screen). Inter latin 400/700 cover body text and headings/buttons. -->
@@ -200,31 +204,31 @@
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-400.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-700.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/assets/fonts/inter/inter.css">
<link rel="stylesheet" href="src/styles/main.css?v=1786072827999">
<link rel="stylesheet" href="src/styles/animations.css?v=1786072827999">
<link rel="stylesheet" href="src/styles/components.css?v=1786072827999">
<link rel="stylesheet" href="src/styles/main.css?v=1786294476636">
<link rel="stylesheet" href="src/styles/animations.css?v=1786294476636">
<link rel="stylesheet" href="src/styles/components.css?v=1786294476636">
<!-- Non-critical CSS (FontAwesome ~102KB, Prism) loaded async — no longer blocks paint. -->
<script defer src="src/scripts/load-async-css.js?v=1786072827999"></script>
<script defer src="src/scripts/load-async-css.js?v=1786294476636"></script>
<noscript>
<link rel="stylesheet" href="/assets/fontawesome/css/all.min.css">
<link rel="stylesheet" href="libs/prism/prism.css">
</noscript>
<script defer src="src/scripts/fa-check.js?v=1786072827999"></script>
<script defer src="src/scripts/fa-check.js?v=1786294476636"></script>
<!-- Update Manager - система принудительного обновления -->
<script defer src="src/utils/updateManager.js?v=1786072827999"></script>
<script type="module" src="src/components/UpdateChecker.jsx?v=1786072827999"></script>
<script type="module" src="dist/qr-local.js?v=1786072827999"></script>
<script type="module" src="src/components/QRScanner.js?v=1786072827999"></script>
<script defer src="src/utils/updateManager.js?v=1786294476636"></script>
<script type="module" src="src/components/UpdateChecker.jsx?v=1786294476636"></script>
<script type="module" src="dist/qr-local.js?v=1786294476636"></script>
<script type="module" src="src/components/QRScanner.js?v=1786294476636"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="dist/app-boot.js?v=1786072827999"></script>
<script type="module" src="dist/app.js?v=1786072827999"></script>
<script type="module" src="dist/app-boot.js?v=1786294476636"></script>
<script type="module" src="dist/app.js?v=1786294476636"></script>
<script defer src="src/scripts/pwa-register.js?v=1786072827999"></script>
<script src="./src/pwa/install-prompt.js?v=1786072827999" type="module"></script>
<script src="./src/pwa/pwa-manager.js?v=1786072827999" type="module"></script>
<script defer src="./src/scripts/pwa-offline-test.js?v=1786072827999"></script>
<link rel="stylesheet" href="./src/styles/pwa.css?v=1786072827999">
<script defer src="src/scripts/pwa-register.js?v=1786294476636"></script>
<script src="./src/pwa/install-prompt.js?v=1786294476636" type="module"></script>
<script src="./src/pwa/pwa-manager.js?v=1786294476636" type="module"></script>
<script defer src="./src/scripts/pwa-offline-test.js?v=1786294476636"></script>
<link rel="stylesheet" href="./src/styles/pwa.css?v=1786294476636">
</body>
</html>
+7 -7
View File
@@ -1,10 +1,10 @@
{
"version": "1786072827999",
"buildVersion": "1786072827999",
"appVersion": "5.9.1",
"buildTime": "2026-08-07T03:20:28.043Z",
"buildId": "1786072827999-808fd99",
"gitHash": "808fd99",
"version": "1786294476636",
"buildVersion": "1786294476636",
"appVersion": "5.9.2",
"buildTime": "2026-08-09T16:54:36.685Z",
"buildId": "1786294476636-556727e",
"gitHash": "556727e",
"generated": true,
"generatedAt": "2026-08-07T03:20:28.044Z"
"generatedAt": "2026-08-09T16:54:36.686Z"
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "securebit-chat",
"version": "5.9.1",
"version": "5.9.2",
"description": "Secure P2P Communication Application with End-to-End Encryption",
"main": "index.html",
"scripts": {
@@ -11,7 +11,7 @@
"dev": "npm run build && python -m http.server 8000",
"watch": "npx tailwindcss -i src/styles/tw-input.css -o assets/tailwind.css --watch",
"serve": "npx http-server -p 8000",
"test": "node tests/sas-verification.test.mjs && node tests/verification-gate.test.mjs && node tests/inbound-frame-authentication.test.mjs && node tests/control-frame-authorization.test.mjs && node tests/security-level-shape.test.mjs && node tests/desktop-download-links.test.mjs && node tests/file-transfer-consent.test.mjs && node tests/incoming-message-sanitization.test.mjs && node tests/outgoing-message-integrity.test.mjs && node tests/secure-chat-features.test.mjs && node tests/notification-meta-forwarding.test.mjs && node tests/notification-ephemeral-privacy.test.mjs && node tests/key-derivation-compat.test.mjs && node tests/key-exchange-e2e.test.mjs && node tests/file-type-allowlist.test.mjs && node tests/voice-auto-accept.test.mjs && node tests/legacy-offer-purge.test.mjs && node tests/webrtc-privacy-mode.test.mjs && node tests/indexeddb-metadata-encryption.test.mjs && node tests/disconnect-cleanup.test.mjs && node tests/timer-lifecycle.test.mjs && node tests/file-transfer-cleanup.test.mjs && node tests/file-transfer-ui-cleanup.test.mjs && node tests/file-transfer-callback-propagation.test.mjs && node tests/debug-window-hooks.test.mjs && node tests/inbound-message-rate-limit.test.mjs && node tests/file-transfer-chunk-rate-limit.test.mjs && node tests/ice-servers-validation.test.mjs && node tests/sessions-reducer.test.mjs && node tests/webrtc-sdp.test.mjs && node tests/webrtc-video.test.mjs && node tests/webrtc-adaptation.test.mjs && node tests/session-recovery.test.mjs && node tests/qr-zip-bomb.test.mjs && node tests/ice-gathering-patience.test.mjs && node tests/version-consistency.test.mjs && node tests/double-ratchet.test.mjs && node tests/ratchet-integration.test.mjs && node tests/descriptor-sbq2.test.mjs && node tests/sbq2-key-exchange.test.mjs && node tests/qr-scan-single-frame.test.mjs"
"test": "node tests/sas-verification.test.mjs && node tests/verification-gate.test.mjs && node tests/inbound-frame-authentication.test.mjs && node tests/control-frame-authorization.test.mjs && node tests/security-level-shape.test.mjs && node tests/desktop-download-links.test.mjs && node tests/file-transfer-consent.test.mjs && node tests/incoming-message-sanitization.test.mjs && node tests/outgoing-message-integrity.test.mjs && node tests/secure-chat-features.test.mjs && node tests/notification-meta-forwarding.test.mjs && node tests/notification-ephemeral-privacy.test.mjs && node tests/key-derivation-compat.test.mjs && node tests/key-exchange-e2e.test.mjs && node tests/file-type-allowlist.test.mjs && node tests/voice-auto-accept.test.mjs && node tests/legacy-offer-purge.test.mjs && node tests/webrtc-privacy-mode.test.mjs && node tests/indexeddb-metadata-encryption.test.mjs && node tests/disconnect-cleanup.test.mjs && node tests/timer-lifecycle.test.mjs && node tests/file-transfer-cleanup.test.mjs && node tests/file-transfer-ui-cleanup.test.mjs && node tests/file-transfer-callback-propagation.test.mjs && node tests/debug-window-hooks.test.mjs && node tests/inbound-message-rate-limit.test.mjs && node tests/file-transfer-chunk-rate-limit.test.mjs && node tests/ice-servers-validation.test.mjs && node tests/sessions-reducer.test.mjs && node tests/webrtc-sdp.test.mjs && node tests/webrtc-video.test.mjs && node tests/webrtc-adaptation.test.mjs && node tests/session-recovery.test.mjs && node tests/qr-zip-bomb.test.mjs && node tests/ice-gathering-patience.test.mjs && node tests/version-consistency.test.mjs && node tests/double-ratchet.test.mjs && node tests/ratchet-integration.test.mjs && node tests/descriptor-sbq2.test.mjs && node tests/sbq2-key-exchange.test.mjs && node tests/qr-scan-single-frame.test.mjs && node tests/mobile-chat-layout.test.mjs"
},
"keywords": [
"p2p",
+154 -9
View File
@@ -2432,7 +2432,7 @@ import {
})
);
const composer = React.createElement('footer', { key: 'composer', style: { flex: 'none', padding: '12px 20px calc(18px + env(safe-area-inset-bottom, 0px))', background: '#0f0f11', borderTop: '1px solid rgba(255,255,255,0.05)' } },
const composer = React.createElement('footer', { key: 'composer', style: { flex: 'none', padding: '12px 20px calc(18px + var(--sb-safe-bottom, env(safe-area-inset-bottom, 0px)))', background: '#0f0f11', borderTop: '1px solid rgba(255,255,255,0.05)' } },
React.createElement('div', { style: { maxWidth: '1000px', margin: '0 auto' } },
isRecording
? [recordingBar]
@@ -2671,7 +2671,56 @@ import {
// App-shell height tracks the *visual* viewport (--sb-vh, set from the
// VisualViewport API) so the layout shrinks when the on-screen keyboard
// opens no grey gap under the composer. Falls back to 100dvh, then 100vh.
'.sb-app-shell{height:var(--sb-vh,100dvh) !important;}.sb-app-col{height:var(--sb-vh,100dvh) !important;}.chat-container{height:var(--sb-vh,100dvh) !important;}' +
// The shell is sized by HEIGHT, not pinned by position.
//
// An earlier attempt used position:fixed with a JS-driven top
// offset. That is the wrong instrument: a fixed element is laid
// out against the layout viewport, which iOS does not shrink for
// the keyboard, so it has to be chased with JS and the result
// feels nailed down rather than laid out. The reference iOS chat
// implementations all drive HEIGHT from visualViewport and leave
// positioning alone.
//
// --sb-vh is the visual viewport height; 100dvh is the fallback,
// which is correct on its own everywhere the layout viewport does
// resize for the keyboard (Chrome/Firefox on Android, via
// interactive-widget=resizes-content). WebKit has not implemented
// interactive-widget at all, which is exactly why the JS fallback
// still exists.
//
// min-height:0 is what makes the height above mean anything.
// The shell also carries .minimal-bg, which sets
// min-height:100vh and min-height always beats height. On iOS
// `100vh` is the URL-bar-retracted *large* viewport, so with the
// bar showing the shell was pinned ~60-100px taller than the area
// you can actually see. That surplus is precisely what made the
// document scrollable, and a document that scrolls is how the
// header rode away off the top. Everything below the visual
// viewport tracking, the dvh fallback was inert until this
// floor was removed: --sb-vh could shrink all it liked and the
// shell would not follow it below 100vh.
'.sb-app-shell{height:var(--sb-vh,100dvh) !important;min-height:0 !important;overflow:hidden;}' +
// Pin the header, and nothing else.
//
// Sticky is the whole fix: if an ancestor scrolls which is what
// iOS does to lift a focused input above the keyboard the header
// holds against the top instead of riding away with the page. An
// earlier version answered this by taking the document's scroll
// away altogether; that broke the connection screen and was a far
// broader change than the problem called for.
'.sb-chat-header{position:sticky;top:0;z-index:20;}' +
// Children fill the shell rather than restating a viewport height,
// which would count the header twice.
// The column carries .minimal-bg too, so it needs the same floor
// removed !important rather than relying on this block winning
// on source order, because it is load-bearing.
'.sb-app-col{height:100% !important;min-height:0 !important;}' +
'.chat-container{height:100% !important;min-height:0 !important;}' +
// The message list is the only scroller. min-height:0 is not
// cosmetic: a flex:1 item in a column defaults to min-height:auto,
// refuses to shrink below its content, and pushes the composer off
// the bottom once the conversation is long enough.
'.sb-scroll{min-height:0 !important;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;}' +
// iOS Safari zooms the page when a focused field has font-size < 16px.
// Force 16px on mobile inputs to stop the zoom-and-reflow on tap.
'@media (max-width:768px){textarea,input,select{font-size:16px !important;}}' +
@@ -2879,16 +2928,35 @@ import {
// var (--sb-vh) that the app shell uses, so the layout tracks the keyboard.
React.useEffect(() => {
const vv = (typeof window !== 'undefined') ? window.visualViewport : null;
const apply = () => {
const h = vv ? vv.height : (window.innerHeight || 0);
if (h) document.documentElement.style.setProperty('--sb-vh', h + 'px');
const root = document.documentElement;
let lastH = -1, lastInset = '';
// HEIGHT changes only on resize. It deliberately does NOT follow
// `scroll`: on iOS the visual viewport also moves while the URL bar
// collapses and during rubber-banding, so resizing the shell on every
// scroll event made the whole layout twitch under the finger which
// is most of what "the chat layout jumps" was.
const applyHeight = () => {
const h = Math.round(vv ? vv.height : (window.innerHeight || 0));
if (h && h !== lastH) { lastH = h; root.style.setProperty('--sb-vh', h + 'px'); }
};
// The home-indicator inset must collapse while the keyboard is up.
// Once the shell has shrunk to the visible area, that padding is no
// longer clearing the indicator it is just dead space between the
// composer and the keyboard.
const applyInset = () => {
const covered = !!vv && (window.innerHeight - vv.height) > 120;
const v = covered ? '0px' : 'env(safe-area-inset-bottom, 0px)';
if (v !== lastInset) { lastInset = v; root.style.setProperty('--sb-safe-bottom', v); }
};
const apply = () => { applyHeight(); applyInset(); };
apply();
if (vv) { vv.addEventListener('resize', apply); vv.addEventListener('scroll', apply); }
if (vv) vv.addEventListener('resize', apply);
window.addEventListener('resize', apply);
window.addEventListener('orientationchange', apply);
return () => {
if (vv) { vv.removeEventListener('resize', apply); vv.removeEventListener('scroll', apply); }
if (vv) vv.removeEventListener('resize', apply);
window.removeEventListener('resize', apply);
window.removeEventListener('orientationchange', apply);
};
@@ -5425,14 +5493,21 @@ import {
// conversation must stay on screen. Throwing the user back to the connect
// screen for a two-second NAT rebind would defeat the recovery entirely
// the composer keeps working and queues (see the send path's offlineNow).
// `?preview=chat` renders the chat layout with no connection behind it.
// Declared here because the in-chat body class below depends on it.
const previewMode = React.useMemo(() => {
try { return new URLSearchParams(window.location.search).get('preview') === 'chat'; }
catch { return false; }
}, []);
const isConnectedAndVerified = (connectionStatus === 'connected' || connectionStatus === 'verified' || connectionStatus === 'reconnecting') && isVerified;
// The PWA "Install app" pill is a landing-page affordance hide it once
// we're inside the chat (CSS: body.sb-in-chat #pwa-install-button).
React.useEffect(() => {
document.body.classList.toggle('sb-in-chat', isConnectedAndVerified);
document.body.classList.toggle('sb-in-chat', isConnectedAndVerified || previewMode);
return () => document.body.classList.remove('sb-in-chat');
}, [isConnectedAndVerified]);
}, [isConnectedAndVerified, previewMode]);
React.useEffect(() => {
// All security features are enabled by default - no session activation needed
@@ -5554,6 +5629,76 @@ import {
return s && s.sas && s.sas.isVerified;
});
// Stop the document scrolling while the app shell is up, so the header
// cannot ride off the top. Applied from JS rather than a :has() selector
// because this is load-bearing, and removed again on the landing page,
// which must keep scrolling normally.
// LAYOUT PREVIEW
// `?preview=chat` renders the real chat components inside the real
// shell with canned content and no WebRTC, so the layout can be
// looked at and iterated on without standing up a connection each
// time. It is presentational only: no peer manager, no keys, no
// network, nothing sendable the send handlers are no-ops. It
// cannot be reached by accident, and it is not a way around
// verification, because there is no session behind it to reach.
if (previewMode) {
const previewMessages = [
{ message: 'Preview mode — no connection is open.', type: 'system', id: 1, timestamp: Date.now() - 300000 },
{ message: 'This renders the real chat components so the layout can be checked without a handshake.', type: 'received', id: 2, timestamp: Date.now() - 240000 },
{ message: 'Header pinned, list scrolls, composer sits above the keyboard.', type: 'sent', id: 3, timestamp: Date.now() - 180000 },
...Array.from({ length: 30 }, (_, i) => ({
message: 'Filler message ' + (i + 1) + ' — enough content to make the list scroll.',
type: i % 2 ? 'sent' : 'received',
id: 10 + i,
timestamp: Date.now() - (30 - i) * 5000
}))
];
const noop = () => {};
return React.createElement('div', {
className: 'minimal-bg sb-app-shell',
style: { display: 'flex', flexDirection: 'row', height: '100vh', width: '100%', overflow: 'hidden' }
}, [
React.createElement(SessionsSidebar, {
key: 'sidebar',
chats: [{ id: 'preview', label: 'Preview peer', unread: 0, active: true, verified: true }],
collapsed: sidebarCollapsed, drawerOpen: sidebarDrawerOpen,
onToggleCollapse: () => setSidebarCollapsed(v => !v),
onSelect: noop, onNewChat: noop, onRename: noop,
onCloseDrawer: () => setSidebarDrawerOpen(false),
myStatus: myStatus, onSetStatus: setMyStatus
}),
React.createElement('button', {
key: 'burger', className: 'sb-burger',
onClick: () => setSidebarDrawerOpen(true),
style: { display: 'none', position: 'fixed', top: '13px', left: '13px', zIndex: 55, width: '38px', height: '38px', borderRadius: '10px', placeItems: 'center', border: '1px solid rgba(255,255,255,0.1)', background: 'rgba(18,18,20,0.9)', color: '#cfcfd4', cursor: 'pointer' },
dangerouslySetInnerHTML: { __html: SB_SVG.burger }
}),
React.createElement('div', {
key: 'col', className: 'minimal-bg sb-app-col',
style: { flex: 1, minWidth: 0, height: '100vh', overflow: 'hidden', display: 'flex', flexDirection: 'column' }
}, React.createElement('main', { key: 'main' },
React.createElement(EnhancedChatInterface, {
title: 'Preview peer', isOffline: false, peerPresence: 'available',
onRenameTitle: noop,
messages: previewMessages,
messageInput: messageInput, setMessageInput: setMessageInput,
onSendMessage: noop, onSendVoice: noop, onDisconnect: noop,
keyFingerprint: 'preview', isVerified: true,
chatMessagesRef: chatMessagesRef, scrollToBottom: noop,
webrtcManager: null, status: 'connected',
pendingIncomingFiles: [], onIncomingDecision: noop,
codeMode: codeMode, setCodeMode: setCodeMode,
viewOnceMode: viewOnceMode, setViewOnceMode: setViewOnceMode,
viewOnceTtl: viewOnceTtl, setViewOnceTtl: setViewOnceTtl,
disappearTtl: disappearTtl, setDisappearTtl: setDisappearTtl,
nowTick: nowTick, onUnsendMessage: noop, onMessageExpire: noop
})
))
]);
}
return React.createElement('div', {
className: showSidebar ? "minimal-bg sb-app-shell" : "minimal-bg",
// With the rail visible the app is a fixed-height shell (rail + column
+7
View File
@@ -794,6 +794,13 @@ button i {
@media (max-width: 560px) { .sb-start-left { padding: 30px 22px !important; } }
/* PWA install pill belongs to the landing page only — hide it inside the chat. */
body.sb-in-chat #pwa-install-button { display: none !important; }
/* The online/offline toast is `fixed top-4 left-1/2`, which inside the chat puts it
squarely on top of the 64px header it covered the peer name. Drop it below the
header there, and keep it clear of the notch. It stays where it is on the landing
page, which has no header of its own to collide with. */
body.sb-in-chat #pwa-connection-status {
top: calc(64px + 12px + env(safe-area-inset-top, 0px)) !important;
}
/* The new design spaces icons with flex gap, not icon margins neutralise the
global `button i { margin-right: .5rem }` so icons stay centered in their tiles. */
.sb-start button i, .sb-ice-overlay button i { margin-right: 0; vertical-align: baseline; }
+1 -1
View File
@@ -11,7 +11,7 @@ let DYNAMIC_CACHE = 'securebit-pwa-dynamic-v4.7.56';
// Build stamp — rewritten by scripts/post-build.js on every release so this file's
// bytes change each deploy. That is what makes the browser detect a new Service Worker,
// reinstall it, drop stale caches and (via controllerchange) prompt the page to update.
const SW_BUILD_VERSION = '1786072827999';
const SW_BUILD_VERSION = '1786294476636';
// Load version from meta.json on install
async function getAppVersion() {
+173
View File
@@ -0,0 +1,173 @@
// Invariants that keep the chat from shifting on mobile, iOS in particular.
//
// Every assertion here corresponds to something that was actually wrong:
//
// - `env(safe-area-inset-*)` was already used by the composer but resolved to 0,
// because the viewport meta lacked `viewport-fit=cover`.
// - A first attempt pinned the shell with position:fixed and chased
// visualViewport.offsetTop from JS. That is the wrong instrument: a fixed box
// is laid out against the layout viewport, which iOS never shrinks for the
// keyboard, so it must be chased forever — and it felt nailed down rather
// than laid out. The shell is sized from the visual viewport instead.
// - `--sb-vh` was rewritten on every `visualViewport` *scroll* event, so the
// whole layout resized under the finger while the iOS URL bar collapsed.
// - Children re-asserted a full viewport height, adding the header back.
// - The message list is `flex: 1` in a column, which defaults to
// `min-height: auto` — it refuses to shrink below its content and pushes the
// composer off the bottom once the conversation is long enough.
//
// These are structural checks. The layout itself is measured against a real
// connection in a real browser; see the notes in the pull request.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
const app = readFileSync(new URL('../src/app.jsx', import.meta.url), 'utf8');
// ---------------------------------------------------------------------------
// safe areas
// ---------------------------------------------------------------------------
const meta = /<meta\s+name="viewport"\s+content="([^"]+)"/.exec(html);
assert.ok(meta, 'a viewport meta tag must exist');
assert.match(meta[1], /viewport-fit=cover/,
'without viewport-fit=cover, env(safe-area-inset-*) is 0 on iOS and the ' +
'composer padding that depends on it does nothing');
assert.match(meta[1], /width=device-width/);
assert.ok(app.includes('env(safe-area-inset-bottom'),
'the composer must pad for the home indicator');
// ---------------------------------------------------------------------------
// the shell is sized, not pinned
//
// An earlier fix pinned the shell with position:fixed and chased the visual
// viewport offset from JS. That is the wrong instrument — the reference iOS chat
// implementations drive HEIGHT from visualViewport and leave positioning alone —
// and it made the layout feel nailed down instead of laid out.
// ---------------------------------------------------------------------------
assert.match(app, /\.sb-app-shell\{height:var\(--sb-vh,100dvh\) !important/,
'the shell must be sized from the visual viewport, with a dvh fallback');
// The floor has to come off, or the height above is decorative.
//
// The shell and the column both carry .minimal-bg, which sets min-height:100vh
// (src/styles/components.css, src/styles/main.css) — and min-height always beats
// height. On iOS `100vh` is the URL-bar-retracted *large* viewport, so with the bar
// showing the shell was held ~60-100px taller than the visible area; that surplus
// made the document scrollable, and a scrolling document is how the header rode off
// the top. Measured before the fix: with --sb-vh forced to 700px the shell stayed
// 844px. After: 700px, and the document is not scrollable.
assert.match(app, /\.sb-app-shell\{[^}]*min-height:0 !important/,
'the shell must clear the min-height:100vh it inherits from .minimal-bg, or ' +
'--sb-vh can never shrink it and the whole viewport-tracking path is inert');
assert.match(app, /\.sb-app-col\{[^}]*min-height:0 !important/,
'the column carries .minimal-bg too and needs the same floor removed');
assert.ok(!/\.sb-app-shell\{position:fixed/.test(app),
'the shell must not be position:fixed: a fixed box is laid out against the ' +
'layout viewport, which iOS never shrinks for the keyboard, so it has to be ' +
'chased with JS forever');
assert.ok(!/--sb-vv-top/.test(app),
'chasing visualViewport.offsetTop belongs to the pinned approach and should ' +
'be gone with it');
assert.ok(!/sb-scroll-locked/.test(app),
'the body scroll lock existed only to stop rubber-banding behind a pinned ' +
'shell; without the pin it just breaks normal scrolling');
// Children fill the shell; they must not restate a viewport height.
for (const sel of ['.sb-app-col', '.chat-container']) {
const re = new RegExp(`\\${sel}\\{height:100% !important`);
assert.match(app, re, `${sel} must fill the shell, not re-assert a viewport height`);
assert.ok(!new RegExp(`\\${sel}\\{height:var\\(--sb-vh`).test(app),
`${sel} must not set a viewport height of its own — nested full-height ` +
'boxes are how the header got counted twice');
}
// ---------------------------------------------------------------------------
// the header stays at the top
//
// Sticky, and nothing more. An earlier attempt pinned the header by removing the
// document's ability to scroll (html/body overflow:hidden). That is a far broader
// change than the problem called for: it also killed scrolling on the connection
// screen. The layout was already right at that point — the header was the only
// outstanding item — and the lesson is to keep the fix the size of the problem.
// ---------------------------------------------------------------------------
assert.match(app, /\.sb-chat-header\{position:sticky;top:0/,
'the header must stick to the top of whatever scrolls');
assert.ok(!/sb-app-open/.test(app),
'the document scroll lock must stay gone: it broke the connection screen and ' +
'was never what the header needed');
assert.ok(!/html[^{]*\{[^}]*overflow:hidden/.test(app),
'nothing may take the document\'s scroll away');
// ---------------------------------------------------------------------------
// the message list is the only scroller
// ---------------------------------------------------------------------------
assert.match(app, /\.sb-scroll\{min-height:0 !important/,
'a flex:1 item in a column needs min-height:0 or it pushes the composer off ' +
'the bottom of the screen');
assert.match(app, /\.sb-scroll\{[^}]*overscroll-behavior:contain/,
'the list must not chain its scroll to the document');
// ---------------------------------------------------------------------------
// the home-indicator inset collapses while the keyboard is up
// ---------------------------------------------------------------------------
assert.match(app, /--sb-safe-bottom/,
'the safe-area inset must be a variable so it can collapse with the keyboard');
assert.match(app, /var\(--sb-safe-bottom, env\(safe-area-inset-bottom, 0px\)\)/,
'the composer must use the toggled inset, falling back to the raw env()');
// ---------------------------------------------------------------------------
// viewport tracking: resize only, never scroll
// ---------------------------------------------------------------------------
{
const i = app.indexOf('const applyHeight = ');
assert.notEqual(i, -1, 'height tracking must exist');
const block = app.slice(i, i + 2500);
assert.match(block, /vv\.addEventListener\('resize', apply\)/,
'height must be recomputed on resize');
assert.ok(!/vv\.addEventListener\('scroll'/.test(block),
'nothing may be recomputed on visualViewport scroll: on iOS that fires ' +
'while the URL bar collapses and during rubber-banding, and resizing the ' +
'shell there is what made the layout twitch under the finger');
// Redundant writes cause a style recalculation on every event.
assert.match(block, /h !== lastH/, 'skip no-op height writes');
assert.match(block, /Math\.round/, 'sub-pixel churn must be rounded away');
}
// ---------------------------------------------------------------------------
// layout preview
// ---------------------------------------------------------------------------
assert.match(app, /get\('preview'\) === 'chat'/,
'?preview=chat must render the chat layout without a connection');
assert.match(app, /webrtcManager: null/,
'the preview must not be given a peer manager');
{
const i = app.indexOf('if (previewMode) {');
assert.notEqual(i, -1, 'the preview branch must exist');
const branch = app.slice(i, app.indexOf('return React.createElement(\'div\', {\n className: showSidebar', i) + 1 || i + 6000);
assert.ok(!/onSendMessage: handleSendMessage/.test(branch),
'the preview must not wire real send handlers');
}
// ---------------------------------------------------------------------------
// the connection toast must not sit on the header
//
// It is `fixed top-4 left-1/2` (src/pwa/pwa-manager.js), which inside the chat
// lands on the 64px header and covers the peer name.
// ---------------------------------------------------------------------------
{
const css = readFileSync(new URL('../src/styles/components.css', import.meta.url), 'utf8');
assert.match(css, /body\.sb-in-chat #pwa-connection-status\s*\{[^}]*top:\s*calc\(64px/,
'the online/offline toast must clear the chat header');
}
// ---------------------------------------------------------------------------
// iOS focus zoom
// ---------------------------------------------------------------------------
assert.match(app, /textarea,input,select\{font-size:16px !important/,
'iOS zooms the page when a focused field is under 16px, which reflows ' +
'everything and looks like the layout jumping');
console.log('mobile-chat-layout: all assertions passed');