eleven / messenger Public

ios/mac: optimistic sends — the same generic mechanism, natively (#1373)

Native parity for the web's optimistic-send layer, built on the same generic
seam so a sent message shows instantly and is replaced silently by its echo:

- MessagePayload/Outgoing/AppEventOut carry a client id (_cid) inside the
  ciphertext; the hub relays the same blob back, so our own echo carries it
  verbatim. Codable ignores it on peers/old clients (carry-and-ignore).
- ChatFeedModel.send() folds the message immediately as a pending copy stamped
  with the cid (a synthetic pending id that sorts last), before the send. The
  echo — same _cid, now a real server id — upgrades that copy IN PLACE via the
  new nonisolated-static foldOptimistic (adopt id/time, drop pending), so the
  row is never destroyed/recreated. Idempotent under reconnect/history re-pull.
- A 5s timer flips an unconfirmed copy to a slow state; stay-pending on failure
  (never auto-removed). App events stamp the same _cid so an optimistic
  projection can build on the seam later.

Rendering uses the mechanism per surface: chat instantly shows the copy (no view
change needed — the feed renders `messages`), and the iOS + Mac feed rows dim +
add a VoiceOver cue once a message goes slow. Whiteboard stays webview-rendered
(the web board.js optimistic path covers it).

Vectors: EventVectorTests gains the optimistic lifecycle (pending -> upgrade in
place, pending-sorts-last, _cid carry-and-ignore), mirroring the web
event-vectors optimistic tests. Changelog held until the native release is minted.

Needs Xcode to build/test (no Swift toolchain in the authoring env).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
James Adam 1ece1ee47647a1596ab36cb32fc0ec1e865eb733 parent 35c77d3
5 files changed, +225 −10
  • ios/LChat/Views/FlippedChatFeed.swift +6 −0
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +136 −10
  • ios/LChatCore/Sources/LChatCore/Models.swift +9 −0
  • ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift +67 −0
  • ios/LChatMac/NativeFeedView.swift +7 −0
diff --git a/ios/LChat/Views/FlippedChatFeed.swift b/ios/LChat/Views/FlippedChatFeed.swift
index 9da5f45..269e568 100644
--- a/ios/LChat/Views/FlippedChatFeed.swift
+++ b/ios/LChat/Views/FlippedChatFeed.swift
@@ -964,6 +964,12 @@ struct FeedMessageRow: View {
// The jump wave, applied AFTER .clipped() so the wag never
// beheads at the row edge (shared keyframes: JumpWave in core).
.jumpWave(on: message.id == interaction.danceID, tick: interaction.danceTick)
+ // Optimistic send (web parity): the copy already shows instantly; once it
+ // has gone slow (5s unconfirmed) the bubble dims, with a non-visual cue
+ // for VoiceOver so the delay isn't conveyed by opacity alone. Outermost +
+ // additive, so it never disturbs the bubble's own layout.
+ .opacity(message.pending && message.slow ? 0.55 : 1)
+ .accessibilityHint(message.pending ? (message.slow ? "Still sending" : "Sending") : "")
}
private func separator(_ sep: TimeSeparator, at seconds: Int64) -> some View {
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
index 591a53a..958b5f7 100644
--- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
+++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
@@ -15,7 +15,12 @@ public final class ChatFeedModel: ObservableObject {
// One rendered line — deliberately view-ready and minimal (plain text for
// now; attachments/replies/reactions arrive one message type at a time).
public struct Message: Identifiable, Equatable {
- public let id: Int64 // server message id — the stable identity
+ // Server message id — the stable identity and sort key. For an OPTIMISTIC
+ // copy (a message shown the instant you send it, before the server echoes
+ // it back) this is a synthetic pending id that sorts after every real one;
+ // its own echo upgrades this to the server id in place. `var`, not `let`,
+ // for exactly that upgrade — see foldOptimistic.
+ public var id: Int64
public var name: String
public var text: String
public var mine: Bool
@@ -41,6 +46,17 @@ public final class ChatFeedModel: ObservableObject {
// The decrypted payload as sent — an edit re-encrypts {…raw, text}, so
// attachments/reply metadata survive the rewrite (chat.js does the same).
public var raw = MessagePayload()
+ // Optimistic-send state (web parity — chat.js pending/slow). `cid` is the
+ // client id this copy was sent under; it's non-nil ONLY while unconfirmed,
+ // and the matching key the echo upgrades on. `pending` = shown but not yet
+ // acknowledged by the server. `slow` flips true if it's still unconfirmed
+ // after 5s (the surface shows a subtle "Sending…"); `sentAt` (epoch ms) is
+ // when the optimistic copy was folded. A confirmed/received message carries
+ // cid == nil, pending == false.
+ public var cid: String? = nil
+ public var pending: Bool = false
+ public var slow: Bool = false
+ public var sentAt: Int64? = nil
}
// One decrypted reaction event (type="reaction" payload) as folded into a
@@ -537,6 +553,7 @@ public final class ChatFeedModel: ObservableObject {
var attachments: [Attachment]?
var replyTo: ReplyRef?
var _ckpt: CkptGossip? // tamper-evidence gossip (nil = omitted)
+ var _cid: String? // optimistic-send client id (rides back in our own echo)
}
// A typed mini-app event (todo.add, remind.add, …) — the web's ctx.emit
@@ -553,6 +570,7 @@ public final class ChatFeedModel: ObservableObject {
var uid: String?
var when: Double? // remind.add: fire time, epoch ms
var sid: String? // remind.add: server schedule id ("" = offline fallback)
+ var _cid: String? // optimistic-send client id (rides back in our own echo)
}
// Encrypt an outgoing payload and send it, on the off-main lane (the lane
@@ -586,6 +604,11 @@ public final class ChatFeedModel: ObservableObject {
if !myUserId.isEmpty { payload.uid = myUserId }
payload.when = when
payload.sid = sid
+ // The same transport-level client id chat carries. App events project
+ // asynchronously through the app host, so there's no optimistic projection
+ // to render yet — but the wire field is uniform, so that can build on this
+ // seam later without a protocol change.
+ payload._cid = UUID().uuidString
encryptAndSend(payload, via: socket)
typingBeat(active: false)
return true
@@ -859,14 +882,22 @@ public final class ChatFeedModel: ObservableObject {
let trimmed = emojify(text.trimmingCharacters(in: .whitespacesAndNewlines))
guard !trimmed.isEmpty || !attachments.isEmpty, key != nil,
let socket, socket.isLive else { return false }
- var payload = Outgoing(text: trimmed, name: instance.screenName,
- ts: Int64(Date().timeIntervalSince1970 * 1000))
+ let ts = Int64(Date().timeIntervalSince1970 * 1000)
+ var payload = Outgoing(text: trimmed, name: instance.screenName, ts: ts)
if !myUserId.isEmpty { payload.uid = myUserId }
if !attachments.isEmpty { payload.attachments = attachments }
payload.replyTo = replyTo
payload._ckpt = integrity?.gossipOut() // tamper-evidence gossip (inside the ciphertext)
- // The hub stores + broadcasts to everyone including us; the echo frame
- // folds the message into the list (same as the web — no optimistic row).
+ // Optimistic send (web parity — shell.js): show the message immediately as
+ // a pending copy stamped with a client id, then let the hub's broadcast —
+ // which carries the same _cid back — upgrade it in place. Do it before the
+ // send so it lands with no round-trip wait.
+ let cid = UUID().uuidString
+ payload._cid = cid
+ foldOptimisticSend(cid: cid, text: trimmed, ts: ts,
+ attachments: attachments, replyTo: replyTo)
+ // The hub stores + broadcasts to everyone including us; that echo folds
+ // over this optimistic copy (matched on _cid), replacing it silently.
encryptAndSend(payload, via: socket)
typingBeat(active: false) // a send clears the indicator now
return true
@@ -1417,6 +1448,9 @@ public final class ChatFeedModel: ObservableObject {
private func foldDecrypted(id: Int64, at: Int64, payload: MessagePayload?,
edited: Bool, reactions: [Reaction]) {
noteGossip(payload, own: payload?.uid == myUserId) // tamper-evidence (live)
+ // Our own message echoing back: cancel its "sending…" timer — the fold
+ // below upgrades the optimistic copy in place (matched on _cid).
+ if let c = payload?._cid { clearSlowTimer(c) }
var list = messages
fold(id: id, at: at, payload: payload,
edited: edited, reactions: reactions, into: &list)
@@ -1532,7 +1566,8 @@ public final class ChatFeedModel: ObservableObject {
}
private func fold(id: Int64, at: Int64, payload: MessagePayload?,
- edited: Bool, reactions: [Reaction], into list: inout [Message]) {
+ edited: Bool, reactions: [Reaction], into list: inout [Message],
+ cid: String? = nil, pending: Bool = false) {
guard let p = payload, p.isRenderableChat else { return }
let attachments = p.attachments ?? []
let text = p.text ?? ""
@@ -1540,22 +1575,113 @@ public final class ChatFeedModel: ObservableObject {
// payload carries one, else the screen name (old payloads).
let mine = p.uid.map { !myUserId.isEmpty && $0 == myUserId }
?? (p.name == instance.screenName)
+ // The client id to pair on: the explicit optimistic-fold argument, else
+ // the one riding in the echo's payload. A confirming echo has pending ==
+ // false and reads it from `p._cid`; a fresh optimistic copy passes it in.
+ let pairCid = cid ?? p._cid
var msg = Message(id: id, name: p.name ?? "", text: text, mine: mine, at: at,
action: p.action ?? false, edited: edited,
attachments: attachments, reply: p.replyTo, reactions: reactions,
raw: p)
- if let i = list.firstIndex(where: { $0.id == id }) {
+ msg.cid = pending ? pairCid : nil
+ msg.pending = pending
+ msg.sentAt = pending ? Int64(Date().timeIntervalSince1970 * 1000) : nil
+ Self.foldOptimistic(msg, pairCid: pairCid, pending: pending, into: &list)
+ }
+
+ // The optimistic-send lifecycle on the message list — factored out (nonisolated
+ // static, like foldReaction) so the event vectors can drive it directly. Three
+ // cases, in order:
+ // • a fresh OPTIMISTIC copy (pending) → appended, sorted last by its synthetic
+ // pending id;
+ // • our own ECHO confirming one (same cid, now a real server id) → the pending
+ // copy is UPGRADED in place: it adopts the server id/time and drops its
+ // pending flags, keeping its row so nothing is destroyed and re-created;
+ // • anything else (another member's message, a history re-pull, a reconnect
+ // replay) → the existing upsert-by-id, which stays idempotent.
+ nonisolated static func foldOptimistic(_ msg: Message, pairCid: String?,
+ pending: Bool, into list: inout [Message]) {
+ if !pending, let c = pairCid,
+ let i = list.firstIndex(where: { $0.cid == c && $0.pending }) {
+ var up = list[i]
+ up.id = msg.id // adopt the real server id (re-sorts into place)
+ up.at = msg.at
+ up.pending = false
+ up.slow = false
+ up.cid = nil
+ up.text = msg.text
+ up.attachments = msg.attachments
+ up.reply = msg.reply
+ up.raw = msg.raw
+ if !msg.reactions.isEmpty { up.reactions = msg.reactions }
+ if msg.edited { up.edited = true }
+ list[i] = up
+ list.sort { $0.id < $1.id }
+ return
+ }
+ if let i = list.firstIndex(where: { $0.id == msg.id }) {
// A live echo carries no reactions/edit state — keep what we have
// unless the refold supplies fresher values.
- if msg.reactions.isEmpty { msg.reactions = list[i].reactions }
- if !msg.edited { msg.edited = list[i].edited }
- list[i] = msg
+ var m = msg
+ if m.reactions.isEmpty { m.reactions = list[i].reactions }
+ if !m.edited { m.edited = list[i].edited }
+ list[i] = m
} else {
list.append(msg)
list.sort { $0.id < $1.id }
}
}
+ // Pending optimistic copies live above every real server id so they sort to
+ // the bottom of the feed until confirmed. 2^62 is unreachable by real
+ // (auto-increment) message ids; the counter keeps concurrent pending sends in
+ // send order.
+ private static let pendingIDBase: Int64 = 1 << 62
+ private var pendingIDSeq: Int64 = 0
+ // The "still sending" timers, keyed by client id — each flips its copy to the
+ // slow state 5s on if the echo hasn't landed. The echo cancels it.
+ private var slowTimers: [String: Task<Void, Never>] = [:]
+
+ // Fold our own just-sent message straight into the list as a pending copy
+ // (mirrors shell.js optimisticEmit). It shows instantly; its echo upgrades it.
+ private func foldOptimisticSend(cid: String, text: String, ts: Int64,
+ attachments: [Attachment], replyTo: ReplyRef?) {
+ let localID = Self.pendingIDBase &+ pendingIDSeq
+ pendingIDSeq &+= 1
+ var p = MessagePayload()
+ p.text = text
+ p.name = instance.screenName
+ p.ts = ts
+ if !myUserId.isEmpty { p.uid = myUserId }
+ if !attachments.isEmpty { p.attachments = attachments }
+ p.replyTo = replyTo
+ p._cid = cid
+ var list = messages
+ fold(id: localID, at: ts / 1000, payload: p, edited: false, reactions: [],
+ into: &list, cid: cid, pending: true)
+ Self.refreshReplyCounts(&list)
+ messages = list
+ // Stay-pending on failure (web parity): we never remove the copy, only
+ // surface a delay. After 5s unconfirmed, flip it to the slow state.
+ slowTimers[cid] = Task { [weak self] in
+ try? await Task.sleep(nanoseconds: 5_000_000_000)
+ guard !Task.isCancelled else { return }
+ self?.markSlow(cid: cid)
+ }
+ }
+
+ private func markSlow(cid: String) {
+ slowTimers[cid] = nil
+ guard let i = messages.firstIndex(where: { $0.cid == cid && $0.pending }) else { return }
+ messages[i].slow = true
+ }
+
+ // Our own echo has landed (or a confirming path ran) — cancel any slow timer.
+ private func clearSlowTimer(_ cid: String) {
+ slowTimers[cid]?.cancel()
+ slowTimers[cid] = nil
+ }
+
// Resolve every reply's thread ROOT (message id → root id). New replies
// point straight at the top message, so this is one hop; legacy chains (a
// reply quoting another reply) are chased through the loaded window,
diff --git a/ios/LChatCore/Sources/LChatCore/Models.swift b/ios/LChatCore/Sources/LChatCore/Models.swift
index 00e7626..5901c8f 100644
--- a/ios/LChatCore/Sources/LChatCore/Models.swift
+++ b/ios/LChatCore/Sources/LChatCore/Models.swift
@@ -184,6 +184,15 @@ public struct MessagePayload: Codable, Equatable, Sendable {
// renders it). Optional, so it's absent on messages that don't gossip.
public var _ckpt: CkptGossip?
+ // Optimistic-send client id (web shell.js `_cid`): a per-message id the
+ // sender tucks inside the ciphertext so the hub's broadcast carries it back
+ // verbatim. It lets the sender pair its own echo with the optimistic copy it
+ // showed on send and upgrade that copy in place (adopt the server id, clear
+ // pending) instead of duplicating it. Host metadata, never chat content;
+ // absent on messages sent before this shipped, and ignored by every peer but
+ // the original sender. Vector contract: chat.json / EventVectorTests.
+ public var _cid: String?
+
/// Mirrors web/static/message.js acceptsMessageType: render untyped (legacy)
/// and "chat" payloads; skip unknown future kinds rather than mis-render them.
public var isRenderableChat: Bool { type == nil || type == "chat" }
diff --git a/ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift b/ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift
index c4315b9..f3c8ddf 100644
--- a/ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift
+++ b/ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift
@@ -244,6 +244,73 @@ final class EventVectorTests: XCTestCase {
"an event for another message never touches this one")
}
+ func testOptimisticSendLifecycle() {
+ // The optimistic-send contract (web parity — chat.js): a message folds as a
+ // PENDING copy the instant it's sent, then its own echo (same _cid, now a
+ // real server id) UPGRADES that copy in place rather than duplicating it.
+ let cid = "cid-abc"
+ var list: [ChatFeedModel.Message] = []
+
+ // 1. Optimistic copy: a synthetic pending id (sorts last), pending, keyed by cid.
+ var pending = ChatFeedModel.Message(id: (1 << 62), name: "Ada", text: "hi there",
+ mine: true, at: 1700000000, edited: false)
+ pending.cid = cid
+ pending.pending = true
+ ChatFeedModel.foldOptimistic(pending, pairCid: cid, pending: true, into: &list)
+ XCTAssertEqual(list.count, 1, "the pending copy shows immediately")
+ XCTAssertTrue(list[0].pending, "flagged pending")
+ XCTAssertEqual(list[0].cid, cid, "keyed by the client id")
+
+ // 2. Our own echo confirms it: adopt the server id, drop pending, no duplicate.
+ let echo = ChatFeedModel.Message(id: 42, name: "Ada", text: "hi there",
+ mine: true, at: 1700000005, edited: false)
+ ChatFeedModel.foldOptimistic(echo, pairCid: cid, pending: false, into: &list)
+ XCTAssertEqual(list.count, 1, "the echo upgrades in place — no duplicate")
+ XCTAssertEqual(list[0].id, 42, "adopts the real server id")
+ XCTAssertFalse(list[0].pending, "no longer pending")
+ XCTAssertNil(list[0].cid, "client id cleared on confirm")
+ XCTAssertEqual(list[0].at, 1700000005, "adopts the server time")
+
+ // 3. A duplicate echo (reconnect / history re-pull) is idempotent.
+ ChatFeedModel.foldOptimistic(echo, pairCid: cid, pending: false, into: &list)
+ XCTAssertEqual(list.count, 1, "re-folding the confirmed message changes nothing")
+ }
+
+ func testOptimisticPendingSortsLast() {
+ // A pending copy sits below every confirmed message until its echo slots it
+ // into server order (mirrors the web bySidPendingLast comparator).
+ var list: [ChatFeedModel.Message] = []
+ let first = ChatFeedModel.Message(id: 100, name: "Bo", text: "first",
+ mine: false, at: 10, edited: false)
+ ChatFeedModel.foldOptimistic(first, pairCid: nil, pending: false, into: &list)
+ var mine = ChatFeedModel.Message(id: (1 << 62), name: "Ada", text: "mine",
+ mine: true, at: 20, edited: false)
+ mine.cid = "z"; mine.pending = true
+ ChatFeedModel.foldOptimistic(mine, pairCid: "z", pending: true, into: &list)
+ XCTAssertEqual(list.map { $0.text }, ["first", "mine"], "pending sits at the bottom")
+
+ // A higher-id message arriving before our echo still lands before the pending copy.
+ let later = ChatFeedModel.Message(id: 200, name: "Cy", text: "later",
+ mine: false, at: 30, edited: false)
+ ChatFeedModel.foldOptimistic(later, pairCid: nil, pending: false, into: &list)
+ XCTAssertEqual(list.map { $0.text }, ["first", "later", "mine"], "pending stays last until confirmed")
+
+ // On confirm it slots into server order.
+ let confirm = ChatFeedModel.Message(id: 150, name: "Ada", text: "mine",
+ mine: true, at: 20, edited: false)
+ ChatFeedModel.foldOptimistic(confirm, pairCid: "z", pending: false, into: &list)
+ XCTAssertEqual(list.map { $0.text }, ["first", "mine", "later"], "confirmed message sorts by server id")
+ }
+
+ func testOptimisticCidCarriedAndIgnored() {
+ // A MessagePayload carrying _cid decodes cleanly and never leaks the id into
+ // content — native must carry-and-ignore it (Codable drops unknown-to-old keys).
+ let json = #"{"text":"hello","name":"Bo","ts":5,"_cid":"unmatched"}"#.data(using: .utf8)!
+ let p = try? JSONDecoder().decode(MessagePayload.self, from: json)
+ XCTAssertEqual(p?.text, "hello", "_cid never leaks into the text")
+ XCTAssertEqual(p?._cid, "unmatched", "the client id round-trips")
+ }
+
func testEditedMessage() {
// Edited messages: the payload is the new text, edited_at is server-side.
let original = MessagePayload(
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index d6303e3..bf274df 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -411,6 +411,13 @@ struct NativeFeedView: View {
}
}
}
+ // Optimistic send (web parity): the copy already shows
+ // instantly and the confirm swaps it in place; once it's
+ // gone slow (5s unconfirmed) the bubble dims, with a
+ // VoiceOver cue so the delay isn't conveyed by opacity
+ // alone.
+ .opacity(m.pending && m.slow ? 0.55 : 1)
+ .accessibilityHint(m.pending ? (m.slow ? "Still sending" : "Sending") : "")
}
// The web's typing bubble: three bouncing dots, them-side,
// living IN the feed (the old composer-inset text line's