eleven / ocho Public

ios: Signal-style floating glass bars + edge-only back swipe + auto group titles (#58)

- The feed runs full-bleed; the nav bar and composer float over it in Liquid
  Glass (iOS 26 glassEffect) with an ultraThinMaterial fallback. Native
  measures the bar/keyboard insets from the safe-region frame and injects them
  as CSS vars; .native-embed #feed pads with them (id selector — #feed's own
  rule outranks any class combo).
- iOS 26's swipe-anywhere back pop fought the feed's swipe-to-reply: disable
  interactiveContentPopGestureRecognizer so back is the classic left-edge
  swipe. Drops the unused EdgeSwipeBack.
- Unnamed groups title from member names (autoGroupTitle port, kept in
  lockstep with format.js) instead of the bare "Group" fallback.
- Screens-only UI test (TEST_RUNNER_OPEN_ONLY=1) for layout eyeballing.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 3b5f7133a650f787de49019d78f8f46cc9529f2c parent 66aa5a0
6 files changed, +202 −83
  • ios/LChat/ThreadStore.swift +17 −1
  • ios/LChat/Views/ChatView.swift +115 −25
  • ios/LChat/Views/EdgeOnlyBackGesture.swift +39 −0
  • ios/LChat/Views/EdgeSwipeBack.swift +0 −55
  • ios/LChatUITests/JoinAndChatTests.swift +21 −0
  • web/static/style.css +10 −2
