Multiplayer live preview with presence
Scope: co-viewing, which is on in production. This page is about two people watching the same draft preview — a presence bar and live cursors, both live in a production build. CRDT co-editing (simultaneous typing in one rich-text block) is a separate prototype, disabled in production: it is gated on
config :kiln_cms, :collab_prototype, which is on only inconfig/dev.exsandconfig/test.exs(config/prod.exspins it off). See collaborative-editing-spike.md.
Two (or more) people can open the same live preview of a draft and see each other in it — a live presence bar of who's watching, plus each other's cursors moving over the page in real time (#343). An editor and a stakeholder can review the same draft together, on the same URL — a collaboration feature even Sanity gates behind enterprise pricing.
The asymmetry
A Phoenix.Presence tracker already exists; shared preview is near-trivial on
top. Phoenix.Presence handles join/leave over the cluster, and native
Phoenix.PubSub carries cursor moves — no external realtime service, sub-200ms
on a LAN.
Using it
Open a content item's pop-out preview (the Preview action in the editor,
/editor/preview/:kind/:id). When more than one person has it open:
- Presence bar — a coloured avatar per viewer (initial + name on hover) and a "N viewing" count, in the draft ribbon. Updates live as people join and leave.
- Cursors — every other viewer's pointer is drawn on the preview with their name and colour, following their mouse in real time.
Each viewer gets a stable colour (used for both their avatar and their cursor) so they're easy to track. Editor/admin only, like the rest of the preview.
How it works
- Presence. On the connected mount,
PreviewLivetracks the viewer onKilnCMSWeb.Presenceunder a per-window key carrying their display name and colour (track_preview_viewer/5), and subscribes to the topic's diffs. A join or leave re-renders the presence bar. (This lands on the same presence topic the editor already watches to decide whether to build preview payloads, sopreviews_open?/2keeps working.) - Cursors. A small
PreviewCursorsJS hook reports the pointer position as fractions (0..1) of the preview surface, throttled to ~20/sec. The server broadcasts it to the other viewers over PubSub (broadcast_from, so a viewer never renders their own cursor); each co-viewer renders it at the right spot regardless of their window size. A viewer's cursor is dropped when they leave (mouse-out or disconnect). - Privacy. Viewers are shown by their chosen display name, never their email (issue #214) — the same rule as the editing presence indicator.
Modules: KilnCMSWeb.PreviewLive, KilnCMSWeb.Presence (the
track_preview_viewer / preview_viewers / preview_cursor_topic additions),
and the PreviewCursors hook in assets/js/app.js.
Verification
Verified with Phoenix.LiveViewTest + Presence assertions (the pop-out's
LiveView socket isn't reliably driveable from the in-app preview browser):
KilnCMSWeb.PreviewLiveTest mounts two viewers on the same preview and asserts
they see each other in the presence bar, that one viewer's render_hook("cursor", …) shows up as a positioned cursor on the other's screen (and never on their
own), and that leaving drops both the viewer and their cursor for everyone else.
Scope & follow-ons
Phase-1 slice:
- Cursors, not selections. Live text-selection / caret sharing inside the block editor is a bigger, CRDT-adjacent effort; this covers preview cursors.
- Shared locale switcher (#378, shipped) — the ribbon shows the document's
locale; when locale siblings exist (same slug, other locales) it becomes a
switcher that moves every co-viewer to the sibling's preview (a
{:preview_switch, id}broadcast on the preview topic → everyonepush_navigates together, and presence re-forms there). Late joiners follow the shared URL, so the group is always on the same variant. - Presence on the token preview (#379, shipped) —
/preview/:token/liveis the human face of the token link: external stakeholders without an editor account join the same presence/cursor session as the editor pop-out (same topics per{kind, id}), see live edits, and appear as "Guest N" (renameable; no account data exists to leak). Browsers opening the JSON link are content-negotiated to it; the JSON surface and the tight:previewrate limit are unchanged.