| index 077aac6..47ee55d 100644 |
| --- a/ios/LChat/Views/ChatView.swift |
| +++ b/ios/LChat/Views/ChatView.swift |
| @@ -26,6 +26,8 @@ struct ChatView: View { |
| @State private var showPhotos = false |
| @State private var pickerItem: PhotosPickerItem? |
| @State private var attachError: String? |
| + @State private var fullFrame: CGRect = .zero // the full-bleed webview, global coords |
| + @State private var safeFrame: CGRect = .zero // the safe region between the bars |
| |
| let instance: SavedInstance |
| let thread: Thread |
| @@ -47,16 +49,32 @@ struct ChatView: View { |
| ChatFeedWebView(instance: instance, thread: thread, myUserId: myUserId, |
| instanceKeyB64: instanceKeyB64, origin: url, bridge: bridge) |
| .ignoresSafeArea() |
| + // The webview is the full-bleed reference surface: its own |
| + // global frame is what the insets are measured against (never |
| + // UIScreen.main, which disagrees under Display Zoom). |
| + .overlay { |
| + GeometryReader { geo in |
| + Color.clear.onChange(of: geo.frame(in: .global), initial: true) { _, f in |
| + fullFrame = f |
| + pushInsets() |
| + } |
| + } |
| + // Children of a full-bleed view are STILL safe-area |
| + // constrained — without this the "full" frame equals the |
| + // safe frame and every inset computes to zero. |
| + .ignoresSafeArea() |
| + .allowsHitTesting(false) |
| + } |
| // The probe spans the safe region (between the glass bars, above the |
| - // keyboard); the bars' heights are the gaps between its global frame |
| - // and the screen edges. A full-bleed GeometryReader can't do this — |
| + // keyboard); the bars' heights are the gaps between its frame and |
| + // the webview's. A full-bleed GeometryReader can't self-report this — |
| // once it ignores the safe area, it reports zero insets. |
| GeometryReader { geo in |
| let frame = geo.frame(in: .global) |
| Color.clear |
| .onChange(of: frame, initial: true) { _, f in |
| - let screenH = UIScreen.main.bounds.height |
| - bridge.setInsets(top: max(0, f.minY), bottom: max(0, screenH - f.maxY)) |
| + safeFrame = f |
| + pushInsets() |
| } |
| } |
| .allowsHitTesting(false) |
| @@ -87,6 +105,12 @@ struct ChatView: View { |
| } |
| } |
| |
| + private func pushInsets() { |
| + guard fullFrame != .zero, safeFrame != .zero else { return } |
| + bridge.setInsets(top: max(0, safeFrame.minY - fullFrame.minY), |
| + bottom: max(0, fullFrame.maxY - safeFrame.maxY)) |
| + } |
| + |
| // Toggling a picker's isPresented binding directly inside a Menu action races with |
| // the menu's own dismissal (the picker can fail to appear on Mac Catalyst). Defer |
| // the toggle until after the menu has gone. |
| @@ -277,11 +301,20 @@ final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNav |
| private var topInset: CGFloat = 0 |
| private var bottomInset: CGFloat = 0 |
| |
| + private var settle: DispatchWorkItem? |
| + |
| func setInsets(top: CGFloat, bottom: CGFloat) { |
| guard top != topInset || bottom != bottomInset else { return } |
| topInset = top |
| bottomInset = bottom |
| applyInsets() |
| + // 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. |
| + settle?.cancel() |
| + let task = DispatchWorkItem { [weak self] in self?.applyInsets() } |
| + settle = task |
| + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: task) |
| } |
| |
| private func applyInsets() { |