| index e20c566..e9a6ad1 100644 |
| --- a/ios/LChat/Views/ChatView.swift |
| +++ b/ios/LChat/Views/ChatView.swift |
| @@ -458,23 +458,32 @@ final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNav |
| |
| func setInsets(top: CGFloat, bottom: CGFloat) { |
| guard top != topInset || bottom != bottomInset else { return } |
| + // Re-pin the feed to the bottom only when the reserved space GROWS (keyboard |
| + // rise, composer growth) — then scrollTop must chase the now-taller content. |
| + // When it SHRINKS (the keyboard dismissing, esp. an interactive drag firing |
| + // per-frame inset changes) the browser already clamps scrollTop to the new |
| + // bottom on its own; re-pinning every frame instead races that shrink and |
| + // makes the feed flash up and down. So: grow → repin, shrink → let it be. |
| + let grew = bottom > bottomInset || top > topInset |
| topInset = top |
| bottomInset = bottom |
| - applyInsets() |
| + applyInsets(repin: grew) |
| // The keyboard animates ~0.25s of per-frame inset changes; run one more |
| - // apply after it settles so a lost race mid-animation can't strand the |
| - // feed a few px shy of the bottom. |
| + // apply (always re-pinning) after it settles so a lost race mid-animation |
| + // can't strand the feed a few px shy of the bottom. |
| settle?.cancel() |
| - let task = DispatchWorkItem { [weak self] in self?.applyInsets() } |
| + let task = DispatchWorkItem { [weak self] in self?.applyInsets(repin: true) } |
| settle = task |
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: task) |
| } |
| |
| - private func applyInsets() { |
| + private func applyInsets(repin: Bool = true) { |
| // Keep the feed pinned to the bottom through padding changes (keyboard |
| // rise, composer growth). Judge "was at bottom" BEFORE the padding |
| // mutates — measured after, the gap equals the padding just added and |
| // the check always fails, stranding the newest message under the bars. |
| + // `repin` is false while the reserved space is shrinking (a dismiss): the |
| + // browser clamps scrollTop for us, so a manual snap only causes jitter. |
| let js = """ |
| (function(){ |
| // The live chat.js feed is .chatapp-feed (id-less); the original #feed |
| @@ -484,7 +493,7 @@ final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNav |
| var r = document.documentElement.style; |
| r.setProperty('--native-top-inset','\(Int(topInset))px'); |
| r.setProperty('--native-bottom-inset','\(Int(bottomInset))px'); |
| - if (atBottom) requestAnimationFrame(function(){ f.scrollTop = f.scrollHeight; }); |
| + if (\(repin ? "true" : "false") && atBottom) requestAnimationFrame(function(){ f.scrollTop = f.scrollHeight; }); |
| })() |
| """ |
| webView?.evaluateJavaScript(js) |
| @@ -509,6 +518,9 @@ final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNav |
| NotificationCenter.default.addObserver( |
| self, selector: #selector(applyKeyboardDismiss), |
| name: UIResponder.keyboardWillShowNotification, object: nil) |
| + NotificationCenter.default.addObserver( |
| + self, selector: #selector(keyboardWillRise), |
| + name: UIResponder.keyboardWillShowNotification, object: nil) |
| } |
| |
| @objc func applyKeyboardDismiss() { |
| @@ -522,6 +534,28 @@ final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNav |
| for sub in view.subviews { setInteractiveDismiss(in: sub) } |
| } |
| |
| + // On keyboard rise the feed should re-pin to the bottom so the newest message |
| + // rests above the composer, not behind the keyboard. The per-frame inset re-pin |
| + // (applyInsets) can't manage this alone: the keyboard grows the reserved space by |
| + // ~300px in one animation, which outruns the re-pin and trips its "at bottom" |
| + // check, stranding the feed mid-rise. So: capture whether the feed was at the |
| + // bottom BEFORE the rise, and once the keyboard + inset animation has settled, |
| + // snap it to the now-taller bottom. Gated on "was at bottom" so a reader who |
| + // scrolled up into history isn't yanked down just for tapping the field. |
| + @objc func keyboardWillRise() { |
| + guard let webView, webView.window != nil else { return } |
| + let probe = "(function(){var f=document.querySelector('.chatapp-feed')||document.getElementById('feed');return f?(f.scrollHeight-f.scrollTop-f.clientHeight<120):false;})()" |
| + webView.evaluateJavaScript(probe) { [weak self] result, _ in |
| + guard (result as? Bool) == true else { return } |
| + // After the rise (~0.25s) + the inset settle (0.35s), the padding is fully |
| + // grown; read the fresh scrollHeight and land on the true bottom. |
| + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { |
| + self?.webView?.evaluateJavaScript( |
| + "(function(){var f=document.querySelector('.chatapp-feed')||document.getElementById('feed');if(f)requestAnimationFrame(function(){f.scrollTop=f.scrollHeight;});})()") |
| + } |
| + } |
| + } |
| + |
| // Message links and link-preview cards render as target="_blank" anchors |
| // (chat.js), which WebKit routes here as a new-window request, not a normal |
| // navigation — with no WKUIDelegate the tap fell on the floor and nothing |