iOS: keyboard pin, native reply banner, composer typeahead, send touch targets (#59)
* ios: keep the feed pinned to the newest message as the keyboard rises
Judge "was at bottom" before the inset padding mutates, not after — measured
after, the gap equals the padding just added and the re-pin never fired, so
the composer + keyboard covered the latest messages while typing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ios+web: native reply banner, composer typeahead, bigger send targets
- Swipe-to-reply worked invisibly in the native app: chat.js staged the reply
but its banner is display:none in embed mode. startReply/cancelReply now post
{replying} to the shell, which draws its own glass banner (✕ cancels on both
sides, optimistically native-first).
- :emoji / @mention typeahead in the native composer: token detection + ranking
stay in the web layer (composerToken + a shared typeaheadItems), the shell
renders a floating chip strip and splices picks by UTF-16 offsets.
- Send-button near-misses: 44pt native hit target + the composer cluster
consumes blank taps (focus, never keyboard-dismiss); on mobile web the
.send-btn::after extension is clipped by overflow:hidden, so the composer
itself now treats a gutter tap at Send's height as a send.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+262
−18
changelog/2026-07-02-ios-app-polish.md+5 −0ios/LChat/Views/ChatView.swift+166 −7ios/LChat/Views/ComposerField.swift+8 −0ios/LChatUITests/JoinAndChatTests.swift+34 −0web/static/chat.js+49 −11
diff --git a/changelog/2026-07-02-ios-app-polish.md b/changelog/2026-07-02-ios-app-polish.md| new file mode 100644 |
| index 0000000..76a5119 |
| --- /dev/null |
| +++ b/changelog/2026-07-02-ios-app-polish.md |
| @@ -0,0 +1,5 @@ |
| +The iPhone app (Eleven) got a big round of polish: the chat now floats a glass |
| +title bar and message box over your conversation, typing keeps the newest |
| +message in view, swipe-right to reply shows who you're replying to above the |
| +message box, and :emoji: and @name suggestions pop up as you type — just like |
| +on the web. |
diff --git a/ios/LChat/Views/ChatView.swift b/ios/LChat/Views/ChatView.swift| index a328194..077aac6 100644 |
| --- a/ios/LChat/Views/ChatView.swift |
| +++ b/ios/LChat/Views/ChatView.swift |
| @@ -21,6 +21,7 @@ struct ChatView: View { |
| @StateObject private var bridge = WebBridge() |
| @State private var text = "" |
| + @State private var caret = 0 // UTF-16, kept in sync by ComposerField |
| @State private var composerFocused = false |
| @State private var showPhotos = false |
| @State private var pickerItem: PhotosPickerItem? |
| @@ -97,8 +98,100 @@ struct ChatView: View { |
| // 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). |
| + // messages show through underneath (the Signal/Messages look). A staged |
| + // reply (swipe-right in the feed) shows as a glass banner above the row. |
| private var composer: some View { |
| + VStack(spacing: 8) { |
| + if let replying = bridge.replying { |
| + replyBanner(replying) |
| + .transition(.move(edge: .bottom).combined(with: .opacity)) |
| + } |
| + if let ta = bridge.typeahead { |
| + typeaheadStrip(ta) |
| + .transition(.move(edge: .bottom).combined(with: .opacity)) |
| + } |
| + composerRow |
| + } |
| + .animation(.easeOut(duration: 0.18), value: bridge.replying) |
| + .animation(.easeOut(duration: 0.12), value: bridge.typeahead) |
| + // The cluster's blank padding consumes taps: a near-miss focuses the |
| + // input rather than falling through to the feed and closing the keyboard. |
| + .contentShape(Rectangle()) |
| + .onTapGesture { composerFocused = true } |
| + .onChange(of: bridge.replying) { _, r in |
| + if r != nil { composerFocused = true } // mirror the web: replying focuses the input |
| + } |
| + .onChange(of: text) { _, _ in bridge.updateTypeahead(text: text, caretUTF16: caret) } |
| + .onChange(of: caret) { _, _ in bridge.updateTypeahead(text: text, caretUTF16: caret) } |
| + } |
| + |
| + // Horizontal strip of :emoji / @mention candidates, floating like the rest |
| + // of the composer cluster. Tap to splice the pick into the field. |
| + private func typeaheadStrip(_ ta: WebBridge.Typeahead) -> some View { |
| + ScrollView(.horizontal, showsIndicators: false) { |
| + HStack(spacing: 6) { |
| + ForEach(Array(ta.items.enumerated()), id: \.offset) { _, item in |
| + Button { pick(ta, item) } label: { |
| + Text(item.label) |
| + .font(.subheadline) |
| + .lineLimit(1) |
| + .padding(.horizontal, 12) |
| + .padding(.vertical, 7) |
| + .floatingGlass(in: Capsule()) |
| + } |
| + .buttonStyle(.plain) |
| + } |
| + } |
| + .padding(.horizontal, 10) |
| + } |
| + } |
| + |
| + // Replace the token (trigger + typed query) with the pick. Offsets from the |
| + // web layer are UTF-16 — splice in that space so emoji upstream don't skew. |
| + private func pick(_ ta: WebBridge.Typeahead, _ item: WebBridge.TypeaheadItem) { |
| + let units = Array(text.utf16) |
| + let start = max(0, min(ta.start, units.count)) |
| + let end = max(start, min(caret, units.count)) |
| + let head = String(utf16CodeUnits: Array(units[0..<start]), count: start) |
| + let tail = String(utf16CodeUnits: Array(units[end...]), count: units.count - end) |
| + text = head + item.insert + tail |
| + bridge.typeahead = nil |
| + composerFocused = true |
| + } |
| + |
| + private func replyBanner(_ replying: WebBridge.Replying) -> some View { |
| + HStack(spacing: 10) { |
| + Image(systemName: "arrowshape.turn.up.left.fill") |
| + .font(.footnote) |
| + .foregroundStyle(Color.accentColor) |
| + VStack(alignment: .leading, spacing: 1) { |
| + Text("Replying to \(replying.name)") |
| + .font(.footnote.weight(.semibold)) |
| + .lineLimit(1) |
| + if !replying.snippet.isEmpty { |
| + Text(replying.snippet) |
| + .font(.footnote) |
| + .foregroundStyle(.secondary) |
| + .lineLimit(1) |
| + } |
| + } |
| + Spacer(minLength: 0) |
| + Button { bridge.cancelReply() } label: { |
| + Image(systemName: "xmark.circle.fill") |
| + .font(.system(size: 18)) |
| + .foregroundStyle(Color(.tertiaryLabel)) |
| + } |
| + .buttonStyle(.plain) |
| + .accessibilityLabel("Cancel reply") |
| + } |
| + .padding(.horizontal, 12) |
| + .padding(.vertical, 8) |
| + .floatingGlass(in: RoundedRectangle(cornerRadius: 16)) |
| + .padding(.horizontal, 10) |
| + .padding(.top, 8) |
| + } |
| + |
| + private var composerRow: some View { |
| HStack(alignment: .bottom, spacing: 8) { |
| Menu { |
| Button { presentAfterMenu { showPhotos = true } } label: { Label("Photo Library", systemImage: "photo") } |
| @@ -114,6 +207,7 @@ struct ChatView: View { |
| ComposerField( |
| text: $text, |
| focused: $composerFocused, |
| + caret: $caret, |
| placeholder: "Message", |
| sendOnReturn: isMac, |
| onSend: { if canSend { send() } } |
| @@ -125,12 +219,15 @@ struct ChatView: View { |
| Image(systemName: "arrow.up.circle.fill") |
| .font(.system(size: 28)) |
| .foregroundStyle(canSend ? Color.accentColor : Color(.tertiaryLabel)) |
| + // 44pt minimum target — the visible icon is 28pt, but a |
| + // near-miss to its right must still send, not fall through |
| + // to the feed (which dismisses the keyboard). |
| + .frame(width: 44, height: 40) |
| + .contentShape(Rectangle()) |
| } |
| .buttonStyle(.plain) |
| .disabled(!canSend) |
| .accessibilityLabel("Send") |
| - .padding(.trailing, 4) |
| - .padding(.bottom, 4) |
| } |
| .floatingGlass(in: RoundedRectangle(cornerRadius: 22)) |
| } |
| @@ -188,15 +285,18 @@ final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNav |
| } |
| 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). |
| + // 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. |
| let js = """ |
| (function(){ |
| + var f = document.getElementById('feed'); |
| + var atBottom = f && (f.scrollHeight - f.scrollTop - f.clientHeight < 200); |
| 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; |
| + if (atBottom) requestAnimationFrame(function(){ f.scrollTop = f.scrollHeight; }); |
| })() |
| """ |
| webView?.evaluateJavaScript(js) |
| @@ -214,6 +314,57 @@ final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNav |
| // surfaces it as an alert, then clears it — so an attachment never just vanishes. |
| @Published var lastError: String? |
| + // The web layer's staged reply (swipe-right / the message menu set it). The |
| + // native shell shows the banner — the web one is hidden in embed mode. |
| + struct Replying: Equatable { |
| + let name: String |
| + let snippet: String |
| + } |
| + @Published var replying: Replying? |
| + |
| + // The native banner's ✕: clear locally right away (no round-trip lag), then |
| + // tell chat.js to drop its staged reply so the next send doesn't carry it. |
| + func cancelReply() { |
| + replying = nil |
| + webView?.evaluateJavaScript( |
| + "window.lchatNative && window.lchatNative.cancelReply && window.lchatNative.cancelReply()") |
| + } |
| + |
| + // Composer typeahead (:emoji / @mention): token detection and ranking live |
| + // in the web layer (one implementation for all clients); native just renders |
| + // the strip and splices the pick into its field. Offsets are UTF-16 on both |
| + // sides, so they line up even with emoji already in the text. |
| + struct TypeaheadItem: Equatable { |
| + let label: String |
| + let insert: String |
| + } |
| + struct Typeahead: Equatable { |
| + let start: Int // UTF-16 offset of the token's trigger char |
| + let items: [TypeaheadItem] |
| + } |
| + @Published var typeahead: Typeahead? |
| + |
| + func updateTypeahead(text: String, caretUTF16: Int) { |
| + guard let webView, |
| + let data = try? JSONSerialization.data(withJSONObject: [text]), |
| + let arg = String(data: data, encoding: .utf8) else { return } |
| + let js = "window.lchatNative && window.lchatNative.typeahead ? window.lchatNative.typeahead(\(arg)[0], \(caretUTF16)) : null" |
| + webView.evaluateJavaScript(js) { [weak self] result, _ in |
| + guard let d = result as? [String: Any], |
| + let start = d["start"] as? Int, |
| + let raw = d["items"] as? [[String: Any]] else { |
| + self?.typeahead = nil |
| + return |
| + } |
| + let items = raw.compactMap { item -> TypeaheadItem? in |
| + guard let label = item["label"] as? String, |
| + let insert = item["insert"] as? String else { return nil } |
| + return TypeaheadItem(label: label, insert: insert) |
| + } |
| + self?.typeahead = items.isEmpty ? nil : Typeahead(start: start, items: items) |
| + } |
| + } |
| + |
| // Forwards typed text to chat.js's window.lchatNative.sendText. JSON-encoding the |
| // string safely escapes quotes/newlines/unicode before it crosses into JS. The |
| // completion reports whether the web layer accepted it (socket open, hook present, |
| @@ -262,6 +413,14 @@ final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNav |
| guard message.name == "lchat", let body = message.body as? [String: Any] else { return } |
| if let staged = body["staged"] as? Bool { attachmentStaged = staged } |
| if let err = body["error"] as? String { lastError = err } |
| + if body.keys.contains("replying") { |
| + if let r = body["replying"] as? [String: Any] { |
| + replying = Replying(name: (r["name"] as? String) ?? "", |
| + snippet: (r["snippet"] as? String) ?? "") |
| + } else { |
| + replying = nil // {replying: null} — reply cancelled or sent |
| + } |
| + } |
| } |
| } |
diff --git a/ios/LChat/Views/ComposerField.swift b/ios/LChat/Views/ComposerField.swift| index 7e0f360..4c65b27 100644 |
| --- a/ios/LChat/Views/ComposerField.swift |
| +++ b/ios/LChat/Views/ComposerField.swift |
| @@ -14,6 +14,9 @@ import UIKit |
| struct ComposerField: UIViewRepresentable { |
| @Binding var text: String |
| @Binding var focused: Bool |
| + // Caret position as a UTF-16 offset (UIKit's native unit) — drives the |
| + // typeahead, which needs to know which token the caret sits in. |
| + var caret: Binding<Int>? = nil |
| var placeholder: String = "" |
| var sendOnReturn: Bool |
| var onSend: () -> Void |
| @@ -65,9 +68,14 @@ struct ComposerField: UIViewRepresentable { |
| func textViewDidChange(_ tv: UITextView) { |
| parent.text = tv.text |
| + parent.caret?.wrappedValue = tv.selectedRange.location |
| (tv as? GrowingTextView)?.placeholderLabel.isHidden = !tv.text.isEmpty |
| } |
| + func textViewDidChangeSelection(_ tv: UITextView) { |
| + parent.caret?.wrappedValue = tv.selectedRange.location |
| + } |
| + |
| func textView(_ tv: UITextView, shouldChangeTextIn range: NSRange, |
| replacementText replacement: String) -> Bool { |
| guard parent.sendOnReturn, replacement == "\n" else { return true } |
diff --git a/ios/LChatUITests/JoinAndChatTests.swift b/ios/LChatUITests/JoinAndChatTests.swift| index 287635c..29a10ed 100644 |
| --- a/ios/LChatUITests/JoinAndChatTests.swift |
| +++ b/ios/LChatUITests/JoinAndChatTests.swift |
| @@ -83,9 +83,43 @@ final class JoinAndChatTests: XCTestCase { |
| XCTAssertTrue(composer.waitForExistence(timeout: 15), "no composer") |
| sleep(2) // let the feed render + settle |
| shoot(app, "10-chat-floating") |
| + |
| + // Swipe-right on a message → the native reply banner appears; ✕ clears it. |
| + let bubble = webElement(app, "hello back from Paula") |
| + if bubble.waitForExistence(timeout: 5) { |
| + bubble.swipeRight() |
| + let banner = app.staticTexts["Replying to Paula"].firstMatch |
| + XCTAssertTrue(banner.waitForExistence(timeout: 5), "reply banner never appeared") |
| + shoot(app, "12-reply-banner") |
| + app.buttons["Cancel reply"].firstMatch.tap() |
| + XCTAssertTrue(waitGone(banner, timeout: 5), "reply banner didn't clear") |
| + } |
| + |
| composer.tap() |
| sleep(2) // keyboard animation |
| shoot(app, "11-chat-keyboard") |
| + |
| + // :emoji typeahead — the strip appears with ranked glyphs; a tap splices. |
| + composer.typeText(":tada") |
| + let emojiChip = app.buttons.matching(NSPredicate(format: "label CONTAINS %@", ":tada:")).firstMatch |
| + XCTAssertTrue(emojiChip.waitForExistence(timeout: 5), "emoji typeahead never appeared") |
| + shoot(app, "13-emoji-typeahead") |
| + emojiChip.tap() |
| + XCTAssertTrue(((composer.value as? String) ?? "").contains("🎉"), "emoji wasn't spliced in") |
| + |
| + // @mention typeahead over the roster. |
| + composer.typeText(" @Pa") |
| + let mentionChip = app.buttons.matching(NSPredicate(format: "label CONTAINS %@", "Paula")).firstMatch |
| + XCTAssertTrue(mentionChip.waitForExistence(timeout: 5), "mention typeahead never appeared") |
| + shoot(app, "14-mention-typeahead") |
| + mentionChip.tap() |
| + XCTAssertTrue(((composer.value as? String) ?? "").contains("@Paula"), "mention wasn't spliced in") |
| + } |
| + |
| + private func waitGone(_ element: XCUIElement, timeout: TimeInterval) -> Bool { |
| + let p = NSPredicate(format: "exists == false") |
| + return XCTWaiter().wait(for: [XCTNSPredicateExpectation(predicate: p, object: element)], |
| + timeout: timeout) == .completed |
| } |
| // Web content exposes elements inconsistently (button vs link vs static text |
diff --git a/web/static/chat.js b/web/static/chat.js| index 6062ff9..fa221d8 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -431,6 +431,23 @@ function installNativeBridge() { |
| nativePost({ error: "Couldn't attach that photo." }); |
| } |
| }, |
| + // The native reply banner's ✕ — clears the staged reply on both sides |
| + // (cancelReply posts {replying: null} back). |
| + cancelReply() { |
| + cancelReply(); |
| + }, |
| + // Composer typeahead for the native input: same token detection + ranking |
| + // as the web menu (composerToken + typeaheadItems). caret is a UTF-16 |
| + // offset, matching JS string indexing. Returns {start, items} or null. |
| + typeahead(text, caret) { |
| + const tok = composerToken(text, caret); |
| + if (!tok) return null; |
| + const items = typeaheadItems(tok).map((it) => ({ |
| + label: it.glyph ? `${it.glyph} :${it.code}:` : it.name, |
| + insert: it.insert, |
| + })); |
| + return items.length ? { start: tok.start, items } : null; |
| + }, |
| }; |
| } |
| @@ -850,6 +867,19 @@ function renderApp() { |
| feedForBlur.addEventListener("pointerdown", () => { |
| if (document.activeElement === text) text.blur(); |
| }); |
| + // A tap on the composer's own blank padding must never close the keyboard. |
| + // The .send-btn::after hit extension is clipped by the field's overflow:hidden, |
| + // so the gutter just right of Send falls through to here — treat a near-miss |
| + // at Send's height as a send; anything else only keeps focus where it is. |
| + composer.addEventListener("pointerdown", (e) => { |
| + if (e.target !== composer) return; |
| + e.preventDefault(); // don't blur the field |
| + if (sendBtn.hidden || sendBtn.disabled) return; |
| + const r = sendBtn.getBoundingClientRect(); |
| + if (e.clientX >= r.left - 8 && Math.abs(e.clientY - (r.top + r.height / 2)) <= r.height) { |
| + sendBtn.click(); |
| + } |
| + }); |
| text.addEventListener("input", () => { |
| updateSendVisibility(); |
| text.style.height = "auto"; |
| @@ -2267,7 +2297,10 @@ function startReply(meta) { |
| box.appendChild(bar); |
| box.classList.remove("hidden"); |
| if (atBottom && feed) feed.scrollTop = feed.scrollHeight; |
| - document.getElementById("text").focus(); |
| + // The native shell draws its own reply banner (this one is display:none in |
| + // embed mode) and focuses its own composer. |
| + if (embed) nativePost({ replying: { name: meta.name || "", snippet: meta.snippet || "" } }); |
| + else document.getElementById("text").focus(); |
| } |
| function cancelReply() { |
| @@ -2277,6 +2310,7 @@ function cancelReply() { |
| box.innerHTML = ""; |
| box.classList.add("hidden"); |
| } |
| + if (embed) nativePost({ replying: null }); |
| } |
| // Returns true when the composer should be reset (message sent, or nothing to |
| @@ -4307,21 +4341,25 @@ function updateTypeahead() { |
| if (!text) return closeTypeahead(); |
| const tok = composerToken(text.value, text.selectionStart); |
| if (!tok) return closeTypeahead(); |
| - let items; |
| - if (tok.kind === "emoji") { |
| - items = emojiMatches(tok.query, 8).map((m) => ({ glyph: m.emoji, code: m.code, insert: m.emoji })); |
| - } else { |
| - const q = tok.query.toLowerCase(); |
| - items = roster |
| - .filter((e) => e.user_id !== myUserId && e.label && e.label.toLowerCase().includes(q)) |
| - .slice(0, 8) |
| - .map((e) => ({ name: e.label, userId: e.user_id, insert: "@" + e.label + " " })); |
| - } |
| + const items = typeaheadItems(tok); |
| if (!items.length) return closeTypeahead(); |
| typeahead = { kind: tok.kind, start: tok.start, items, index: 0 }; |
| renderTypeahead(); |
| } |
| +// Candidates for a composer token — shared by the web menu above and the native |
| +// shell's strip (window.lchatNative.typeahead), so both rank identically. |
| +function typeaheadItems(tok) { |
| + if (tok.kind === "emoji") { |
| + return emojiMatches(tok.query, 8).map((m) => ({ glyph: m.emoji, code: m.code, insert: m.emoji })); |
| + } |
| + const q = tok.query.toLowerCase(); |
| + return roster |
| + .filter((e) => e.user_id !== myUserId && e.label && e.label.toLowerCase().includes(q)) |
| + .slice(0, 8) |
| + .map((e) => ({ name: e.label, userId: e.user_id, insert: "@" + e.label + " " })); |
| +} |
| + |
| function renderTypeahead() { |
| const box = document.getElementById("typeahead"); |
| if (!typeahead || !box) return; |