diff --git a/ios/LChat/ThreadStore.swift b/ios/LChat/ThreadStore.swift
index 1529a8d..d8375e8 100644
--- a/ios/LChat/ThreadStore.swift
+++ b/ios/LChat/ThreadStore.swift
@@ -147,7 +147,23 @@ final class ThreadStore: ObservableObject {
let n = p.name, !n.isEmpty {
return n
}
- return "Group"
+ // Unnamed group: title from the other members' names — mirrors
+ // autoGroupTitle in web/static/format.js exactly.
+ let names = (t.members ?? []).filter { $0 != myUserId }
+ .compactMap { rosterLabels[$0] }.filter { !$0.isEmpty }
+ return Self.autoGroupTitle(names)
+ }
+ }
+
+ // "A" / "A & B" / "A, B & C" / "A, B & N more" — keep in lockstep with
+ // format.js's autoGroupTitle so a chat is called the same thing everywhere.
+ static func autoGroupTitle(_ names: [String]) -> String {
+ switch names.count {
+ case 0: return "Group"
+ case 1: return names[0]
+ case 2: return "\(names[0]) & \(names[1])"
+ case 3: return "\(names[0]), \(names[1]) & \(names[2])"
+ default: return "\(names[0]), \(names[1]) & \(names.count - 2) more"
}
}
}
diff --git a/ios/LChat/Views/ChatView.swift b/ios/LChat/Views/ChatView.swift
index bb8abbd..a328194 100644
--- a/ios/LChat/Views/ChatView.swift
+++ b/ios/LChat/Views/ChatView.swift
@@ -38,17 +38,37 @@ struct ChatView: View {
}
var body: some View {
- VStack(spacing: 0) {
+ ZStack {
if let url = instance.baseURL {
+ // Full-bleed: the feed scrolls under the (glass) nav bar and the
+ // floating composer. The page gets the real insets as CSS vars via
+ // the geometry probe below, so messages clear the bars + keyboard.
ChatFeedWebView(instance: instance, thread: thread, myUserId: myUserId,
instanceKeyB64: instanceKeyB64, origin: url, bridge: bridge)
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- composer
+ .ignoresSafeArea()
+ // 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 —
+ // 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))
+ }
+ }
+ .allowsHitTesting(false)
} else {
ContentUnavailableView("Can't open this chat", systemImage: "exclamationmark.triangle",
description: Text("This chat's address is invalid."))
}
}
+ .safeAreaInset(edge: .bottom, spacing: 0) {
+ if instance.baseURL != nil { composer }
+ }
+ .background(EdgeOnlyBackGesture())
+ .modifier(GlassNavigationBar())
.navigationTitle(isMac ? "" : thread.title)
.navigationBarTitleDisplayMode(.inline)
.onAppear { if isMac { composerFocused = true } }
@@ -75,6 +95,9 @@ struct ChatView: View {
// MARK: Composer (native input, web send)
+ // Floats over the feed (installed via safeAreaInset): a glass plus-button and
+ // input capsule on iOS 26, thin material before that — no opaque bar, the
+ // messages show through underneath (the Signal/Messages look).
private var composer: some View {
HStack(alignment: .bottom, spacing: 8) {
Menu {
@@ -82,35 +105,37 @@ struct ChatView: View {
} label: {
Image(systemName: "plus")
.font(.system(size: 18, weight: .medium))
- .frame(width: 32, height: 32)
- .background(Color(.secondarySystemFill), in: Circle())
+ .frame(width: 36, height: 36)
+ .floatingGlass(in: Circle())
}
.buttonStyle(.plain)
- ComposerField(
- text: $text,
- focused: $composerFocused,
- placeholder: "Message",
- sendOnReturn: isMac,
- onSend: { if canSend { send() } }
- )
- .padding(.horizontal, 12)
- .padding(.vertical, 7)
- .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 18))
- .overlay(RoundedRectangle(cornerRadius: 18).strokeBorder(Color(.separator).opacity(0.5), lineWidth: 0.5))
+ HStack(alignment: .bottom, spacing: 6) {
+ ComposerField(
+ text: $text,
+ focused: $composerFocused,
+ placeholder: "Message",
+ sendOnReturn: isMac,
+ onSend: { if canSend { send() } }
+ )
+ .padding(.leading, 14)
+ .padding(.vertical, 8)
- Button(action: send) {
- Image(systemName: "arrow.up.circle.fill")
- .font(.system(size: 28))
- .foregroundStyle(canSend ? Color.accentColor : Color(.tertiaryLabel))
+ Button(action: send) {
+ Image(systemName: "arrow.up.circle.fill")
+ .font(.system(size: 28))
+ .foregroundStyle(canSend ? Color.accentColor : Color(.tertiaryLabel))
+ }
+ .buttonStyle(.plain)
+ .disabled(!canSend)
+ .accessibilityLabel("Send")
+ .padding(.trailing, 4)
+ .padding(.bottom, 4)
}
- .buttonStyle(.plain)
- .disabled(!canSend)
- .accessibilityLabel("Send")
+ .floatingGlass(in: RoundedRectangle(cornerRadius: 22))
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
- .background(.bar)
.photosPicker(isPresented: $showPhotos, selection: $pickerItem, matching: .images)
.onChange(of: pickerItem) { _, item in stagePhoto(item) }
}
@@ -145,9 +170,42 @@ struct ChatView: View {
// Holds a reference to the live WKWebView so the native composer can drive the web
// client's send() path, and receives staged-attachment state + errors back.
-final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler {
+final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNavigationDelegate {
weak var webView: WKWebView?
+ // The native shell's floating bars + keyboard, as CSS pixel insets. The page
+ // pads the feed with these (--native-top/bottom-inset in style.css) so
+ // messages scroll under the glass but rest clear of it. Kept and re-applied
+ // on every page load (the vars live on <html>, which navigation resets).
+ private var topInset: CGFloat = 0
+ private var bottomInset: CGFloat = 0
+
+ func setInsets(top: CGFloat, bottom: CGFloat) {
+ guard top != topInset || bottom != bottomInset else { return }
+ topInset = top
+ bottomInset = bottom
+ applyInsets()
+ }
+
+ private func applyInsets() {
+ // After padding changes, keep the feed pinned to the bottom if it was
+ // there (e.g. the keyboard rising shouldn't strand the newest message).
+ let js = """
+ (function(){
+ var r = document.documentElement.style;
+ r.setProperty('--native-top-inset','\(Int(topInset))px');
+ r.setProperty('--native-bottom-inset','\(Int(bottomInset))px');
+ var f = document.getElementById('feed');
+ if (f && f.scrollHeight - f.scrollTop - f.clientHeight < 200) f.scrollTop = f.scrollHeight;
+ })()
+ """
+ webView?.evaluateJavaScript(js)
+ }
+
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+ applyInsets()
+ }
+
// Whether the web composer currently has a photo staged. Drives the native send
// button so an image-only message can be sent. Kept in sync by the "lchat" handler.
@Published var attachmentStaged = false
@@ -229,6 +287,10 @@ struct ChatFeedWebView: UIViewRepresentable {
cfg.userContentController.add(bridge, name: "lchat")
let web = WKWebView(frame: .zero, configuration: cfg)
bridge.webView = web
+ web.navigationDelegate = bridge
+ // The shell owns the insets (they ride in as CSS vars) — don't let WebKit
+ // add its own on top when the webview runs full-bleed.
+ web.scrollView.contentInsetAdjustmentBehavior = .never
#if DEBUG
if #available(iOS 16.4, macCatalyst 16.4, *) { web.isInspectable = true }
#endif
@@ -257,3 +319,31 @@ struct ChatFeedWebView: UIViewRepresentable {
return WKUserScript(source: js, injectionTime: .atDocumentStart, forMainFrameOnly: true)
}
}
+
+// Liquid Glass on iOS 26, thin material below — one modifier so every floating
+// element falls back the same way.
+extension View {
+ @ViewBuilder func floatingGlass<S: InsettableShape>(in shape: S) -> some View {
+ if #available(iOS 26.0, *) {
+ self.glassEffect(.regular, in: shape)
+ } else {
+ self.background(.ultraThinMaterial, in: shape)
+ .overlay(shape.strokeBorder(Color(.separator).opacity(0.4), lineWidth: 0.5))
+ }
+ }
+}
+
+// The nav bar over a full-bleed feed: iOS 26 draws its own Liquid Glass; older
+// systems need the material made explicit or the bar renders clear over the
+// webview.
+struct GlassNavigationBar: ViewModifier {
+ func body(content: Content) -> some View {
+ if #available(iOS 26.0, *) {
+ content
+ } else {
+ content
+ .toolbarBackground(.ultraThinMaterial, for: .navigationBar)
+ .toolbarBackground(.visible, for: .navigationBar)
+ }
+ }
+}
diff --git a/ios/LChat/Views/EdgeOnlyBackGesture.swift b/ios/LChat/Views/EdgeOnlyBackGesture.swift
new file mode 100644
index 0000000..f6364c5
--- /dev/null
+++ b/ios/LChat/Views/EdgeOnlyBackGesture.swift
@@ -0,0 +1,39 @@
+import SwiftUI
+import UIKit
+
+// iOS 26 pops navigation on a rightward swipe from anywhere in the view, which
+// fights the feed's swipe-to-reply (a web gesture inside the WKWebView). Turn
+// the content-pop gesture off so going back is the classic left-screen-edge
+// swipe only. Installed from ChatView; the edge gesture stays untouched.
+struct EdgeOnlyBackGesture: UIViewRepresentable {
+ func makeUIView(context: Context) -> UIView { Probe() }
+ func updateUIView(_ uiView: UIView, context: Context) {}
+
+ final class Probe: UIView {
+ init() {
+ super.init(frame: .zero)
+ isUserInteractionEnabled = false
+ }
+ required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
+
+ override func didMoveToWindow() {
+ super.didMoveToWindow()
+ guard window != nil, let nav = navigationController() else { return }
+ if #available(iOS 26.0, *) {
+ nav.interactiveContentPopGestureRecognizer?.isEnabled = false
+ }
+ }
+
+ private func navigationController() -> UINavigationController? {
+ var responder: UIResponder? = self
+ while let current = responder {
+ if let vc = current as? UIViewController {
+ if let nav = vc as? UINavigationController { return nav }
+ if let nav = vc.navigationController { return nav }
+ }
+ responder = current.next
+ }
+ return nil
+ }
+ }
+}
diff --git a/ios/LChat/Views/EdgeSwipeBack.swift b/ios/LChat/Views/EdgeSwipeBack.swift
deleted file mode 100644
index 5e58d11..0000000
--- a/ios/LChat/Views/EdgeSwipeBack.swift
+++ /dev/null
@@ -1,55 +0,0 @@
-import SwiftUI
-import UIKit
-
-// A left-screen-edge swipe that runs `action` (used to go back to the chat list).
-// Installed as a UIScreenEdgePanGestureRecognizer on the window so it coexists
-// with the scroll view and the message swipe-to-reply instead of fighting them.
-struct EdgeSwipeBack: UIViewRepresentable {
- var action: () -> Void
-
- func makeCoordinator() -> Coordinator { Coordinator(action) }
- func makeUIView(context: Context) -> UIView { ProbeView(context.coordinator) }
- func updateUIView(_ uiView: UIView, context: Context) { context.coordinator.action = action }
-
- final class Coordinator: NSObject, UIGestureRecognizerDelegate {
- var action: () -> Void
- init(_ action: @escaping () -> Void) { self.action = action }
-
- @objc func handle(_ g: UIScreenEdgePanGestureRecognizer) {
- if g.state == .ended || g.state == .recognized { action() }
- }
-
- func gestureRecognizer(_ g: UIGestureRecognizer,
- shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool {
- true
- }
- }
-
- // Adds the recognizer when it joins a window and removes it when it leaves, so
- // it doesn't linger after the chat view goes away.
- final class ProbeView: UIView {
- private let coordinator: Coordinator
- private weak var recognizer: UIScreenEdgePanGestureRecognizer?
-
- init(_ coordinator: Coordinator) {
- self.coordinator = coordinator
- super.init(frame: .zero)
- isUserInteractionEnabled = false
- }
- required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
-
- override func didMoveToWindow() {
- super.didMoveToWindow()
- if let window, recognizer == nil {
- let g = UIScreenEdgePanGestureRecognizer(target: coordinator, action: #selector(Coordinator.handle))
- g.edges = .left
- g.delegate = coordinator
- window.addGestureRecognizer(g)
- recognizer = g
- } else if window == nil, let g = recognizer {
- g.view?.removeGestureRecognizer(g)
- recognizer = nil
- }
- }
- }
-}
diff --git a/ios/LChatUITests/JoinAndChatTests.swift b/ios/LChatUITests/JoinAndChatTests.swift
index dcf744d..287635c 100644
--- a/ios/LChatUITests/JoinAndChatTests.swift
+++ b/ios/LChatUITests/JoinAndChatTests.swift
@@ -67,6 +67,27 @@ final class JoinAndChatTests: XCTestCase {
shoot(app, "05-reply-received")
}
+ // Screens-only pass over an already-joined install: open the first chat,
+ // raise the keyboard, screenshot each state. For eyeballing layout work
+ // (floating bars, insets) without burning an invite.
+ func testOpenExistingChatScreens() throws {
+ guard ProcessInfo.processInfo.environment["OPEN_ONLY"] == "1" else {
+ throw XCTSkip("Set TEST_RUNNER_OPEN_ONLY=1 (with a joined install) for the screens pass.")
+ }
+ let app = XCUIApplication()
+ app.launch()
+ let row = app.staticTexts["Paula"].firstMatch
+ XCTAssertTrue(row.waitForExistence(timeout: 15), "no Paula chat on this install")
+ row.tap()
+ let composer = app.textViews.firstMatch
+ XCTAssertTrue(composer.waitForExistence(timeout: 15), "no composer")
+ sleep(2) // let the feed render + settle
+ shoot(app, "10-chat-floating")
+ composer.tap()
+ sleep(2) // keyboard animation
+ shoot(app, "11-chat-keyboard")
+ }
+
// Web content exposes elements inconsistently (button vs link vs static text
// depending on markup) — match any descendant by label.
private func webElement(_ app: XCUIApplication, _ label: String) -> XCUIElement {
diff --git a/web/static/style.css b/web/static/style.css
index 0597d71..8c31c95 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -1354,7 +1354,15 @@ html.native .thread-head {
.native-embed .composer-edit {
display: none;
}
+/* The native shell can run this page full-bleed with floating (glass) bars over
+ it. It injects the bar+keyboard heights as --native-top/bottom-inset; pad the
+ feed with them so messages scroll UNDER the bars but rest clear of them. The
+ vars default to 0, so a shell that doesn't inject them gets the plain layout. */
+.native-embed #feed {
+ padding-top: calc(18px + var(--native-top-inset, 0px));
+ padding-bottom: calc(18px + var(--native-bottom-inset, 0px));
+}
/* Keep the attachment preview visible: it's how a staged photo shows up just above
the native composer (drag-drop or the native + button stage into it). The composer
- below is hidden, so add the bottom padding it would have provided. */
-.native-embed .composer-preview { padding-bottom: 10px; }
+ below is hidden, so clear the native bar's height (plus the old 10px breathing room). */
+.native-embed .composer-preview { padding-bottom: calc(10px + var(--native-bottom-inset, 0px)); }