eleven / messenger Public

ios: wire message editing to the native composer (#121)

In embed mode the web composer + edit banner are display:none, so startEdit only
populated the hidden web textarea — the native composer stayed blank with no
"editing" indicator, so you couldn't see or change the message. Mirror the reply
bridge: startEdit/cancelEdit now post {editing:{text}} / {editing:null}, the native
WebBridge publishes it, and ChatView prefills its composer + shows an "Editing"
banner. A native send already hits chat.js's editing path; on send/cancel chat.js
posts {editing:null} to clear the banner and field.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell cdf9da37e8af814c2a2e73108af626d9bf421055 parent 38478d5
2 files changed, +74 −0
  • ios/LChat/Views/ChatView.swift +63 −0
  • web/static/chat.js +11 −0
diff --git a/ios/LChat/Views/ChatView.swift b/ios/LChat/Views/ChatView.swift
index 6948252..78b7439 100644
--- a/ios/LChat/Views/ChatView.swift
+++ b/ios/LChat/Views/ChatView.swift
@@ -158,6 +158,10 @@ struct ChatView: View {
// reply (swipe-right in the feed) shows as a glass banner above the row.
private var composer: some View {
VStack(spacing: 8) {
+ if bridge.editing != nil {
+ editBanner()
+ .transition(.move(edge: .bottom).combined(with: .opacity))
+ }
if let replying = bridge.replying {
replyBanner(replying)
.transition(.move(edge: .bottom).combined(with: .opacity))
@@ -169,6 +173,7 @@ struct ChatView: View {
composerRow
}
.animation(.easeOut(duration: 0.18), value: bridge.replying)
+ .animation(.easeOut(duration: 0.18), value: bridge.editing)
.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.
@@ -177,6 +182,17 @@ struct ChatView: View {
.onChange(of: bridge.replying) { _, r in
if r != nil { composerFocused = true } // mirror the web: replying focuses the input
}
+ // Edit mode is driven by the web layer (message menu → Edit). Prefill the
+ // native composer with the original text so it's visible + editable; clear
+ // it when edit ends (sent or cancelled, both arrive as editing == nil).
+ .onChange(of: bridge.editing) { _, e in
+ if let e {
+ text = e.text
+ composerFocused = true
+ } else {
+ text = ""
+ }
+ }
.onChange(of: text) { _, _ in
bridge.updateTypeahead(text: text, caretUTF16: caret)
bridge.noteTyping(hasText: !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
@@ -218,6 +234,32 @@ struct ChatView: View {
composerFocused = true
}
+ // Mirrors replyBanner: a glass "Editing message" strip above the composer while
+ // an edit is in flight. The ✕ drops edit mode on both sides (bridge.cancelEdit).
+ private func editBanner() -> some View {
+ HStack(spacing: 10) {
+ Image(systemName: "pencil")
+ .font(.footnote)
+ .foregroundStyle(Color.accentColor)
+ Text("Editing message")
+ .font(.footnote.weight(.semibold))
+ .lineLimit(1)
+ Spacer(minLength: 0)
+ Button { bridge.cancelEdit() } label: {
+ Image(systemName: "xmark.circle.fill")
+ .font(.system(size: 18))
+ .foregroundStyle(Color(.tertiaryLabel))
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel("Cancel edit")
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 8)
+ .floatingGlass(in: RoundedRectangle(cornerRadius: 16))
+ .padding(.horizontal, 10)
+ .padding(.top, 8)
+ }
+
private func replyBanner(_ replying: WebBridge.Replying) -> some View {
HStack(spacing: 10) {
Image(systemName: "arrowshape.turn.up.left.fill")
@@ -413,6 +455,20 @@ final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNav
"window.lchatNative && window.lchatNative.cancelReply && window.lchatNative.cancelReply()")
}
+ // The edit the web layer entered (message menu → Edit). In embed mode the web
+ // composer + edit banner are hidden, so the native shell prefills its composer
+ // with the original text and shows its own "Editing" banner. A native send then
+ // hits chat.js's editing path; on success/cancel chat.js posts {editing:null}.
+ struct Editing: Equatable { let text: String }
+ @Published var editing: Editing?
+
+ // The native edit banner's ✕: clear locally, then tell chat.js to drop edit mode.
+ func cancelEdit() {
+ editing = nil
+ webView?.evaluateJavaScript(
+ "window.lchatNative && window.lchatNative.cancelEdit && window.lchatNative.cancelEdit()")
+ }
+
// 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
@@ -512,6 +568,13 @@ final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNav
replying = nil // {replying: null} — reply cancelled or sent
}
}
+ if body.keys.contains("editing") {
+ if let e = body["editing"] as? [String: Any] {
+ editing = Editing(text: (e["text"] as? String) ?? "")
+ } else {
+ editing = nil // {editing: null} — edit cancelled or sent
+ }
+ }
}
}
diff --git a/web/static/chat.js b/web/static/chat.js
index 9088ffa..77e72b5 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -518,6 +518,11 @@ function installNativeBridge() {
cancelReply() {
cancelReply();
},
+ // The native edit banner's ✕ — drops edit mode (cancelEdit posts
+ // {editing: null} back so the native banner + prefilled composer clear).
+ cancelEdit() {
+ cancelEdit();
+ },
// 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.
@@ -2796,6 +2801,11 @@ function startEdit(msgId) {
x.onclick = cancelEdit;
banner.appendChild(x);
banner.classList.remove("hidden");
+ // Native shell: the web composer + banner above are display:none in embed mode,
+ // so hand the original text to the native composer and let it show its own
+ // "Editing" banner (mirrors the reply bridge). A native send then hits the
+ // editing path in sendMessage just like the web composer would.
+ if (embed) nativePost({ editing: { text: d.data.text || "" } });
}
function cancelEdit() {
@@ -2810,6 +2820,7 @@ function cancelEdit() {
text.value = "";
text.dispatchEvent(new Event("input"));
}
+ if (embed) nativePost({ editing: null });
}
// Unsend a message (server deletes it for everyone if we're the author).