Pingline M3 — optimistic send and reconciliationswift-6.4/ios-26
Lesson 4 / 6
Unit 26 · Pingline

Pingline M3 — optimistic send and reconciliation

Note

Unit 26 · Pingline Milestone 3. You make sending feel instant. This is where the app stops feeling like a database viewer and starts feeling like iMessage.

A chat that waits for the server before showing your own message feels broken, even on a fast network. The fix is optimistic UI: append the message locally the instant the user hits send, mark it sending, fire the network call, and then reconcile — flip it to sent on success or failed on error. Done right, the happy path is invisible and the failure path is honest.

The optimistic send

Build the Message with a client-generated id and createdAt, insert it locally as sending, then attempt the network write. The same id you generated is what lets you find the row again to update it.

extension ThreadViewModel {
    func send(_ text: String, from senderID: UUID) {
        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return }

        var optimistic = Message(
            id: UUID(),
            conversationID: conversationID,
            senderID: senderID,
            body: trimmed,
            createdAt: .now,
            deliveryState: .sending
        )
        insert(optimistic)          // shows immediately, greyed as "sending"

        Task {
            do {
                try await postMessage(optimistic)     // INSERT into Supabase
                updateState(of: optimistic.id, to: .sent)
            } catch {
                updateState(of: optimistic.id, to: .failed)
            }
        }
    }

    private func updateState(of id: UUID, to state: DeliveryState) {
        guard let index = messages.firstIndex(where: { $0.id == id }) else { return }
        messages[index].deliveryState = state
    }
}

The message appears at once. When postMessage returns, updateState locates the row by id and flips its deliveryState — the checkmark appears. If it throws, the row turns failed and you can offer a retry. Nothing about the UI blocks on the network.

Note

Never mutate messages by index without looking the row up by id first. Between the optimistic insert and the ack, the array can change (a live message arrives, another send starts). A stale index would corrupt the wrong row. Find by id, then mutate.

Reconciling with the echo

Realtime will also deliver your own insert back through the subscription from Milestone 2 — the server's echo. That is why insert(_:) dedupes by id: the echo carries the same client-generated UUID, so it is recognized as already present and dropped. Your optimistic row is the one that stays, now marked sent. Without the dedupe, every message you send would appear twice.

private func insert(_ message: Message) {
    if let index = messages.firstIndex(where: { $0.id == message.id }) {
        // Already have it (our own echo) — keep our row, trust the server's timestamp.
        messages[index].createdAt = message.createdAt
        return
    }
    messages.append(message)
    messages.sort { $0.createdAt < $1.createdAt }
}

Scroll-to-bottom

Wrap the list in a ScrollViewReader and scroll to the last message whenever the count changes. Anchor on the last message's id.

ScrollViewReader { proxy in
    List(model.messages) { message in
        MessageRow(message: message).id(message.id)
    }
    .onChange(of: model.messages.count) {
        if let last = model.messages.last {
            withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
        }
    }
}

Typing indicator

A typing indicator is ephemeral state that does not belong in the messages table — it is a Realtime broadcast (a fire-and-forget event on the channel), not a database row. Send a typing event as the user edits, debounced, and clear it after a short idle.

func setTyping(_ isTyping: Bool) {
    Task { await channel.broadcast(event: "typing", message: ["on": isTyping]) }
}

On the receiving side, a broadcast handler flips a @Observable peerIsTyping flag that the view renders as an animated ellipsis. Because it is broadcast, not persisted, it vanishes with the connection — exactly what you want.

Tip

Distinguish persisted state (messages — a database row, replayed on reconnect) from ephemeral state (typing, presence — a broadcast that should evaporate). Putting typing in the messages table would litter your history with junk rows.

Groups, reactions, attachments, and read receipts

Four features turn the thread from a demo into a real chat client. None changes the realtime core — they add fields and rows the same view model already carries.

Groups are the same thread with sender attribution. A group Conversation has isGroup = true; the row joins each message's senderID to the author's profile and, for messages that aren't yours, shows the avatar and name above the bubble:

if isGroup && !isMine {
    Text(sender.name).font(.caption2).foregroundStyle(.secondary)
}

Reactions are a reactions table keyed to a message (message_id, author_id, symbol) — a live insert like any other. Long-press a bubble for a contextMenu of tapbacks; the chosen one inserts a reaction (toggling your own), and it renders as a badge overlapping the bubble's corner:

bubble
    .overlay(alignment: isMine ? .topLeading : .topTrailing) {
        if !message.reactions.isEmpty { TapbackBadge(reactions: message.reactions) }
    }
    .contextMenu {
        ForEach(tapbacks, id: \.self) { symbol in
            Button { model.react(to: message.id, symbol: symbol) } label: { Image(systemName: symbol) }
        }
    }

Attachments are an image bubble: a message carries an optional imageURL (a Storage path), and the row renders the image instead of text — the same PhotosPicker → Storage → row flow you built in Project 5, pointed at a message.

Read receipts are just the delivery-state ladder made visible. A message climbs sending → sent → delivered → read; render the label under only the last message you sent:

if isMine, message.id == lastMineID {
    switch message.deliveryState {
    case .read:      Text("Read")
    case .delivered: Text("Delivered")
    case .failed:    Text("Not Delivered")
    default:         EmptyView()
    }
}
Tip

Notice how little each adds. Groups surface a field you already had (senderID); reactions and attachments are extra rows/tables read the same way; receipts are the state machine you already built, printed. The realtime spine you got right in Milestones 1–2 is what makes the surface cheap — the recurring lesson of the backend tiers.

Elective: push notifications

Out of the graded core, but the hook is worth knowing. A Supabase database trigger on messages INSERT can call an edge function that sends an APNs push to the recipient's device token. In the app you register for remote notifications, store the token against the user, and handle the tap to deep-link into the right conversation. Wire this only after the core works — it is a stretch, not a milestone.

Checkpoint: your app should now…

  • Show a sent message instantly, greyed while sending, flagged on failed.
  • Deduplicate the server echo so your message never appears twice.
  • Auto-scroll to the newest message and show a typing indicator driven by broadcast.
  • Attribute senders in a group thread; long-press to react with a tapback badge.
  • Render an image attachment bubble, and a Delivered → Read receipt on your last sent message.

Stretch goals

  • Add a tap-to-retry on failed messages that re-runs postMessage with the same id.
  • Wire the APNs push elective end-to-end with an edge function trigger.

Knowledge check

Q: Why generate the message id on the client before sending? So you can match the server's echo back to the optimistic row you already displayed. Deduping by that shared id is what keeps the message from appearing twice and lets you flip it to sent.

Q: Why is the typing indicator a broadcast rather than a table row? Typing is ephemeral — it should disappear when the user stops or disconnects. Persisting it as a row would pollute the message history and replay stale "typing" state on reconnect.