From 12e62db76fd9b12795ae75091959699c70020f8f Mon Sep 17 00:00:00 2001 From: lockbitchat Date: Sat, 12 Sep 2026 01:25:45 -0400 Subject: [PATCH] v6.8.2: the keyboard no longer pushes the chat off the screen on iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from an iPhone, with a screenshot: tap the message box, the keyboard comes up, and the whole app has slid off the top — header gone, only the bottom edge of the composer left, black underneath it down to the keyboard. That is the visual viewport being panned. WebKit shrinks it for the keyboard and then moves it down inside the layout viewport (which it does not shrink) to reveal the focused field. The shell had shrunk correctly, to the top --sb-vh of the layout viewport; the window had simply moved off it. No CSS declines that pan, and it is not a document scroll, so there is no scrollTop to put back. So the shell is pinned to the visible rect: fixed to the layout viewport and translated by visualViewport.offsetTop. The earlier objection to pinning is kept where it was right — HEIGHT still moves on resize only, which is what stopped the layout twitching under the finger, and the offset rides a transform rather than `top`. The first cut of this had a guard that silently disabled it: the pan was only followed when window.innerHeight was much taller than the visual viewport, i.e. "a keyboard is covering us". That assumes iOS never resizes the layout viewport, which stopped being safe once WebKit started honouring interactive-widget. Only a pinch-zoomed page switches the pan off now, by scale, and offsetTop is believed whenever it is non-zero. The offset is also re-read across the keyboard's animation on focusin/focusout: on iOS 26 the viewport is documented not to settle back immediately on dismissal. The message list stays at the newest message across a keyboard open or close, instead of holding a scroll position from the taller layout and leaving the last few messages under the composer. Two smaller things on the same screen: The QR code closes itself once the safety number appears. It was only dismissed when both exchange flags cleared, and those stay set through verification, so a one-time credential sat on top of the code the user was meant to be reading. The disconnect button's label is hidden wherever the button becomes a 40px square. Its own rule stopped at 560px while the square starts at 768px, so between the two the word was still inside the box, pushing the icon off centre. ?vvdebug=1 paints the viewport numbers over the app — visible rect height, offset, scale, what the shell resolved to. A phone cannot be put on a debugger and none of those numbers show up in a screenshot of the result; this one is how the guard above was caught. Off unless named in the URL. Claude-Session: https://claude.ai/code/session_01R63J7dexujFQCJpDAeKxr9 --- CHANGELOG.md | 16 +++ README.md | 2 +- ar/index.html | 38 +++---- de/index.html | 38 +++---- dist/app-boot.js | 2 +- dist/app-boot.js.map | 2 +- dist/app.js | 21 ++-- dist/app.js.map | 6 +- doc/CRYPTOGRAPHY.md | 4 +- docs/cryptography/index.html | 4 +- es/index.html | 38 +++---- fa/index.html | 38 +++---- fr/index.html | 38 +++---- he/index.html | 38 +++---- hi/index.html | 38 +++---- index.html | 36 +++---- ko/index.html | 38 +++---- meta.json | 14 +-- package.json | 2 +- ru/index.html | 38 +++---- src/app.jsx | 173 ++++++++++++++++++++++++++++-- sw.js | 2 +- tests/mobile-chat-layout.test.mjs | 55 +++++++--- tests/theme-switching.test.mjs | 2 +- uk/index.html | 38 +++---- ur/index.html | 38 +++---- zh/index.html | 38 +++---- 27 files changed, 496 insertions(+), 301 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccf040f..8d8721c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## v6.8.2 — The keyboard no longer pushes the chat off the screen + +On iPhone, tapping the message box used to shove the whole chat off the top of the +screen: the header disappeared, only the bottom edge of the message box was left, and +everything under it was empty black down to the keyboard. Safari moves the visible part +of the page up to show the field you tapped, and the app was not following it. Now it +is, so the conversation stays where it is and the message box sits just above the +keyboard. The chat also stays scrolled to the newest message when the keyboard opens and +closes, instead of leaving you a screen behind. + +The QR code now closes by itself once the other side has scanned it and the security +code appears — it used to stay on top of the code you were meant to be checking. + +The disconnect button in the chat header is a clean square again on small screens; its +label was still taking up room inside it and pushing the icon off centre. + ## v6.8.1 — Desktop 1.0.3 The download buttons point at desktop 1.0.3. That release fixes video on a call diff --git a/README.md b/README.md index 2b06078..26658f7 100644 --- a/README.md +++ b/README.md @@ -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-6.8.1-3ecf8e.svg)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-6.8.2-3ecf8e.svg)](CHANGELOG.md) [![Get it from the Snap Store](https://snapcraft.io/securebit-chat/badge.svg)](https://snapcraft.io/securebit-chat) [![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) diff --git a/ar/index.html b/ar/index.html index 245dac8..84f4b27 100644 --- a/ar/index.html +++ b/ar/index.html @@ -30,18 +30,18 @@ - + - + - + @@ -116,7 +116,7 @@ - + @@ -125,7 +125,7 @@ - + @@ -264,7 +264,7 @@ - + - - + + - + - + - + - - + + - + - + - + @@ -116,7 +116,7 @@ - + @@ -125,7 +125,7 @@ - + @@ -264,7 +264,7 @@ - + - - + + - + - + - + - - + + ';\n }\n\n /* Stringify, in case dirty is an object */\n if (typeof dirty !== 'string' && !_isNode(dirty)) {\n dirty = stringifyValue(dirty);\n\n if (typeof dirty !== 'string') {\n throw typeErrorCreate('dirty is not a string, aborting');\n }\n }\n\n /* Return dirty HTML if DOMPurify cannot run */\n if (!DOMPurify.isSupported) {\n return dirty;\n }\n\n /* Assign config vars */\n if (!SET_CONFIG) {\n _parseConfig(cfg);\n }\n\n /* Clean up removed elements */\n DOMPurify.removed = [];\n\n /* Resolve IN_PLACE for this call without mutating persistent config.\n Writing the IN_PLACE closure variable here leaks under setConfig(),\n where _parseConfig is skipped on later calls: a single string call would\n disable in-place mode for every subsequent node call, returning a\n sanitized copy while leaving the caller's node \u2014 which in-place callers\n keep using and whose return value they ignore \u2014 unsanitized. REPORT-2. */\n const inPlace = IN_PLACE && typeof dirty !== 'string' && _isNode(dirty);\n\n if (inPlace) {\n /* Do some early pre-sanitization to avoid unsafe root nodes.\n Read nodeName through the cached prototype getter \u2014 a clobbering\n child named \"nodeName\" on the form root would otherwise shadow\n the property and let this check skip the root-allowlist\n validation entirely. */\n const nn = getNodeName\n ? getNodeName(dirty as Node)\n : (dirty as Node).nodeName;\n if (typeof nn === 'string') {\n const tagName = transformCaseFunc(nn);\n if (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) {\n throw typeErrorCreate(\n 'root node is forbidden and cannot be sanitized in-place'\n );\n }\n }\n\n /* Pre-flight the root through _isClobbered. The iterator-driven\n removal path can not detach a parent-less root: _forceRemove\n falls through to Element.prototype.remove(), which per spec\n is a no-op on a node with no parent. A clobbered root would\n then survive the main loop with its attributes uninspected,\n because _sanitizeAttributes early-returns on _isClobbered. The\n result would be an attacker-controlled form, complete with any\n event-handler attributes the caller passed in, handed back to\n the application unsanitized. Refuse to sanitize such a root\n the same way we refuse a forbidden tag. GHSA-r47g-fvhr-h676. */\n if (_isClobbered(dirty as Element)) {\n throw typeErrorCreate(\n 'root node is clobbered and cannot be sanitized in-place'\n );\n }\n\n /* Sanitize attached shadow roots before the main iterator runs.\n The iterator does not descend into shadow trees. Same fail-closed\n barrier as the main walk (campaign-3 F2): a custom-element reaction\n inside a shadow root could abort this pre-pass before the walk runs,\n which would otherwise leave the entire live tree unsanitized. */\n try {\n _sanitizeAttachedShadowRoots(dirty as Node);\n } catch (error) {\n _neutralizeRoot(dirty as Node);\n\n throw error;\n }\n } else if (_isNode(dirty)) {\n /* If dirty is a DOM element, append to an empty document to avoid\n elements being stripped by the parser */\n body = _initDocument('');\n importedNode = body.ownerDocument.importNode(dirty, true);\n if (\n importedNode.nodeType === NODE_TYPE.element &&\n importedNode.nodeName === 'BODY'\n ) {\n /* Node is already a body, use as is */\n body = importedNode;\n } else if (importedNode.nodeName === 'HTML') {\n body = importedNode;\n } else {\n // eslint-disable-next-line unicorn/prefer-dom-node-append\n body.appendChild(importedNode);\n }\n\n /* Clonable shadow roots are deep-cloned by importNode(); sanitize\n them before the main iterator runs, since the iterator does not\n descend into shadow trees. The walk routes every read through a\n cached prototype getter so clobbering descendants on a form root\n cannot hide a shadow host from this pass. */\n _sanitizeAttachedShadowRoots(importedNode);\n } else {\n /* Exit directly if we have nothing to do */\n if (\n !RETURN_DOM &&\n !SAFE_FOR_TEMPLATES &&\n !WHOLE_DOCUMENT &&\n // eslint-disable-next-line unicorn/prefer-includes\n dirty.indexOf('<') === -1\n ) {\n return trustedTypesPolicy && RETURN_TRUSTED_TYPE\n ? _createTrustedHTML(dirty)\n : dirty;\n }\n\n /* Initialize the document to work on */\n body = _initDocument(dirty);\n\n /* Check we have a DOM node from the data */\n if (!body) {\n return RETURN_DOM ? null : RETURN_TRUSTED_TYPE ? emptyHTML : '';\n }\n }\n\n /* Remove first element node (ours) if FORCE_BODY is set */\n if (body && FORCE_BODY) {\n _forceRemove(body.firstChild);\n }\n\n /* Get node iterator */\n const nodeIterator = _createNodeIterator(inPlace ? dirty : body);\n\n /* Now start iterating over the created document.\n The walk runs inside an exception barrier (campaign-3 F2): a re-entrant\n engine/custom-element mutation can detach a node mid-walk so\n `_forceRemove`'s parentless guard throws, aborting the loop. Without the\n barrier the caller's in-place tree would be left half-sanitized with the\n unvisited tail still armed. On any throw we fail closed \u2014 strip the\n in-place root bare \u2014 then rethrow so the existing throw contract is\n preserved. (String/DOM-copy paths never return the partial body, so the\n propagating throw is already fail-closed there.) */\n try {\n while ((currentNode = nodeIterator.nextNode())) {\n /* Sanitize tags and elements */\n _sanitizeElements(currentNode);\n\n /* Check attributes next */\n _sanitizeAttributes(currentNode);\n\n /* Shadow DOM detected, sanitize it.\n Realm-safe check (GHSA-hpcv-96wg-7vj8): nodeType-based detection\n instead of instanceof, so foreign-realm