Pingline M1 — models and the connection actorswift-6.4/ios-26
Lesson 2 / 6
Unit 26 · Pingline

Pingline M1 — models and the connection actor

Coming from
Note

Unit 26 · Pingline Milestone 1. You build the data model and the actor ConnectionState that every later milestone leans on. No UI yet — this is the spine.

Before a single message crosses the wire you need two things: value types that describe a message and a conversation, and one object that owns the truth about whether you are connected. That object has to be safe to hit from many tasks at once — the send path, the receive path, the reconnect timer — so it is an actor, not a class you hope nobody races.

The models

Messages come from a database and travel over a socket, so they must be Codable (to decode the JSON payload) and Sendable (to cross task boundaries safely). Structs of Sendable parts are Sendable for free.

import Foundation

struct Message: Identifiable, Codable, Sendable, Equatable {
    let id: UUID
    let conversationID: UUID
    let senderID: UUID
    let body: String
    var imageURL: URL?             // an optional image attachment (a Storage path)
    var reactions: [Reaction]      // tapbacks keyed to this message
    var createdAt: Date   // `var`: Milestone 3's echo-dedupe rewrites it to the server's timestamp

    // Local-only: how this message is doing on its way to the server.
    var deliveryState: DeliveryState = .sent
}

struct Reaction: Identifiable, Codable, Sendable, Equatable {
    let id: UUID
    let symbol: String   // an SF Symbol tapback
    let byMe: Bool
}

enum DeliveryState: String, Codable, Sendable {
    case sending    // optimistic — shown locally, not yet acknowledged
    case sent       // confirmed by the server
    case delivered  // reached the recipient's device
    case read       // the recipient opened the thread
    case failed     // the send did not go through
}

struct Conversation: Identifiable, Codable, Sendable, Equatable {
    let id: UUID
    let title: String
    var isGroup: Bool             // a group thread attributes each message to its sender
    var lastMessage: String
    var unreadCount: Int
}

Message is Identifiable so SwiftUI lists can diff it by id — you will care about that a lot in Project 7. The deliveryState field never comes from the server; it is how the client tracks an optimistic message through its lifecycle. createdAt gives you a stable sort key so out-of-order arrivals still render in send order.

Tip

Give every message a client-generated UUID before you send it. That id is what lets you match the server's echo back to the optimistic row you already showed — the seam that makes reconciliation possible in Milestone 3.

Why the connection is an actor

The connection has mutable state — are we connected, what is buffered — that is touched from several tasks concurrently. That is the exact situation actor exists for: it serializes access so no two tasks corrupt the buffer, without a lock you write by hand.

enum ConnectionStatus: Sendable {
    case disconnected
    case connecting
    case connected
}

actor ConnectionState {
    private(set) var status: ConnectionStatus = .disconnected
    private var outbox: [Message] = []   // messages queued while offline

    func markConnecting() { status = .connecting }
    func markConnected()  { status = .connected }
    func markDisconnected() { status = .disconnected }

    // Queue a message that could not be sent right now.
    func enqueue(_ message: Message) {
        outbox.append(message)
    }

    // Hand back everything buffered and clear the outbox, atomically.
    func drainOutbox() -> [Message] {
        let pending = outbox
        outbox.removeAll()
        return pending
    }
}

Every method here is synchronous inside the actor, so each one is atomic against the others. drainOutbox() in particular must be one method: reading the array and clearing it in a single isolated step means a message can never be sent twice or dropped. Split it into an awaited "read" and an awaited "clear" and you have introduced the exact race the checkpoint hunts.

Coming from Kotlin

In Kotlin you might reach for a Mutex around a MutableList, or a Channel for the outbox. An actor folds the mutual exclusion into the type: there is no lock object to forget to take, because the only way to touch outbox is through an isolated method. The reentrancy caveat still applies — keep read-modify-write inside one suspension-free method.

Wiring the client (shape only)

The Supabase client is created once and shared. Connect flips the status and, on success, drains anything that queued while you were down. You will implement the actual subscribe in Milestone 2; here you establish the state machine.

func connect() async {
    await markConnecting()
    // ... open the Supabase realtime channel here (Milestone 2) ...
    await markConnected()
    let pending = await drainOutbox()   // flush the offline buffer
    for message in pending {
        await send(message)             // send() defined in M3
    }
}

Notice connect() calls drainOutbox() once and iterates the snapshot. Because the drain is atomic, any message enqueued after the drain stays safely in the outbox for the next flush — no lost sends, no double sends.

Checkpoint: your app should now…

  • Compile with Message, Conversation, DeliveryState, and ConnectionStatus as Sendable value types.
  • Have an actor ConnectionState that tracks status and buffers an outbox.
  • Pass a quick unit test: enqueue three messages, drainOutbox() returns all three, and a second drain returns an empty array.

Stretch goals

  • Add a reconnect(after:) that backs off exponentially and caps the delay.
  • Track status changes as an AsyncStream<ConnectionStatus> so the UI can show a banner.

Knowledge check

Q: Why is ConnectionState an actor rather than an @Observable class? Because its mutable state is written from many tasks concurrently (send, receive, reconnect), and an actor serializes that access to prevent data races. An @Observable class is for state a view observes on the main actor; connection state is infrastructure hit off the main actor.

Q: Why must drainOutbox() be a single synchronous method? So reading the buffer and clearing it are atomic. If you split them across an await, another task could enqueue or drain in between, causing a message to be sent twice or lost.