| 1 | // fixtures/presence.mjs — who else is in this deck, and where they |
| 2 | // are: the heartbeat this tab posts, and the diff it makes of the set |
| 3 | // the server sends back. |
| 4 | // |
| 5 | // Presence is NOT document state (spec §3.3, and the plan's global |
| 6 | // constraint). Nothing in this file touches the Y.Doc, nothing it |
| 7 | // carries is ever saved, and a deck that nobody is looking at has no |
| 8 | // presence at all. It rides its own endpoint (POST /d/{sqid}/presence) |
| 9 | // and its own stream event, and the server holds it in memory for 45 |
| 10 | // seconds per heartbeat. |
| 11 | // |
| 12 | // What this tab says: {client, slide, block}. Nothing else — the name |
| 13 | // and the colour are stamped by the server from the users row |
| 14 | // (live.go), never sent from here, because a browser that could name |
| 15 | // itself could wear a colleague's name in somebody else's deck. |
| 16 | // |
| 17 | // What it hears: the whole set, every time, never a delta (hub.go's |
| 18 | // reason: a subscriber that applies a whole set is right rather than |
| 19 | // approximately right, and there is no "fetch the presence I missed"). |
| 20 | // So a stream that drops and reconnects needs no recovery here at all |
| 21 | // — the next set is complete, and applySet diffs it like any other. |
| 22 | // |
| 23 | // The DOM is presence-view.mjs's business; this file owns the timing |
| 24 | // and the arithmetic, so both are testable under plain Node with a |
| 25 | // fake clock (presence.test.mjs). |
| 26 | |
| 27 | // HEARTBEAT_MS is how often a tab that is doing nothing says it is |
| 28 | // still here. The server expires a presence after 45 s (live/hub.go's |
| 29 | // TTL), so this is two beats of slack: one lost request does not make |
| 30 | // somebody flicker out of the deck their colleague is watching. |
| 31 | const HEARTBEAT_MS = 20000 |
| 32 | |
| 33 | // CHANGE_MS coalesces a burst of movement. Arrowing down the strip or |
| 34 | // clicking through four blocks is one place arrived at, not four, and |
| 35 | // without this each keystroke would be its own request. |
| 36 | const CHANGE_MS = 200 |
| 37 | |
| 38 | /** |
| 39 | * A random client id for this tab: 16 hex characters. Per TAB rather |
| 40 | * than per person, because one person with the deck open twice is two |
| 41 | * cursors — keying on the user would make the second tab overwrite the |
| 42 | * first (hub.go says the same from the other side). |
| 43 | */ |
| 44 | export function newClientId (crypto = globalThis.crypto) { |
| 45 | const bytes = new Uint8Array(8) |
| 46 | crypto.getRandomValues(bytes) |
| 47 | return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('') |
| 48 | } |
| 49 | |
| 50 | /** |
| 51 | * The letters on an avatar: the first of each of the first two words, |
| 52 | * or the first two characters of an address's local part when the name |
| 53 | * IS an address (a person who has not set a name is their email). |
| 54 | * |
| 55 | * Never the only thing shown — the name rides beside it in `title` and |
| 56 | * in the label the avatar carries — so this is decoration that happens |
| 57 | * to be useful, and a name it handles badly costs nothing. |
| 58 | */ |
| 59 | export function initials (name) { |
| 60 | const raw = String(name || '').trim() |
| 61 | if (!raw) return '' |
| 62 | const local = raw.includes('@') ? raw.slice(0, raw.indexOf('@')) : raw |
| 63 | const words = local.split(/[\s._-]+/).filter(Boolean) |
| 64 | if (words.length === 0) return '' |
| 65 | if (words.length === 1) return [...words[0]].slice(0, 2).join('').toUpperCase() |
| 66 | return (([...words[0]][0] || '') + ([...words[1]][0] || '')).toUpperCase() |
| 67 | } |
| 68 | |
| 69 | /** One entry of the server's set, or null if it is not one. */ |
| 70 | function record (p) { |
| 71 | if (!p || typeof p !== 'object') return null |
| 72 | if (typeof p.client !== 'string' || !p.client) return null |
| 73 | return { |
| 74 | client: p.client, |
| 75 | name: typeof p.name === 'string' ? p.name : '', |
| 76 | colour: typeof p.colour === 'string' ? p.colour : '', |
| 77 | slide: typeof p.slide === 'string' ? p.slide : '', |
| 78 | block: typeof p.block === 'string' ? p.block : '' |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | /** |
| 83 | * The set as a Map keyed by client, with `mine` left out. Exported for |
| 84 | * the test and for diffSets' callers; the filter is the whole reason |
| 85 | * this is not just `new Map(...)` — a tab that drew itself would show |
| 86 | * a second cursor of its own, on its own block, forever. |
| 87 | */ |
| 88 | export function toMap (set, mine) { |
| 89 | const map = new Map() |
| 90 | for (const raw of Array.isArray(set) ? set : []) { |
| 91 | const p = record(raw) |
| 92 | if (!p || p.client === mine) continue |
| 93 | map.set(p.client, p) |
| 94 | } |
| 95 | return map |
| 96 | } |
| 97 | |
| 98 | /** |
| 99 | * What changed between two sets: who arrived, who moved, who left. |
| 100 | * |
| 101 | * added and moved carry the NEW records, removed the old ones — a |
| 102 | * person who has gone is still named in the announcement of their |
| 103 | * going, and the old record is the only place that name still is. |
| 104 | * |
| 105 | * A name or colour changing on its own is not a move: it is not |
| 106 | * somewhere a person went, and the view redraws from the whole set |
| 107 | * anyway. Only the slide or the block makes a move. |
| 108 | */ |
| 109 | export function diffSets (last, next) { |
| 110 | const added = [] |
| 111 | const moved = [] |
| 112 | const removed = [] |
| 113 | for (const [client, p] of next) { |
| 114 | const before = last.get(client) |
| 115 | if (!before) added.push(p) |
| 116 | else if (before.slide !== p.slide || before.block !== p.block) moved.push(p) |
| 117 | } |
| 118 | for (const [client, p] of last) { |
| 119 | if (!next.has(client)) removed.push(p) |
| 120 | } |
| 121 | return { added, moved, removed } |
| 122 | } |
| 123 | |
| 124 | /** |
| 125 | * The heartbeat and the set. |
| 126 | * |
| 127 | * post({client, slide, block}) sends one heartbeat and resolves (or |
| 128 | * rejects — a failed beat is dropped, and the next one is 20 s away). |
| 129 | * clientId is this tab's own id. getState() answers {slide, block}: |
| 130 | * where this tab is right now, read at the moment of sending rather |
| 131 | * than passed in, so a beat is never a stale place. |
| 132 | * |
| 133 | * Returns {start, stop, changed, noteHidden, noteVisible, applySet, |
| 134 | * people, clientId, setClientId}. |
| 135 | */ |
| 136 | export function createPresence ({ |
| 137 | post, |
| 138 | clientId, |
| 139 | getState, |
| 140 | setTimeoutFn = setTimeout, |
| 141 | clearTimeoutFn = clearTimeout, |
| 142 | heartbeatMs = HEARTBEAT_MS, |
| 143 | changeMs = CHANGE_MS |
| 144 | }) { |
| 145 | let beat = null |
| 146 | let debounce = null |
| 147 | let hidden = false |
| 148 | let stopped = true |
| 149 | let last = new Map() |
| 150 | let id = clientId |
| 151 | |
| 152 | function send () { |
| 153 | const at = getState() || {} |
| 154 | // A failed beat is nothing to say out loud: presence is not the |
| 155 | // save path, and the person's work is not at risk. The next beat |
| 156 | // (or the next move) says it again. Called synchronously — a beat |
| 157 | // deferred to a microtask would be one the timer's own re-arming |
| 158 | // could outrun. |
| 159 | try { |
| 160 | const posting = post({ client: id, slide: at.slide || '', block: at.block || '' }) |
| 161 | if (posting && typeof posting.catch === 'function') posting.catch(() => {}) |
| 162 | } catch { /* the next beat */ } |
| 163 | } |
| 164 | |
| 165 | // One timer for the heartbeat, re-armed after every send: a post |
| 166 | // caused by a move IS a heartbeat, so moving around a deck never |
| 167 | // makes a tab post twice inside the same second. |
| 168 | function arm () { |
| 169 | if (beat !== null) clearTimeoutFn(beat) |
| 170 | beat = setTimeoutFn(() => { beat = null; if (!stopped && !hidden) { send(); arm() } }, heartbeatMs) |
| 171 | } |
| 172 | |
| 173 | function clearDebounce () { |
| 174 | if (debounce === null) return |
| 175 | clearTimeoutFn(debounce) |
| 176 | debounce = null |
| 177 | } |
| 178 | |
| 179 | function beatNow () { |
| 180 | clearDebounce() |
| 181 | send() |
| 182 | arm() |
| 183 | } |
| 184 | |
| 185 | return { |
| 186 | // start posts straight away rather than at the end of the first |
| 187 | // interval: the point of opening a deck is that everybody else |
| 188 | // sees you in it, and twenty seconds of not being there is the |
| 189 | // whole of the collaboration you came for. |
| 190 | start () { |
| 191 | stopped = false |
| 192 | hidden = false |
| 193 | beatNow() |
| 194 | }, |
| 195 | |
| 196 | stop () { |
| 197 | stopped = true |
| 198 | clearDebounce() |
| 199 | if (beat !== null) clearTimeoutFn(beat) |
| 200 | beat = null |
| 201 | }, |
| 202 | |
| 203 | // changed is a slide or a block being arrived at. Debounced, and |
| 204 | // deliberately not compared against the last thing sent: the state |
| 205 | // is read when the timer fires, so a burst that ends where it |
| 206 | // started sends one beat saying exactly that. |
| 207 | changed () { |
| 208 | if (stopped || hidden) return |
| 209 | clearDebounce() |
| 210 | debounce = setTimeoutFn(() => { debounce = null; beatNow() }, changeMs) |
| 211 | }, |
| 212 | |
| 213 | // A hidden tab stops posting and lets the server expire it: a |
| 214 | // laptop closed on Friday must not go on standing in a deck all |
| 215 | // weekend. Nothing is posted to say so — there is no "I left" |
| 216 | // message, because the one that matters is the one nobody can |
| 217 | // send, and the TTL covers both. |
| 218 | noteHidden () { |
| 219 | if (stopped) return |
| 220 | hidden = true |
| 221 | clearDebounce() |
| 222 | if (beat !== null) clearTimeoutFn(beat) |
| 223 | beat = null |
| 224 | }, |
| 225 | |
| 226 | // Back again: post immediately rather than waiting, because the |
| 227 | // server has very likely already expired this tab and the person |
| 228 | // is looking at a deck that does not know they are in it. |
| 229 | noteVisible () { |
| 230 | if (stopped || !hidden) return |
| 231 | hidden = false |
| 232 | beatNow() |
| 233 | }, |
| 234 | |
| 235 | // applySet takes the whole set off the stream and answers what |
| 236 | // changed. This tab's own entry is dropped on the way in, so |
| 237 | // nothing downstream has to remember to. |
| 238 | applySet (set) { |
| 239 | const next = toMap(set, id) |
| 240 | const diff = diffSets(last, next) |
| 241 | last = next |
| 242 | return { ...diff, people: Array.from(next.values()) } |
| 243 | }, |
| 244 | |
| 245 | get people () { return Array.from(last.values()) }, |
| 246 | // setClientId switches this tab to another id — client-id.mjs's |
| 247 | // answer to a duplicated tab that started with its original's — |
| 248 | // and says so straight away, so the server holds the new one. |
| 249 | setClientId (next) { |
| 250 | if (!next || next === id) return |
| 251 | id = next |
| 252 | if (!stopped && !hidden) beatNow() |
| 253 | }, |
| 254 | |
| 255 | get clientId () { return id } |
| 256 | } |
| 257 | } |
| 258 | |