gampleman/elm-crdt/1.0.0

Crdt.Presence

Presence is the "who's here and what are they doing" layer of a collaborative app — each person's name and colour, whether they are online, where their cursor is — as opposed to the document they are editing together.

It is kept deliberately separate from your Crdt.Doc, because it behaves differently: presence is throwaway. You want the newest value and nothing else — when someone moves their cursor you only care where it is now, and when they close the tab their marker should just disappear. There is no history to preserve and nothing to reconcile character by character, so presence has its own tiny, self-contained mechanism rather than going through the document's CRDT machinery.

Each peer owns one slot in a shared table, keyed by its Crdt.Id.ReplicaId. When two tables merge, the newer value wins for each peer — simple last-writer-wins, per peer.

Describing your presence value

You first describe the shape of one peer's presence with a small Codec, so it can be sent as JSON. It reads like a compact record builder:

type alias Peer =
    { name : String
    , color : String
    , caret : Maybe Cursor
    }


peerCodec : Presence.Codec Peer
peerCodec =
    Presence.codec Peer
        |> Presence.field "name" .name Presence.string
        |> Presence.field "color" .color Presence.string
        |> Presence.optional "caret" .caret Presence.cursor
        |> Presence.buildCodec

Using it

Keep one Presence Peer in your model alongside your document. Set your own state as it changes, broadcast the table to peers exactly as you broadcast document updates, merge what comes back, and render everyone with peers:

model.presence
    |> Presence.setLocal { name = "Alice", color = "#e11", caret = Just cursor }
    |> Presence.encode


-- send this JSON to peers
-- on receiving a peer's table:
case Presence.decode peerCodec json of
    Ok theirs ->
        { model | presence = Presence.merge model.presence theirs }


    Err _ ->
        model

The presence table

type Presence a

A table of everyone's presence state: this replica's own id and codec, plus the latest known value for every peer (including itself). Opaque — build one with init.

type Codec a

Describes how one peer's presence value is turned into JSON and back. Build one with codec/field/buildCodec. Opaque.

Setting and reading state

init : ReplicaId -> Codec a -> Presence a

A fresh, empty presence table for a replica. Pass the same ReplicaId you gave Crdt.init and the Codec describing your presence value.

setLocal : a -> Presence a -> Presence a

Set this replica's own presence state (replacing any previous value). Call it whenever your local state changes — the user renamed themselves, moved their cursor — and then broadcast the table.

updateLocal : (a -> a) -> Presence a -> Presence a

Update this replica's own state with a function, if it has been set. Handy for changing one part — moving the cursor while keeping name and colour:

Presence.updateLocal (\me -> { me | caret = Just cursor }) model.presence
local : Presence a -> Maybe a

This replica's own current state, if it has been set.

peers : Presence a -> List ( ReplicaId, a )

Every known peer and its state, including yourself, ordered by replica id. This is what you fold over to render collaborators (filter out your own id if you don't want to draw yourself).

Merge a table received from a peer into this one, keeping the newer value for each replica. Like document merge, the result is the same no matter what order tables arrive in.

remove : ReplicaId -> Presence a -> Presence a

Drop a peer from the table, e.g. when they disconnect. Because presence is throwaway there is nothing left behind — the peer simply vanishes, and reappears if it broadcasts again later.

Sending it over the wire

encode : Presence a -> Value

Serialize the whole presence table to JSON, to broadcast to peers.

decode : Codec a -> Value -> Result String (Presence a)

Decode a presence table received from a peer. Merge the result into your own with merge.

Describing a presence value

codec : (a -> b) -> CodecBuilder full (a -> b)

Start describing a presence value, given the constructor of your record (just like Crdt.record). Follow it with a field/optional per record field and finish with buildCodec.

String
-> (full -> a)
-> CodecBuilder full (a -> b)
-> CodecBuilder full b

Add a required field: its JSON name, a getter, and its field type.

String
-> (full -> Maybe a)
-> CodecBuilder full (Maybe a -> b)
-> CodecBuilder full b

Add an optional field, read as a Maybe and sent as null when absent — good for things not always present, like a cursor only shown while editing.

buildCodec : CodecBuilder a a -> Codec a

Finish building a presence codec.

Field types

A string field.

A boolean field.

An integer field.

A Crdt.Cursor field — a peer's caret position. The common case for sharing where everyone is typing, so it is built in:

Presence.codec Peer
    |> Presence.optional "caret" .caret Presence.cursor
    |> Presence.buildCodec

A Crdt.Cursor.Range field — a peer's text selection (its two ends). Like cursor, but for a highlighted range rather than a single caret.

custom : (a -> Value) -> Decoder a -> FieldCodec a

A field with your own JSON encoder and decoder — for anything the built-in field types don't cover:

Presence.custom encodeColor colorDecoder
type FieldCodec a

Describes one field of a presence value (see string / bool / int / custom).