// fixtures/presence.mjs — who else is in this deck, and where they // are: the heartbeat this tab posts, and the diff it makes of the set // the server sends back. // // Presence is NOT document state (spec §3.3, and the plan's global // constraint). Nothing in this file touches the Y.Doc, nothing it // carries is ever saved, and a deck that nobody is looking at has no // presence at all. It rides its own endpoint (POST /d/{sqid}/presence) // and its own stream event, and the server holds it in memory for 45 // seconds per heartbeat. // // What this tab says: {client, slide, block}. Nothing else — the name // and the colour are stamped by the server from the users row // (live.go), never sent from here, because a browser that could name // itself could wear a colleague's name in somebody else's deck. // // What it hears: the whole set, every time, never a delta (hub.go's // reason: a subscriber that applies a whole set is right rather than // approximately right, and there is no "fetch the presence I missed"). // So a stream that drops and reconnects needs no recovery here at all // — the next set is complete, and applySet diffs it like any other. // // The DOM is presence-view.mjs's business; this file owns the timing // and the arithmetic, so both are testable under plain Node with a // fake clock (presence.test.mjs). // HEARTBEAT_MS is how often a tab that is doing nothing says it is // still here. The server expires a presence after 45 s (live/hub.go's // TTL), so this is two beats of slack: one lost request does not make // somebody flicker out of the deck their colleague is watching. const HEARTBEAT_MS = 20000 // CHANGE_MS coalesces a burst of movement. Arrowing down the strip or // clicking through four blocks is one place arrived at, not four, and // without this each keystroke would be its own request. const CHANGE_MS = 200 /** * A random client id for this tab: 16 hex characters. Per TAB rather * than per person, because one person with the deck open twice is two * cursors — keying on the user would make the second tab overwrite the * first (hub.go says the same from the other side). */ export function newClientId (crypto = globalThis.crypto) { const bytes = new Uint8Array(8) crypto.getRandomValues(bytes) return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('') } /** * The letters on an avatar: the first of each of the first two words, * or the first two characters of an address's local part when the name * IS an address (a person who has not set a name is their email). * * Never the only thing shown — the name rides beside it in `title` and * in the label the avatar carries — so this is decoration that happens * to be useful, and a name it handles badly costs nothing. */ export function initials (name) { const raw = String(name || '').trim() if (!raw) return '' const local = raw.includes('@') ? raw.slice(0, raw.indexOf('@')) : raw const words = local.split(/[\s._-]+/).filter(Boolean) if (words.length === 0) return '' if (words.length === 1) return [...words[0]].slice(0, 2).join('').toUpperCase() return (([...words[0]][0] || '') + ([...words[1]][0] || '')).toUpperCase() } /** One entry of the server's set, or null if it is not one. */ function record (p) { if (!p || typeof p !== 'object') return null if (typeof p.client !== 'string' || !p.client) return null return { client: p.client, name: typeof p.name === 'string' ? p.name : '', colour: typeof p.colour === 'string' ? p.colour : '', slide: typeof p.slide === 'string' ? p.slide : '', block: typeof p.block === 'string' ? p.block : '' } } /** * The set as a Map keyed by client, with `mine` left out. Exported for * the test and for diffSets' callers; the filter is the whole reason * this is not just `new Map(...)` — a tab that drew itself would show * a second cursor of its own, on its own block, forever. */ export function toMap (set, mine) { const map = new Map() for (const raw of Array.isArray(set) ? set : []) { const p = record(raw) if (!p || p.client === mine) continue map.set(p.client, p) } return map } /** * What changed between two sets: who arrived, who moved, who left. * * added and moved carry the NEW records, removed the old ones — a * person who has gone is still named in the announcement of their * going, and the old record is the only place that name still is. * * A name or colour changing on its own is not a move: it is not * somewhere a person went, and the view redraws from the whole set * anyway. Only the slide or the block makes a move. */ export function diffSets (last, next) { const added = [] const moved = [] const removed = [] for (const [client, p] of next) { const before = last.get(client) if (!before) added.push(p) else if (before.slide !== p.slide || before.block !== p.block) moved.push(p) } for (const [client, p] of last) { if (!next.has(client)) removed.push(p) } return { added, moved, removed } } /** * The heartbeat and the set. * * post({client, slide, block}) sends one heartbeat and resolves (or * rejects — a failed beat is dropped, and the next one is 20 s away). * clientId is this tab's own id. getState() answers {slide, block}: * where this tab is right now, read at the moment of sending rather * than passed in, so a beat is never a stale place. * * Returns {start, stop, changed, noteHidden, noteVisible, applySet, * people, clientId, setClientId}. */ export function createPresence ({ post, clientId, getState, setTimeoutFn = setTimeout, clearTimeoutFn = clearTimeout, heartbeatMs = HEARTBEAT_MS, changeMs = CHANGE_MS }) { let beat = null let debounce = null let hidden = false let stopped = true let last = new Map() let id = clientId function send () { const at = getState() || {} // A failed beat is nothing to say out loud: presence is not the // save path, and the person's work is not at risk. The next beat // (or the next move) says it again. Called synchronously — a beat // deferred to a microtask would be one the timer's own re-arming // could outrun. try { const posting = post({ client: id, slide: at.slide || '', block: at.block || '' }) if (posting && typeof posting.catch === 'function') posting.catch(() => {}) } catch { /* the next beat */ } } // One timer for the heartbeat, re-armed after every send: a post // caused by a move IS a heartbeat, so moving around a deck never // makes a tab post twice inside the same second. function arm () { if (beat !== null) clearTimeoutFn(beat) beat = setTimeoutFn(() => { beat = null; if (!stopped && !hidden) { send(); arm() } }, heartbeatMs) } function clearDebounce () { if (debounce === null) return clearTimeoutFn(debounce) debounce = null } function beatNow () { clearDebounce() send() arm() } return { // start posts straight away rather than at the end of the first // interval: the point of opening a deck is that everybody else // sees you in it, and twenty seconds of not being there is the // whole of the collaboration you came for. start () { stopped = false hidden = false beatNow() }, stop () { stopped = true clearDebounce() if (beat !== null) clearTimeoutFn(beat) beat = null }, // changed is a slide or a block being arrived at. Debounced, and // deliberately not compared against the last thing sent: the state // is read when the timer fires, so a burst that ends where it // started sends one beat saying exactly that. changed () { if (stopped || hidden) return clearDebounce() debounce = setTimeoutFn(() => { debounce = null; beatNow() }, changeMs) }, // A hidden tab stops posting and lets the server expire it: a // laptop closed on Friday must not go on standing in a deck all // weekend. Nothing is posted to say so — there is no "I left" // message, because the one that matters is the one nobody can // send, and the TTL covers both. noteHidden () { if (stopped) return hidden = true clearDebounce() if (beat !== null) clearTimeoutFn(beat) beat = null }, // Back again: post immediately rather than waiting, because the // server has very likely already expired this tab and the person // is looking at a deck that does not know they are in it. noteVisible () { if (stopped || !hidden) return hidden = false beatNow() }, // applySet takes the whole set off the stream and answers what // changed. This tab's own entry is dropped on the way in, so // nothing downstream has to remember to. applySet (set) { const next = toMap(set, id) const diff = diffSets(last, next) last = next return { ...diff, people: Array.from(next.values()) } }, get people () { return Array.from(last.values()) }, // setClientId switches this tab to another id — client-id.mjs's // answer to a duplicated tab that started with its original's — // and says so straight away, so the server holds the new one. setClientId (next) { if (!next || next === id) return id = next if (!stopped && !hidden) beatNow() }, get clientId () { return id } } }