Pingline M3 — optimistic send and reconciliation
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.
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.
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() } }
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 onfailed. - 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
failedmessages that re-runspostMessagewith 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.