| 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, |