eleven / ocho Public

ios: fix keyboard insets at the root, Chats header + search on the main index (#63)

The inset probe measured against UIScreen.main and, worse, the "full bleed"
reference overlay was itself safe-area constrained — children of a full-bleed
view still respect the safe area — so both frames matched and every inset
computed to zero: short threads sat under the composer the moment the keyboard
rose. Insets now diff the safe region against the webview's own measured frame
(Display-Zoom-proof, no UIScreen), with a settle re-apply after the keyboard
animation.

The main index gains the Signal-style header: large "Chats" title and a
search field that filters the list by chat name (mirrors the web sidebar
filter), with the standard no-results state.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 84ee99704608d4f42ecc27a849dea68316857d88 parent 65492c5
2 files changed, +54 −7
  • ios/LChat/Views/ChatView.swift +37 −4
  • ios/LChat/Views/ThreadListView.swift +17 −3
diff --git a/ios/LChat/Views/ChatView.swift b/ios/LChat/Views/ChatView.swift
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() {
diff --git a/ios/LChat/Views/ThreadListView.swift b/ios/LChat/Views/ThreadListView.swift
index ddf0d1d..411ba88 100644
--- a/ios/LChat/Views/ThreadListView.swift
+++ b/ios/LChat/Views/ThreadListView.swift
@@ -10,18 +10,32 @@ struct ThreadListView: View {
@Binding var path: [String]
@Binding var showServers: Bool
@State private var showNew = false
+ @State private var search = ""
+
+ // Mirrors the web's sidebar filter: case-insensitive match on the chat name.
+ private var visibleThreads: [Thread] {
+ let q = search.trimmingCharacters(in: .whitespaces)
+ guard !q.isEmpty else { return store.threads }
+ return store.threads.filter { $0.title.localizedCaseInsensitiveContains(q) }
+ }
var body: some View {
List {
- ForEach(store.threads) { t in
+ ForEach(visibleThreads) { t in
NavigationLink(value: t.id) { row(t) }
}
}
.listStyle(.plain)
+ .searchable(text: $search, prompt: "Search chats")
.overlay { if store.loading && store.threads.isEmpty { ProgressView() } }
.overlay { if !store.loading && store.threads.isEmpty { empty } }
- .navigationTitle(store.instanceName.isEmpty ? store.instance.displayName : store.instanceName)
- .navigationBarTitleDisplayMode(.inline)
+ .overlay {
+ if !store.threads.isEmpty && visibleThreads.isEmpty {
+ ContentUnavailableView.search(text: search)
+ }
+ }
+ .navigationTitle("Chats")
+ .navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button { withAnimation(.easeOut(duration: 0.22)) { showServers = true } } label: {