fix(mobile): make the chat layout responsive on phones; release v5.9.2
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:
@@ -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 60–100px 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
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
No accounts. No servers storing your messages. No installation required.
|
||||
|
||||
[](LICENSE)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](#install-as-an-app)
|
||||
[](#security-model)
|
||||
[](#forward-secrecy)
|
||||
|
||||
Vendored
+1
-1
@@ -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}`;
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+111
-14
@@ -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
|
||||
|
||||
Vendored
+2
-2
File diff suppressed because one or more lines are too long
+2
-2
@@ -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
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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');
|
||||
Reference in New Issue
Block a user