eleven / messenger Public

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>
Paul Campbell 3424c5092130c477804d1e9a0d52ddc141f2e29f parent e3c3044
5 files changed, +262 −18
  • changelog/2026-07-02-ios-app-polish.md +5 −0
  • ios/LChat/Views/ChatView.swift +166 −7
  • ios/LChat/Views/ComposerField.swift +8 −0
  • ios/LChatUITests/JoinAndChatTests.swift +34 −0
  • web/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;