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>
6 files changed,
+202
−83
ios/LChat/ThreadStore.swift+17 −1ios/LChat/Views/ChatView.swift+115 −25ios/LChat/Views/EdgeOnlyBackGesture.swift+39 −0ios/LChat/Views/EdgeSwipeBack.swift+0 −55ios/LChatUITests/JoinAndChatTests.swift+21 −0web/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)); } |