oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

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.
31const 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.
36const 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 */
44export 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 */
59export 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. */
70function 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 */
88export 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 */
109export 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 */
136export 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