source: Klonkt/src/assets/js/guardian.js@ 0677bac

main
Last change on this file since 0677bac was e667d26, checked in by roboburr <roboburr@…>, 5 weeks ago

Een levende klok in het antwoord maakte de lange poll een lus (9-8)

Barts melding: de browser komt in een feedback-lus van ongeveer een seconde, en
de scrollpositie wordt vermalen. Dat is mijn fout van een uur eerder, en de
oorzaak is scherper dan hij lijkt.

Het paneel kreeg gisteren een inhouds-ETag, en de paneelstate bevat -- bevatte --
ageMs: de leeftijd van de oudste oppik, uitgerekend als now - tijdstip.
Daardoor verschilde elk antwoord een paar milliseconden van het vorige, kon de
ETag nooit gelijk zijn, kwam de 304 nooit, keerde de lange poll meteen terug, en
tekende het paneel zich stuk terwijl Bart scrollde.

EEN LEVENDE KLOK MAAKT EEN ANTWOORD ONVERGELIJKBAAR MET ZICHZELF. Dat is de les,
en hij raakt elk veld dat we ooit nog toevoegen. Er staat nu een toets op die
hetzelfde antwoord twee keer opvraagt met een halve seconde ertussen en eist dat
het identiek is.

De reparatie zit bij de BRON, niet in de ETag: het antwoord draagt nu het
TIJDSTIP (oldestPickupAt) en de client rekent de leeftijd uit. Dat is ook wat het
altijd al was -- een weergavedetail. De ETag uitzonderingen laten maken zou een
tweede beschrijving zijn van "wat telt als veranderd", precies wat ik gisteren
als argument tegen een cursor gebruikte.

TWEE GRENDELS ERBIJ, aan de kant die de gebruiker merkt:

  • Zelfde inhoud wordt niet hertekend. De 304 hoort dit af te vangen, maar een enkel onstabiel veld zette het scherm aan het knipperen; dit vangt de volgende.
  • Een lange poll die binnen twee seconden terugkeert wacht drie seconden voordat hij opnieuw vraagt. Een poll die niet wacht mag nooit een lus worden -- anders vervangt een defect een tik per 45 seconden door duizend per minuut.

En de scrollpositie overleeft een verversing: meten en terugzetten rond
renderAll. Dat is het verschil tussen een scherm dat bijwerkt en een scherm dat
onder je handen wegschuift.

Suite 782/782; met de klok terug valt hij om.

  • Property mode set to 100644
File size: 55.4 KB
Line 
1/* Guardian PWA client (FEP-633c): renders the dashboard, adopts wards, and
2 manages the guardian push channel. No framework, no inline scripts (CSP).
3 All user-facing text comes from state.strings (server i18n). */
4(function () {
5 'use strict';
6 // A crash here used to fail silently (buttons just do nothing). Surface it on
7 // the page AND the console so the cause is visible instead of "everything hangs".
8 function fatal(msg) {
9 try {
10 var b = document.getElementById('g-fatal') || document.createElement('div');
11 b.id = 'g-fatal'; b.className = 'g-msg err';
12 b.style.cssText = 'display:block;margin:12px 0;padding:10px 14px';
13 b.textContent = 'Guardian: ' + msg;
14 var root = document.querySelector('main') || document.body;
15 if (!b.parentNode && root) root.insertBefore(b, root.firstChild);
16 } catch (e) { /* last resort */ }
17 try { console.error('[guardian]', msg); } catch (e) { /* no console */ }
18 }
19 try {
20 var S = JSON.parse(document.getElementById('guardian-state').textContent || '{}');
21 var T = S.strings || {};
22
23 function el(tag, cls, text) {
24 var n = document.createElement(tag);
25 if (cls) n.className = cls;
26 if (text != null) n.textContent = text;
27 return n;
28 }
29 function handleOf(uri, cached) {
30 if (cached && cached.charAt(0) === '@') return cached; // trust only real @handles
31 try { var u = new URL(uri); return '@' + u.pathname.split('/').filter(Boolean).pop() + '@' + u.host; }
32 catch (e) { return uri; }
33 }
34 // The server hands over a timestamp already formatted in the site's timezone
35 // (Beheer -> Instellingen), the same clock de Krant and Berichten show. The
36 // slice is only a fallback for a row that predates that field: it shows raw
37 // UTC, which is what made a 20:20 call for help read 18:20.
38 function when(item, raw) {
39 if (item && item.when_text) return item.when_text;
40 return String(raw || '').slice(0, 16).replace('T', ' ');
41 }
42 function show(id, on) { document.getElementById(id).hidden = !on; }
43
44 // ── 1. Help requests ───────────────────────────────────────────────────
45 // A call for help is not an alarm: it may well be settled quietly between a
46 // guardian and the child. So the card carries no siren, it just has to be
47 // impossible to miss. It shows up twice on purpose (Robins keuze): the recent
48 // ones across all children at the top, the full history of one child in that
49 // child's panel.
50 var HELP_TOP = 5;
51
52 /**
53 * Wacht deze hulpvraag nog op iemand?
54 *
55 * EEN plek, met opzet. Dit werd eerst op twee plekken los beslist -- de lijst
56 * en de teller op de wardregel -- en toen de lijst gefilterd werd bleef die
57 * teller een afgehandelde vraag meetellen. Een derde plek komt er vast, en dan
58 * hoort hij hier langs te komen.
59 *
60 * Bij TWIJFEL open: alles wat geen expliciete afsluiting draagt telt als
61 * wachtend. De omgekeerde fout -- iets als afgehandeld tonen dat het niet is --
62 * is hier de gevaarlijke.
63 */
64 function helpOpen(h) { return !h || !h.state || (h.state.open && !h.state.formerWard); }
65
66 function helpCard(h) {
67 var card = el('div', 'g-card help');
68 var row = el('div', 'row');
69 var who = el('span', 'who grow');
70 // name_html carries the custom emojis (FEP-9098) of the display name, the
71 // same way de Krant renders a byline. Falls back to the plain name.
72 if (h.name_html) who.innerHTML = h.name_html;
73 else who.textContent = h.actor_name || handleOf(h.actor_uri, h.actor_handle);
74 row.appendChild(who);
75 row.appendChild(el('span', 'when', when(h, h.published || h.created_at)));
76 card.appendChild(row);
77 var body = el('div', 'body g-note');
78 // body_html is the shared note-body partial, rendered server-side: the
79 // content with its emojis, the quote / link-preview card and the media.
80 // Falls back to the bare content for rows stored before that existed.
81 body.innerHTML = h.body_html || h.content || ''; // sanitized server-side on ingest
82 card.appendChild(body);
83 if (h.note_url) {
84 var a = el('a', 'g-link', T.open || 'open');
85 a.href = h.note_url; a.target = '_blank'; a.rel = 'noopener';
86 card.appendChild(a);
87 }
88 card.appendChild(helpState(h));
89 return card;
90 }
91
92 /** Hoe lang geleden, grof. Een oppik vervalt niet maar hoort wel te verouderen. */
93 function ago(ms) {
94 var u = Math.floor(ms / 3600000);
95 if (u < 1) return T.help_just_now || 'just now';
96 if (u < 24) return (T.help_hours || '{n}h ago').replace('{n}', u);
97 return (T.help_days || '{n}d ago').replace('{n}', Math.floor(u / 24));
98 }
99
100 /**
101 * De gedeelde staat van een hulpvraag (shaer-lgo): wie er al op af is, of het
102 * is afgesloten, en de twee knoppen.
103 *
104 * De faalstand hier is NIET veilig: 'iedereen denkt dat het geregeld is' is
105 * gevaarlijker dan geen markering. Dus bij twijfel toont dit OPEN, en een oude
106 * oppik verkleurt in plaats van te verdwijnen -- anders ziet een hulpvraag er
107 * onaangeroerd uit terwijl er iemand mee bezig is.
108 */
109 function helpState(h) {
110 var st = h.state || { open: true, pickedUpBy: [], handled: null, oldestPickupAt: null };
111 var box = el('div', 'g-help-state');
112
113 // Niet meer jouw ward: geen knoppen, want de route zou ze weigeren. En geen
114 // "afgehandeld", want dat zou een claim zijn over een kind waar je niets meer
115 // over te zeggen hebt.
116 if (st.formerWard) {
117 box.appendChild(el('div', 'g-help-former', T.help_former_ward || ''));
118 return box;
119 }
120
121 if (st.handled) {
122 var wie = st.handled.handle || handleOf(st.handled.uri);
123 box.appendChild(el('div', 'g-help-done', (T.help_handled_by || 'Handled by {who}').replace('{who}', wie)));
124 // Geen terugdraaiknop: leeft de vraag nog, dan wordt hij opnieuw gesteld.
125 box.appendChild(el('p', 'small g-empty', T.help_handled_note || ''));
126 return box;
127 }
128
129 (st.pickedUpBy || []).forEach(function (p) {
130 var line = (T.help_picked_by || '{who} is looking into this').replace('{who}', p.handle || handleOf(p.uri));
131 box.appendChild(el('div', 'g-help-pick', line));
132 });
133 // Oud, maar niet weg. Dit is het verschil tussen 'er is iemand mee bezig' en
134 // 'er was ooit iemand mee bezig'.
135 // De leeftijd wordt HIER uitgerekend, niet op de server: een verschil met
136 // `now` in het antwoord maakt dat antwoord elke milliseconde anders, en dan
137 // kan de ETag nooit gelijk zijn.
138 var oud = st.oldestPickupAt ? (Date.now() - Date.parse(st.oldestPickupAt)) : null;
139 if (oud != null && !isNaN(oud) && oud > 3600000) {
140 box.appendChild(el('div', 'g-help-age small', ago(oud)));
141 }
142
143 var row = el('div', 'row');
144 var mij = (st.pickedUpBy || []).some(function (p) { return p.uri === S.me; });
145 if (!mij) {
146 var pick = el('button', 'small', T.help_pick || 'I am on it');
147 pick.addEventListener('click', function () { markHelp(h, 'pickup', pick); });
148 row.appendChild(pick);
149 }
150 var done = el('button', 'quiet small', T.help_close || 'Mark handled');
151 // Een stevige bevestiging, en nooit een window.confirm: dat verstopt een
152 // uitleg achter een OK die mensen wegklikken. Zelfde lijn als het loslaten
153 // van een ward.
154 done.addEventListener('click', function () {
155 done.hidden = true;
156 var ask = el('div', 'g-confirm');
157 ask.appendChild(el('p', 'small', T.help_close_ask || ''));
158 var ja = el('button', 'small', T.help_close_yes || 'Yes, handled');
159 ja.addEventListener('click', function () { markHelp(h, 'handled', ja); });
160 var nee = el('button', 'quiet small', T.release_no || 'No');
161 nee.addEventListener('click', function () { ask.remove(); done.hidden = false; });
162 ask.appendChild(ja); ask.appendChild(nee);
163 box.appendChild(ask);
164 });
165 row.appendChild(done);
166 box.appendChild(row);
167 return box;
168 }
169
170 function markHelp(h, kind, btn) {
171 if (btn) btn.disabled = true;
172 fetch('/guardian/api/help/' + kind, {
173 method: 'POST', headers: { 'Content-Type': 'application/json' },
174 body: JSON.stringify({ note: h.object_uri, ward: h.actor_uri, site: S.site }),
175 }).then(refresh).catch(function () { if (btn) btn.disabled = false; });
176 }
177
178 function renderHelp() {
179 var list = document.getElementById('help-list');
180 list.textContent = '';
181 var alle = S.help || [];
182 // Afgehandeld hoort niet meer in de lijst die om aandacht vraagt, en niet
183 // meer in de teller: anders blijft het cijfer alarm slaan voor iets dat af
184 // is, en neemt een gesloten vraag de ruimte in van een open vraag.
185 var open = alle.filter(helpOpen);
186 var klaar = alle.filter(function (h) { return !helpOpen(h); });
187
188 open.slice(0, HELP_TOP).forEach(function (h) { list.appendChild(helpCard(h)); });
189 if (open.length > HELP_TOP) {
190 list.appendChild(el('p', 'g-sec-sub', '+ ' + (open.length - HELP_TOP) + ' — ' + (T.panel_help || '')));
191 }
192
193 // WEG is niet hetzelfde als AF. Een afgehandelde hulpvraag blijft bestaan --
194 // er wordt in dit systeem niets herschreven -- maar hij hoort achter een
195 // klik, niet voor je neus. De volledige geschiedenis per kind staat sowieso
196 // in het paneel van dat kind.
197 if (klaar.length) {
198 var doos = el('div', 'g-help-archive');
199 doos.hidden = true;
200 klaar.forEach(function (h) { doos.appendChild(helpCard(h)); });
201 var knop = el('button', 'quiet small', (T.help_archive || '{n} handled').replace('{n}', klaar.length));
202 knop.addEventListener('click', function () {
203 doos.hidden = !doos.hidden;
204 knop.textContent = doos.hidden
205 ? (T.help_archive || '{n} handled').replace('{n}', klaar.length)
206 : (T.help_archive_hide || 'hide');
207 });
208 list.appendChild(knop);
209 list.appendChild(doos);
210 }
211
212 var badge = document.getElementById('help-count');
213 badge.textContent = open.length; badge.hidden = open.length === 0;
214 // "Geen hulpverzoeken. Mooi zo." mag ook staan als er wel een archief is:
215 // er wacht dan immers niets.
216 show('help-empty', open.length === 0);
217 }
218
219 // ── 3. Offers I am a party to (sent, or a co-guardianship to co-approve) ─
220 function answer(offerId, decision, btn) {
221 if (btn) btn.disabled = true;
222 fetch('/guardian/offer', {
223 method: 'POST', headers: { 'Content-Type': 'application/json' },
224 body: JSON.stringify({ offer: offerId, answer: decision, site: S.site }),
225 }).then(refresh);
226 }
227 function offerCard(o) {
228 var card = el('div', 'g-card');
229 var row = el('div', 'row');
230 var subject = o['shaer:iAmCandidate']
231 ? handleOf(o['shaer:ward'], o['shaer:wardHandle']) // my sent offer: about the ward
232 : handleOf(o['shaer:candidate'], o['shaer:candidateHandle']); // co-guard: who wants in
233 row.appendChild(el('span', 'who grow', subject));
234 if (o['shaer:iAmCandidate']) {
235 // My own offer, waiting for the others to accept.
236 row.appendChild(el('span', 'tag wait', T.pending));
237 var rt = el('button', 'quiet small', T.retract);
238 rt.addEventListener('click', function () { answer(o.id, 'reject', rt); });
239 row.appendChild(rt);
240 } else if (o['shaer:needsMyAccept']) {
241 // A co-guardianship offer for a ward I already guard: my call.
242 row.appendChild(el('span', 'tag co', T.coguard));
243 var ac = el('button', 'small', T.accept);
244 ac.addEventListener('click', function () { answer(o.id, 'accept', ac); });
245 var rj = el('button', 'quiet small', T.reject);
246 rj.addEventListener('click', function () { answer(o.id, 'reject', rj); });
247 row.appendChild(ac); row.appendChild(rj);
248 } else {
249 row.appendChild(el('span', 'tag wait', T.awaiting_others));
250 }
251 card.appendChild(row);
252 return card;
253 }
254 /** A running lapse (FEP-633c 3.6.3): the available co-guardians deciding
255 * to release a dormant one. Votes ride the same Accept/Reject wire as the
256 * offers; buttons appear only for set members (the ward watches, it does
257 * not vote). */
258 function lapseCard(l) {
259 var card = el('div', 'g-card lapse');
260 card.appendChild(el('div', 'who', (T.lapse_line || '{who} has stopped answering as a guardian of {ward}.')
261 .replace('{who}', handleOf(l.object.object)).replace('{ward}', handleOf(l.object['shaer:ward']))));
262 card.appendChild(el('div', 'g-avlabel', (T.lapse_tally || '{n} of {need} agreed; closes {date}.')
263 .replace('{n}', l['shaer:accepts']).replace('{need}', l['shaer:threshold'])
264 .replace('{date}', new Date(l['shaer:closesAt']).toLocaleDateString())));
265 var row = el('div', 'row');
266 var inSet = (l['shaer:set'] || []).indexOf(S.me) >= 0;
267 if (inSet && !l['shaer:myVote']) {
268 var yes = el('button', 'small', T.lapse_agree || 'Agree');
269 yes.addEventListener('click', function () { answer(l.id, 'accept', yes); });
270 var no = el('button', 'quiet small', T.lapse_disagree || 'Disagree');
271 no.addEventListener('click', function () { answer(l.id, 'reject', no); });
272 row.appendChild(yes); row.appendChild(no);
273 } else if (inSet) {
274 row.appendChild(el('span', 'g-avlabel', T.voted || 'You voted'));
275 }
276 card.appendChild(row);
277 card.appendChild(el('p', 'g-empty small', T.lapse_note || ''));
278 return card;
279 }
280
281 /** A gated-setting proposal (FEP-633c 5.6) a fellow guardian opened on a
282 * ward we share, forwarded here by the ward's server. Answering is the
283 * whole point: without a second voice the threshold is never met and the
284 * proposal quietly expires. */
285 function gatedCard(g) {
286 var card = el('div', 'g-card gated');
287 var line = g.value ? (T.gated_line_on || '') : (T.gated_line_off || '');
288 card.appendChild(el('div', 'who', line
289 .replace('{who}', handleOf(g.proposer || ''))
290 .replace('{ward}', handleOf(g.ward))));
291 // Wat het betekent als dit doorgaat, VOOR de knoppen (shaer-nf9). Alleen bij
292 // openzetten -- dichtzetten laat niets nieuws door.
293 //
294 // En eerlijk over wat wij niet zien: de stemmen worden geteld op de server
295 // van het kind, niet hier. Er zijn geen lokale wards, dus gatedProgress komt
296 // voor deze kaart altijd op null uit. Een verzonnen "1 van 2" zou hier het
297 // gevaarlijkste getal op het scherm zijn -- het leest als "er kan nog iemand
298 // na mij". Wat we wel weten en wat de waarschuwing draagt: jouw ja kan de
299 // doorslag geven, want de telling settelt op het moment dat de drempel
300 // gehaald is.
301 if (g.consequence) {
302 var warn = el('div', 'g-warn');
303 warn.appendChild(el('p', 'small', warnText(g.consequence)));
304 // Wat we WEL weten sinds shaer-8vt: de server van het kind heeft
305 // meegestuurd of jouw antwoord het afmaakt. Zo niet -- een oudere server
306 // -- dan blijft de algemene regel staan, want bij twijfel waarschuwen.
307 warn.appendChild(el('p', g.decisive === false ? 'small g-dim' : 'small g-decisive',
308 g.decisive === false ? (T.warn_not_last || '') : (T.warn_decides || T.warn_tally_elsewhere || '')));
309 card.appendChild(warn);
310 }
311
312 var row = el('div', 'row');
313 var yes = el('button', 'small', T.gated_agree || 'Agree');
314 var no = el('button', 'quiet small', T.gated_disagree || 'Disagree');
315 function answerGated(decision, btn) {
316 btn.disabled = true;
317 fetch('/guardian/api/gated/' + encodeURIComponent(g.id), {
318 method: 'POST', headers: { 'Content-Type': 'application/json' },
319 body: JSON.stringify({ answer: decision, site: S.site }),
320 }).then(refresh).catch(function () { btn.disabled = false; });
321 }
322 yes.addEventListener('click', function () { answerGated('accept', yes); });
323 no.addEventListener('click', function () { answerGated('reject', no); });
324 row.appendChild(yes); row.appendChild(no);
325 card.appendChild(row);
326 return card;
327 }
328
329 function renderPending() {
330 var list = document.getElementById('pending-list');
331 list.textContent = '';
332 var offers = S.offers || [];
333 var gated = S.gatedReviews || [];
334 // The offers state carries the adoption offers; the lapse proposals ride
335 // separately so a lapse never renders as an adoption.
336 var lapses = (S.lapses || []).filter(function (l) { return l['shaer:outcome'] === 'open'; });
337 offers.forEach(function (o) {
338 if (o.object && o.object.type === 'shaer:Lapse') return; // rendered below
339 list.appendChild(offerCard(o));
340 });
341 lapses.forEach(function (l) { list.appendChild(lapseCard(l)); });
342 gated.forEach(function (g) { list.appendChild(gatedCard(g)); });
343 show('pending-section', offers.length > 0 || lapses.length > 0 || gated.length > 0);
344 }
345
346 // ── 4. Accepted wards: one panel per child ─────────────────────────────
347 // A guardian thinks per child, not per function, so everything about one
348 // child sits behind that child's row: the gated settings, the follow requests
349 // waiting on them, their calls for help, their recent posts. The row itself
350 // carries counts, so nothing that needs an answer hides inside a closed
351 // panel.
352 var openPanels = {}; // ward uri -> open, so a refresh does not close it
353 var openGates = {}; // idem voor de poortenlijst: uitklappen mag niet elke 45s dichtvallen
354 // The follow requests and the wards' posts arrive from their own endpoints
355 // and are grouped into the panels by ward, so they are cached here rather
356 // than rendered into a section of their own.
357 var FEED = [], FOLLOWS = [];
358
359 function sectionInto(panel, title, items, empty, build) {
360 var h = el('div', 'g-panel-sec');
361 h.appendChild(el('h3', null, title));
362 if (!items.length) h.appendChild(el('p', 'g-empty small', empty));
363 else items.forEach(function (it) { h.appendChild(build(it)); });
364 panel.appendChild(h);
365 return h;
366 }
367
368 function gateButton(w, feature, current, proposeLabel, onLabel, offLabel, consequence) {
369 var known = current === true || current === false;
370 var btn = el('button', 'quiet small', (known ? (current ? onLabel : offLabel) : proposeLabel) || feature);
371 var allow = known ? !current : true;
372
373 // ALLEEN BIJ OPENZETTEN waarschuwen (shaer-nf9). Dichtzetten laat niets
374 // nieuws door, en een waarschuwing die overal staat wordt nergens gelezen.
375 // Geen window.confirm: dat verstopt de uitleg achter een OK die mensen
376 // wegklikken. Zelfde vorm als het loslaten van een ward.
377 btn.addEventListener('click', function () {
378 if (!allow) return doPropose();
379 btn.hidden = true;
380 var ask = el('div', 'g-confirm');
381 ask.appendChild(el('p', 'small', warnText(consequence)));
382 var ja = el('button', 'small', T.warn_go || 'Yes, propose this');
383 ja.addEventListener('click', function () { ask.remove(); btn.hidden = false; doPropose(); });
384 var nee = el('button', 'quiet small', T.warn_back || 'No, go back');
385 nee.addEventListener('click', function () { ask.remove(); btn.hidden = false; });
386 ask.appendChild(ja); ask.appendChild(nee);
387 btn.parentNode.appendChild(ask);
388 });
389
390 function doPropose() {
391 btn.disabled = true;
392 fetch('/guardian/wards/embeds', {
393 method: 'POST', headers: { 'Content-Type': 'application/json' },
394 body: JSON.stringify({ uri: w.other_uri, feature: feature, allow: allow }),
395 }).then(function (r) { return r.json(); })
396 .then(function (j) {
397 if (j && j.state === 'open') {
398 btn.textContent = (T.embeds_waiting || 'waiting for the other guardians');
399 btn.disabled = true;
400 return;
401 }
402 refresh();
403 })
404 .catch(function () { btn.disabled = false; });
405 }
406 return btn;
407 }
408
409 /** De juiste waarschuwing bij een gevolg. Onbekend krijgt de zwaarste: een
410 * gate die wij niet kennen kunnen we ook niet geruststellend beschrijven. */
411 function warnText(consequence) {
412 if (consequence === 'irreversible') return T.warn_irreversible || '';
413 if (consequence === 'unknown') return T.warn_unknown || '';
414 return T.warn_reversible || '';
415 }
416
417 function embedsButton(w) {
418 // Gated feature: external (non-fediverse) embeds. Off by default for a
419 // ward; only a guardian can open it, and the gate is enforced server-side
420 // when the feed is built, so this button is the only thing that moves it.
421 // Shown for EVERY ward, including one on another server. There the value is
422 // unknown (it lives on the ward's server), but proposing is exactly as
423 // possible: the proposal travels, the ward's server tallies the guardians
424 // and enforces. A guardian next door must not have more say than one far
425 // away.
426 var known = w.embeds === true || w.embeds === false;
427 var emb = el('button', 'quiet small',
428 (known ? (w.embeds ? T.embeds_on : T.embeds_off) : T.embeds_propose) || 'Link previews');
429 emb.addEventListener('click', function () {
430 emb.disabled = true;
431 fetch('/guardian/wards/embeds', {
432 method: 'POST', headers: { 'Content-Type': 'application/json' },
433 body: JSON.stringify({ uri: w.other_uri, allow: known ? !w.embeds : true }),
434 }).then(function (r) { return r.json(); })
435 .then(function (j) {
436 // Not settled yet: the other guardians still have to answer.
437 if (j && j.state === 'open') {
438 emb.textContent = (T.embeds_waiting || 'waiting for the other guardians');
439 emb.disabled = true;
440 return;
441 }
442 refresh();
443 })
444 .catch(function () { emb.disabled = false; });
445 });
446 return emb;
447 }
448
449 /**
450 * The second step of releasing a ward: what it does, then yes or no.
451 *
452 * The warning is assembled from what the server found, not from a fixed
453 * sentence, because releasing means two different things (FEP-633c): stepping
454 * down while other guardians remain (§3.3), or being the last one, which is
455 * emancipation and explicitly not one guardian's call (§3.4). And as long as
456 * the Undo does not federate, the ward's server keeps listing you either way
457 * — a guardian has to know that before pressing, not after.
458 */
459 function releaseStep(w, check, host, relBtn) {
460 var uri = w.other_uri;
461 var who = handleOf(uri, w.other_handle);
462 var box = el('div', 'g-warn');
463 box.appendChild(el('strong', null, (T.release_title || 'Release {who}?').replace('{who}', who)));
464 box.appendChild(el('p', null, T.release_effect || ''));
465 if (check.last === true) box.appendChild(el('p', 'grave', T.release_last || ''));
466 else if (check.last === false) box.appendChild(el('p', null, T.release_step_down || ''));
467 else box.appendChild(el('p', 'grave', T.release_unknown || ''));
468 box.appendChild(el('p', null, T.release_local || ''));
469
470 var row = el('div', 'row');
471 var no = el('button', 'small', T.release_no || 'No');
472 no.addEventListener('click', function () {
473 host.removeChild(box);
474 relBtn.hidden = false; relBtn.disabled = false;
475 });
476 // No first: the way out should be the easy one to hit.
477 row.appendChild(no);
478 // Being the last guardian is not a warning but a wall: the server refuses
479 // it (§3.4), so offering a yes here would only produce an error. The text
480 // above already says what has to happen instead.
481 if (check.last !== true) {
482 var yes = el('button', 'danger small', T.release_yes || 'Yes');
483 yes.addEventListener('click', function () {
484 yes.disabled = true;
485 remove(uri, yes, function (err) {
486 // The guardian set can change between the check and the click.
487 yes.disabled = false;
488 box.appendChild(el('p', 'grave', err === 'would_emancipate' ? (T.release_last || '') : (T.failed || '')));
489 if (err === 'would_emancipate') yes.remove();
490 });
491 });
492 row.appendChild(yes);
493 }
494 box.appendChild(row);
495 return box;
496 }
497
498 /** The availability dot (FEP-633c 3.6): buddy-list language on the
499 * responsibility axis. Green available, yellow declared away with an end,
500 * grey observed dormant (one answer restores). */
501 function availLabel(g) {
502 if (g.availability === 'away') {
503 var date = g.awayUntil ? new Date(g.awayUntil).toLocaleDateString() : '?';
504 return (T.avail_away || 'Unavailable till {date}').replace('{date}', date);
505 }
506 if (g.availability === 'dormant') return T.avail_dormant || 'Offline';
507 return T.avail_available || 'Available';
508 }
509 function availRow(g, wardUri) {
510 var row = el('div', 'row g-guard');
511 var dot = el('span', 'g-avdot ' + (g.availability === 'away' ? 'is-away' : g.availability === 'dormant' ? 'is-dormant' : 'is-active'));
512 row.appendChild(dot);
513 row.appendChild(el('span', 'who grow', handleOf(g.uri, g.handle)));
514 row.appendChild(el('span', 'g-avlabel', availLabel(g)));
515 // A dormant fellow guardian without a running lapse: the deliberate,
516 // rare next step (3.6.3). Never shown for anyone still answering.
517 if (g.availability === 'dormant' && !g.lapse && g.uri !== S.me) {
518 var btn = el('button', 'quiet small', T.lapse_propose || 'Propose release');
519 btn.addEventListener('click', function () {
520 btn.disabled = true;
521 fetch('/guardian/api/lapse', {
522 method: 'POST', headers: { 'Content-Type': 'application/json' },
523 body: JSON.stringify({ ward: wardUri, target: g.uri, site: S.site }),
524 }).then(refresh).catch(function () { btn.disabled = false; });
525 });
526 row.appendChild(btn);
527 }
528 return row;
529 }
530
531 /** Het woord dat we voor een gate gebruiken; onbekend valt terug op de naam. */
532 function gateLabel(feature) {
533 return T['gate_' + feature.replace('shaer:', '')] || feature.replace('shaer:', '');
534 }
535
536 /**
537 * Een rij in het gate-paneel. Toont WAT er geldt, van welk SOORT het is, welke
538 * drempel er hoort en wat er loopt -- en pas daarna een knop, als er iets te
539 * verzetten valt.
540 *
541 * Het soort staat erbij omdat de gates niet hetzelfde werken: een stand is aan
542 * of uit, volgverzoeken zijn een stroom beslissingen, en een overdracht is
543 * onomkeerbaar zodra het kind hem gebruikt. Vier rijen met dezelfde schakelaar
544 * zouden dat verschil wegpoetsen.
545 */
546 /**
547 * Een gate als cockpit-schakelaar (Barts richting, 8-8): een knop zoals de
548 * airco-toets in een dashboard -- lampje links, de naam erop, en een klein
549 * statusveld dat de stand toont. De KNOP is de toggle: aanraken stelt de
550 * andere stand voor, met de nf9-uitleg ervoor. Het lampje en het veld dragen
551 * dezelfde stand in twee vormen, want een lampje alleen is voor kleurenblind
552 * en onwennig volk geen antwoord.
553 *
554 * Drie standen, niet twee. "Uit" en "voor zover wij weten uit" zijn niet
555 * hetzelfde: dat tweede betekent dat niemand er ooit over besloot, en dat
556 * hoort niet te lezen als een genomen besluit. Wat er nog niet bestaat is
557 * een lege fitting, geen dichte deur. En een lopend voorstel knippert --
558 * wachten is de enige stand die vanzelf verandert, dus de enige die om je
559 * blik mag vragen (en bij prefers-reduced-motion staat hij stil).
560 */
561 function gateRow(w, g) {
562 var stand = g.value === null || g.value === undefined
563 ? (T.gate_unknown || 'unknown')
564 : (g.value ? (T.prop_on || 'on') : (T.prop_off || 'off'));
565 if (!g.decided && g.value === false) stand = T.gate_default_off || stand;
566 if (!g.available) stand = T.gate_unavailable || 'not available yet';
567
568 var lamp = !g.available ? 'planned'
569 : (g.waiting || (g.proposal && g.proposal.status === 'open')) ? 'waiting'
570 : (g.value === true ? 'on' : (g.decided ? 'off' : 'undecided'));
571
572 var wrap = el('div', 'g-sw-wrap');
573 var btn = el('button', 'g-sw');
574 btn.type = 'button';
575 if (g.value === true || g.value === false) btn.setAttribute('aria-pressed', String(g.value));
576 var head = el('span', 'g-sw-head');
577 var lampEl = el('span', 'g-sw-lamp g-lamp-' + lamp);
578 lampEl.setAttribute('aria-hidden', 'true');
579 head.appendChild(lampEl);
580 head.appendChild(el('span', 'g-sw-name', gateLabel(g.feature)));
581 btn.appendChild(head);
582 // Het statusveld: de stand als tekst, in een verzonken venstertje zoals de
583 // temperatuur op het aircopaneel. Bij een lopend voorstel telt het veld wat
584 // er wacht -- dat is op dat moment de waarheid over deze poort.
585 var veld = (g.waiting ? (T.gate_waiting || '{n} waiting').replace('{n}', g.waiting) : stand);
586 btn.appendChild(el('span', 'g-sw-state', veld));
587
588 var meta = el('span', 'g-sw-meta small');
589 meta.appendChild(el('span', 'g-gate-kind', T['gate_kind_' + g.kind] || g.kind));
590 if (!g.available) meta.appendChild(el('span', 'g-dim', T.gate_planned_note || ''));
591 if (g.threshold && g.available) {
592 meta.appendChild(el('span', null, (T.gate_threshold || '{need} of {of} guardians')
593 .replace('{need}', g.threshold.need).replace('{of}', g.threshold.of)));
594 } else if (g.available) {
595 // Geen drempel verzinnen die we niet kennen.
596 meta.appendChild(el('span', 'g-dim', T.gate_threshold_unknown || ''));
597 }
598 if (!g.reversible) meta.appendChild(el('span', 'g-gate-warn', T.gate_irreversible || ''));
599 btn.appendChild(meta);
600 wrap.appendChild(btn);
601
602 // Het kind vroeg hier ZELF om (shaer-8ru). Eigen regel onder de schakelaar,
603 // niet in het statusveld: dit is het enige op deze rij dat van het kind komt
604 // en het hoort niet weg te vallen tussen de drempels en de soorten. Ook
605 // bewust niet als "n waiting" -- drie onbekenden die je kind willen volgen is
606 // iets heel anders dan je kind dat een keer vraagt of muziek aan mag.
607 if (g.requested) wrap.appendChild(el('p', 'small g-gate-asked', T.gate_asked || ''));
608
609 if (g.proposal) {
610 wrap.appendChild(el('p', 'small g-prop g-prop-' + g.proposal.status,
611 (T.prop_line || 'Proposal {what} {value}: {status}')
612 .replace('{what}', gateLabel(g.feature))
613 .replace('{value}', g.proposal.value ? (T.prop_on || 'on') : (T.prop_off || 'off'))
614 .replace('{status}', T['prop_st_' + g.proposal.status] || g.proposal.status)));
615 }
616 if (g.blockedBy) {
617 wrap.appendChild(el('p', 'small g-empty', (T.gate_blocked || 'needs {what} first')
618 .replace('{what}', gateLabel(g.blockedBy))));
619 }
620
621 if (!g.adjustable) {
622 // Niet verstelbaar is niet hetzelfde als kapot: het lampje en het veld
623 // blijven de stand tonen, alleen de toets drukt niet in.
624 btn.disabled = true;
625 return wrap;
626 }
627
628 // De toets stelt de ANDERE stand voor; de richting volgt de stand die we
629 // uit de besluiten kennen, dus ook voor een ward elders kun je dichtzetten
630 // voorstellen. Bij openzetten eerst de nf9-uitleg (uitleg staat er, dan pas
631 // de knop); dichtzetten laat niets nieuws door en vraagt niet.
632 var allow = (g.value === true || g.value === false) ? !g.value : true;
633 var consequence = g.reversible === false ? 'irreversible' : 'reversible';
634 btn.addEventListener('click', function () {
635 if (!allow) return doPropose();
636 if (wrap.querySelector('.g-confirm')) return; // uitleg staat er al
637 var ask = el('div', 'g-confirm');
638 ask.appendChild(el('p', 'small', warnText(consequence)));
639 var ja = el('button', 'small', T.warn_go || 'Yes, propose this');
640 ja.addEventListener('click', function () { ask.remove(); doPropose(); });
641 var nee = el('button', 'quiet small', T.warn_back || 'No, go back');
642 nee.addEventListener('click', function () { ask.remove(); });
643 ask.appendChild(ja); ask.appendChild(nee);
644 wrap.appendChild(ask);
645 });
646
647 function doPropose() {
648 btn.disabled = true;
649 fetch('/guardian/wards/embeds', {
650 method: 'POST', headers: { 'Content-Type': 'application/json' },
651 body: JSON.stringify({ uri: w.other_uri, feature: g.feature, allow: allow }),
652 }).then(function (r) { return r.json(); })
653 .then(function (j) {
654 if (j && j.state === 'open') {
655 wrap.appendChild(el('p', 'small g-prop g-prop-open',
656 (T.prop_line || 'Proposal {what} {value}: {status}')
657 .replace('{what}', gateLabel(g.feature))
658 .replace('{value}', allow ? (T.prop_on || 'on') : (T.prop_off || 'off'))
659 .replace('{status}', T.prop_st_open || 'open')));
660 }
661 refresh();
662 })
663 .catch(function () { btn.disabled = false; });
664 }
665 return wrap;
666 }
667
668 function wardPanel(w) {
669 var uri = w.other_uri;
670 var panel = el('div', 'g-panel');
671 panel.hidden = !openPanels[uri];
672
673 var set = el('div', 'g-panel-sec');
674 set.appendChild(el('h3', null, T.settings_title || 'Settings'));
675 // Een rij per gate, uit de catalogus van de server (shaer-ahy.1). Losse
676 // knoppen lieten een guardian zelf uitzoeken wat er allemaal geldt, en wat
677 // niet verstelbaar is stond nergens -- terwijl dat de helft van het antwoord
678 // is op "wat mag dit kind".
679 //
680 // INGEKLAPT met een samenvatting erboven. Met twaalf poorten duwt een open
681 // lijst alles wat eronder staat -- guardians, volgverzoeken, hulpvragen --
682 // van het scherm. Wat je meestal wilt weten is "staat er iets open en wacht
683 // er iets op mij", en dat past op een regel.
684 if ((w.gates || []).length) {
685 var lijst = el('div', 'g-gates');
686 lijst.hidden = !openGates[uri];
687 w.gates.forEach(function (g) { lijst.appendChild(gateRow(w, g)); });
688
689 var aan = w.gates.filter(function (g) { return g.available && g.value === true; }).length;
690 var wacht = w.gates.reduce(function (n, g) { return n + (g.waiting || 0) + (g.proposal && g.proposal.status === 'open' ? 1 : 0); }, 0);
691 var kop = el('button', 'quiet small g-gates-toggle');
692 var zetKop = function () {
693 kop.textContent = (lijst.hidden ? (T.gates_show || 'Show gates') : (T.gates_hide || 'Hide gates'))
694 + ' - ' + (T.gates_summary || '{n} gates - {on} on, {wait} waiting')
695 .replace('{n}', w.gates.length).replace('{on}', aan).replace('{wait}', wacht);
696 };
697 zetKop();
698 kop.addEventListener('click', function () {
699 lijst.hidden = !lijst.hidden;
700 openGates[uri] = !lijst.hidden; // blijft open over een verversing heen
701 zetKop();
702 });
703 set.appendChild(kop);
704 set.appendChild(lijst);
705 }
706 // Terugval voor een server die de catalogus nog niet stuurt: dan de twee
707 // knoppen zoals ze waren, zodat een oudere Klonkt niet met een leeg vak zit.
708 if (!(w.gates || []).length) {
709 var setRow = el('div', 'row');
710 setRow.appendChild(gateButton(w, 'shaer:externalEmbeds', w.embeds, T.embeds_propose, T.embeds_on, T.embeds_off));
711 if (w.embeds !== false) {
712 setRow.appendChild(gateButton(w, 'shaer:externalPlayback', w.playback, T.play_propose, T.play_on, T.play_off));
713 }
714 set.appendChild(setRow);
715 (w.proposals || []).forEach(function (p) {
716 var what = p.feature === 'shaer:externalPlayback' ? (T.prop_play || 'playback') : (T.prop_embeds || 'link previews');
717 set.appendChild(el('p', 'small g-prop g-prop-' + p.status, (T.prop_line || 'Proposal {what} {value}: {status}')
718 .replace('{what}', what).replace('{value}', p.value ? (T.prop_on || 'on') : (T.prop_off || 'off'))
719 .replace('{status}', T['prop_st_' + p.status] || p.status)));
720 });
721 }
722 panel.appendChild(set);
723
724 // The fellow guardians of this child, with availability (3.6). For a
725 // ward on another server the states live there, and saying so honestly
726 // beats guessing.
727 var gsec = el('div', 'g-panel-sec');
728 gsec.appendChild(el('h3', null, T.panel_guards || 'Guardians of this child'));
729 if (w.guardians && w.guardians.length) {
730 w.guardians.forEach(function (g) { gsec.appendChild(availRow(g, uri)); });
731 } else {
732 // A ward on another server: WHO guards it is public on its actor
733 // (shaer:guardians, 2.1), so list the seats; availability is the ward
734 // server's private ledger (3.6.1) and is not shown, only named.
735 var placeholder = el('p', 'g-empty small', '…');
736 gsec.appendChild(placeholder);
737 fetch('/guardian/wards/guardians?site=' + encodeURIComponent(S.site) + '&uri=' + encodeURIComponent(uri))
738 .then(function (r) { return r.json(); })
739 .then(function (j) {
740 if (!j || !j.guardians || !j.guardians.length) {
741 placeholder.textContent = T.panel_guards_remote || '';
742 return;
743 }
744 placeholder.remove();
745 j.guardians.forEach(function (g) {
746 var row = el('div', 'row g-guard');
747 row.appendChild(el('span', 'who grow', handleOf(g.uri, g.handle)));
748 gsec.appendChild(row);
749 });
750 gsec.appendChild(el('p', 'g-empty small', T.panel_guards_far || ''));
751 })
752 .catch(function () { placeholder.textContent = T.panel_guards_remote || ''; });
753 }
754 panel.appendChild(gsec);
755
756 sectionInto(panel, T.panel_follow || 'Follow requests',
757 FOLLOWS.filter(function (f) { return f.wardUri === uri; }),
758 T.panel_follow_empty || '', followCard);
759
760 // Alleen wat nog WACHT. De afgehandelde vragen staan onderaan het paneel als
761 // eigen sectie: dooreen gezet lees je niet meer of er iets van je gevraagd
762 // wordt, en dat is de enige vraag die deze sectie hoort te beantwoorden.
763 sectionInto(panel, T.panel_help || 'Calls for help',
764 (S.help || []).filter(function (h) { return h.actor_uri === uri && helpOpen(h); }),
765 T.panel_help_empty || '', helpCard);
766
767 sectionInto(panel, T.panel_posts || 'Recent posts',
768 FEED.filter(function (p) { return p.authorUri === uri; }),
769 T.panel_posts_empty || '', feedCard);
770
771 // De geschiedenis: apart, en helemaal onderaan. Wat af is hoort niet tussen
772 // wat nog wacht -- daar wordt "moet ik iets doen" onleesbaar van. Weg gaat
773 // hij niet: er wordt in dit systeem niets herschreven, er wordt toegevoegd.
774 // Geen lege sectie als er nog niets is afgehandeld; dat is geen informatie.
775 var gedaan = (S.help || []).filter(function (h) { return h.actor_uri === uri && !helpOpen(h); });
776 if (gedaan.length) {
777 sectionInto(panel, (T.panel_history || 'Handled ({n})').replace('{n}', gedaan.length),
778 gedaan, '', helpCard).classList.add('g-panel-history');
779 }
780
781 var act = el('div', 'g-panel-sec');
782 act.appendChild(el('h3', null, T.panel_actions || 'Actions'));
783 var actRow = el('div', 'row');
784 var wave = el('button', 'small', T.wave || '👋 Wave');
785 wave.addEventListener('click', function () { sendWave(uri, wave); });
786 actRow.appendChild(wave);
787 var rel = el('button', 'quiet small', T.release);
788 // Letting a child go is a decision, not a click. It opens a step that first
789 // asks the server what releasing this particular ward actually does, then
790 // says it plainly and asks yes or no. Never window.confirm: that hides a
791 // long explanation behind an OK button people press to make it go away.
792 rel.addEventListener('click', function () {
793 rel.disabled = true;
794 // site matters: with several of your own sites the server would otherwise
795 // check this ward against the wrong one and answer "not my ward".
796 fetch('/guardian/wards/release-check?site=' + encodeURIComponent(S.site) + '&uri=' + encodeURIComponent(uri))
797 .then(function (r) { return r.json(); })
798 .then(function (c) {
799 rel.hidden = true;
800 act.appendChild(releaseStep(w, c || {}, act, rel));
801 })
802 .catch(function () { rel.disabled = false; });
803 });
804 actRow.appendChild(rel);
805 act.appendChild(actRow);
806 // Bovenaan, niet onderaan: zwaaien en loslaten zijn de dingen die je DOET.
807 // De rest van het paneel is lezen -- instellingen, wie er nog meer op let,
808 // wat er binnenkwam. Wie het paneel opent om iets te doen hoorde eerst langs
809 // vijf secties te scrollen.
810 panel.insertBefore(act, panel.firstChild);
811 return panel;
812 }
813
814 function renderWards() {
815 var list = document.getElementById('wards-list');
816 list.textContent = '';
817 var wards = S.wards || [];
818 wards.forEach(function (w) {
819 var uri = w.other_uri;
820 var card = el('div', 'g-card ward');
821 var row = el('div', 'row');
822 row.appendChild(el('span', 'who grow', handleOf(uri, w.other_handle)));
823 // Counts on the row: whatever is waiting must be visible with the panel shut.
824 // Alleen wat nog WACHT, zoals het commentaar hierboven al zei: een
825 // afgehandelde vraag hoorde de boei niet te laten staan.
826 var nHelp = (S.help || []).filter(function (h) { return h.actor_uri === uri && helpOpen(h); }).length;
827 var nFollow = FOLLOWS.filter(function (f) { return f.wardUri === uri; }).length;
828 if (nHelp) row.appendChild(el('span', 'tag help', '🛟 ' + nHelp));
829 if (nFollow) row.appendChild(el('span', 'tag co', nFollow + ' ' + (nFollow === 1 ? (T.badge_follow_one || '') : (T.badge_follow || ''))));
830 row.appendChild(el('span', 'tag ok', T.active));
831 var toggle = el('button', 'quiet small', openPanels[uri] ? T.panel_close : T.panel_open);
832 row.appendChild(toggle);
833 card.appendChild(row);
834 var panel = wardPanel(w);
835 card.appendChild(panel);
836 toggle.addEventListener('click', function () {
837 openPanels[uri] = !openPanels[uri];
838 panel.hidden = !openPanels[uri];
839 toggle.textContent = openPanels[uri] ? T.panel_close : T.panel_open;
840 });
841 list.appendChild(card);
842 });
843 show('wards-empty', wards.length === 0);
844 // Step away (3.6.1) only means something with wards to tell.
845 show('away-section', wards.length > 0);
846 }
847
848 // ── 4b. Step away (FEP-633c 3.6.1) ─────────────────────────────────────
849 function declareAway(days, btn) {
850 btn.disabled = true;
851 fetch('/guardian/api/away', {
852 method: 'POST', headers: { 'Content-Type': 'application/json' },
853 body: JSON.stringify({ days: days, site: S.site }),
854 }).then(function (r) { return r.json(); })
855 .then(function (j) {
856 btn.disabled = false;
857 var msg = document.getElementById('away-msg');
858 msg.hidden = false;
859 if (j && j.ok) {
860 msg.className = 'g-msg';
861 msg.textContent = (T.away_done || 'Your wards know you are unavailable until {date}.')
862 .replace('{date}', new Date(j.until).toLocaleDateString());
863 } else {
864 msg.className = 'g-msg err';
865 msg.textContent = (j && j.error) || (T.failed || 'failed');
866 }
867 })
868 .catch(function () { btn.disabled = false; });
869 }
870 var awayWeek = document.getElementById('away-week');
871 var awayMonth = document.getElementById('away-month');
872 if (awayWeek) awayWeek.addEventListener('click', function () { declareAway(7, awayWeek); });
873 if (awayMonth) awayMonth.addEventListener('click', function () { declareAway(30, awayMonth); });
874
875 function sendWave(uri, btn) {
876 btn.disabled = true;
877 fetch('/guardian/api/wave', {
878 method: 'POST', headers: { 'Content-Type': 'application/json' },
879 body: JSON.stringify({ ward: uri, site: S.site }),
880 }).then(function (r) { return r.json(); })
881 .then(function (j) { btn.disabled = false; btn.textContent = (j && j.ok) ? (T.waved || '👋 sent') : (T.wave || '👋 Wave'); })
882 .catch(function () { btn.disabled = false; });
883 }
884
885 function remove(uri, btn, onError) {
886 btn.disabled = true;
887 fetch('/guardian/wards/remove', {
888 method: 'POST', headers: { 'Content-Type': 'application/json' },
889 body: JSON.stringify({ uri: uri, site: S.site }),
890 }).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
891 .then(function (res) {
892 // The server can refuse: emptying shaer:guardians is emancipation and
893 // not one guardian's call (§3.4). Say so instead of silently redrawing.
894 if (!res.ok) { if (onError) onError(res.j && res.j.error); return; }
895 refresh();
896 })
897 .catch(function () { if (onError) onError('network'); else btn.disabled = false; });
898 }
899
900
901 // De scrollpositie overleeft een verversing (Barts melding, 9-8). renderAll
902 // bouwt lijsten opnieuw op; staat de pagina daarbij even korter, dan springt
903 // de browser omhoog en ben je kwijt waar je was. Meten en terugzetten is
904 // genoeg -- en het is het verschil tussen een scherm dat bijwerkt en een
905 // scherm dat onder je handen wegschuift.
906 function metBehoudVanScroll(fn) {
907 var y = window.scrollY || document.documentElement.scrollTop || 0;
908 fn();
909 if (y) window.scrollTo(0, y);
910 }
911
912 function renderAll() { renderHelp(); renderPending(); renderWards(); }
913
914 // ── 0. Wards' corner: read-only feed of your wards' posts ───────────────
915 // Lives inside each child's panel now, so the fetches only fill a cache and
916 // ask the ward list to redraw. A guardian watches, it does not publish.
917 function feedCard(p) {
918 var card = el('div', 'g-card feed');
919 var head = el('div', 'row');
920 head.appendChild(el('span', 'who grow', p.author));
921 if (p.published) head.appendChild(el('span', 'g-when', when(p, p.published)));
922 card.appendChild(head);
923 var body = el('div', 'feed-body');
924 // body_html is de gedeelde note-body-partial, serverside gerenderd: opmaak,
925 // media, quote-kaart en embed, precies als in de Krant en in Berichten.
926 // Valt terug op de kale content voor een client uit de cache.
927 var html = p.body_html || p.content || '';
928 if (p.cw) {
929 // De content warning blijft van de PWA zelf: note-body versluiert alleen
930 // bij nsfw, en een ward-post met alleen een cw hoort hier dicht te staan.
931 var d = document.createElement('details');
932 var sum = document.createElement('summary'); sum.textContent = p.cw; d.appendChild(sum);
933 var inner = el('div'); inner.innerHTML = html; d.appendChild(inner);
934 body.appendChild(d);
935 } else {
936 body.innerHTML = html; // server-sanitized HTML (same as Berichten)
937 }
938 card.appendChild(body);
939 return card;
940 }
941
942 function loadFeed() {
943 return fetch('/guardian/api/feed?site=' + encodeURIComponent(S.site))
944 .then(function (r) { return r.json(); })
945 .then(function (f) { if (f && !f.error) { FEED = f.items || []; renderWards(); } })
946 .catch(function () { /* panels just show "nothing yet" */ });
947 }
948
949 // ── 0b. Follow requests on your wards (§5.3) ────────────────────────────
950 function answerFollow(id, decision, btn) {
951 if (btn) btn.disabled = true;
952 fetch('/guardian/api/follow/' + encodeURIComponent(id), {
953 method: 'POST', headers: { 'Content-Type': 'application/json' },
954 body: JSON.stringify({ decision: decision, site: S.site }),
955 }).then(loadFollowReqs);
956 }
957 function followCard(f) {
958 var card = el('div', 'g-card');
959 var row = el('div', 'row');
960 // Inside the child's own panel the ward name is a given, so only the
961 // person asking is named here.
962 row.appendChild(el('span', 'who grow', f.follower));
963 var ok = el('button', 'small', T.accept || 'Accept');
964 ok.addEventListener('click', function () { answerFollow(f.id, 'approve', ok); });
965 var no = el('button', 'quiet small', T.reject || 'Deny');
966 no.addEventListener('click', function () { answerFollow(f.id, 'reject', no); });
967 row.appendChild(ok); row.appendChild(no);
968 card.appendChild(row);
969 return card;
970 }
971 function loadFollowReqs() {
972 return fetch('/guardian/api/follow-requests?site=' + encodeURIComponent(S.site))
973 .then(function (r) { return r.json(); })
974 .then(function (f) { if (f && !f.error) { FOLLOWS = f.items || []; renderWards(); } })
975 .catch(function () { /* panels just show "none waiting" */ });
976 }
977
978 function refresh() {
979 return fetch('/guardian/api/state?site=' + encodeURIComponent(S.site))
980 .then(function (r) { return r.json(); })
981 .then(function (s) { if (s && !s.error) { S = s; T = s.strings || T; metBehoudVanScroll(renderAll); } })
982 .then(loadFeed).then(loadFollowReqs);
983 }
984
985 // ── De lange poll (Barts opdracht, 9-8) ──────────────────────────────
986 //
987 // Het paneel tikte elke 45 seconden, ongeacht of er iets gebeurd was. Nu hangt
988 // er een verzoek open tot er iets is dat je moet verwerken -- een aanbod, een
989 // volgverzoek, een gate-voorstel, een hulpvraag. Dat is niet alleen zuiniger,
990 // het is ook SNELLER: een hulproep stond eerst tot drie kwartier van een
991 // minuut op het scherm te wachten.
992 //
993 // 304 = stilte, en dan meteen opnieuw wachten. Alleen bij 200 wordt er iets
994 // opnieuw getekend.
995 var pollBezig = false;
996 var laatsteState = null; // de vorige body, om onnodig hertekenen te herkennen
997
998 function longPoll() {
999 if (pollBezig || document.hidden) return;
1000 pollBezig = true;
1001 var t0 = Date.now();
1002 fetch('/guardian/api/state?wait=25&site=' + encodeURIComponent(S.site))
1003 .then(function (r) {
1004 pollBezig = false;
1005 if (r.status === 304) return null; // stilte
1006 return r.text().then(function (tekst) {
1007 // ZELFDE INHOUD, NIET HERTEKENEN. De 304 zou dit al moeten afvangen,
1008 // maar een enkel veld dat per verzoek verandert (er stond een levende
1009 // klok in) maakt elke ETag anders, en dan tekent het paneel zichzelf
1010 // stuk terwijl je scrollt. Dit is de tweede grendel, aan de kant die
1011 // de gebruiker merkt.
1012 if (tekst === laatsteState) return null;
1013 laatsteState = tekst;
1014 var s; try { s = JSON.parse(tekst); } catch (e) { return null; }
1015 if (s && !s.error) { S = s; T = s.strings || T; metBehoudVanScroll(renderAll); }
1016 return loadFeed().then(loadFollowReqs);
1017 });
1018 })
1019 .then(function () {
1020 // EEN LANGE POLL DIE METEEN TERUGKEERT MAG GEEN LUS WORDEN. Wachtte de
1021 // server niet (een fout, een tussenliggende proxy, iets wat we nog niet
1022 // weten), dan houden we zelf de rem erop. Anders vervangt een defect de
1023 // ene tik van 45 seconden door duizend per minuut.
1024 var snel = Date.now() - t0 < 2000;
1025 setTimeout(longPoll, snel ? 3000 : 200);
1026 })
1027 .catch(function () {
1028 // Netwerk weg of server herstart: niet meteen opnieuw beuken.
1029 pollBezig = false;
1030 setTimeout(longPoll, 5000);
1031 });
1032 }
1033
1034 // ── 2. Adopt ───────────────────────────────────────────────────────────
1035 var form = document.getElementById('adopt-form');
1036 var input = document.getElementById('adopt-handle');
1037 var adoptBtn = document.getElementById('adopt-btn');
1038 var msg = document.getElementById('adopt-msg');
1039 function setMsg(text, isErr) { msg.hidden = false; msg.className = 'g-msg' + (isErr ? ' err' : ''); msg.textContent = text; }
1040
1041 form.addEventListener('submit', function (ev) {
1042 ev.preventDefault();
1043 var handle = input.value.trim();
1044 if (!handle) return;
1045 adoptBtn.disabled = true;
1046 setMsg(T.sending || '…', false);
1047 fetch('/guardian/adopt', {
1048 method: 'POST', headers: { 'Content-Type': 'application/json' },
1049 body: JSON.stringify({ handle: handle, site: S.site }),
1050 }).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
1051 .then(function (res) {
1052 adoptBtn.disabled = false;
1053 if (res.ok) {
1054 input.value = '';
1055 // Always refresh: the offer is recorded even if delivery is still
1056 // in flight. Show it under "Verzonden aanvragen".
1057 setMsg(res.j.delivered === false ? T.sent_retry : T.sent, false);
1058 refresh();
1059 } else {
1060 setMsg((res.j.error === 'not_found' ? T.not_found : T.failed) , true);
1061 }
1062 })
1063 .catch(function () { adoptBtn.disabled = false; setMsg(T.network, true); });
1064 });
1065
1066 // ── Site picker ────────────────────────────────────────────────────────
1067 var picker = document.getElementById('site-picker');
1068 if (picker) picker.addEventListener('change', function () {
1069 location.href = '/guardian?site=' + encodeURIComponent(picker.value);
1070 });
1071
1072 // ── 5. Push ────────────────────────────────────────────────────────────
1073 var toggle = document.getElementById('push-toggle');
1074 var pmsg = document.getElementById('push-msg');
1075 function pushState() {
1076 if (!('serviceWorker' in navigator) || !('PushManager' in window)) { toggle.disabled = true; return; }
1077 navigator.serviceWorker.register('/sw.js').catch(function () {});
1078 navigator.serviceWorker.ready
1079 .then(function (reg) { return reg.pushManager.getSubscription(); })
1080 .then(function (sub) {
1081 toggle.textContent = sub ? toggle.dataset.onLabel : toggle.dataset.offLabel;
1082 toggle.dataset.subscribed = sub ? '1' : '';
1083 toggle.classList.toggle('is-on', !!sub);
1084 });
1085 }
1086 function urlB64(base64) {
1087 var pad = '='.repeat((4 - (base64.length % 4)) % 4);
1088 var b = (base64 + pad).replace(/-/g, '+').replace(/_/g, '/');
1089 var raw = atob(b); var arr = new Uint8Array(raw.length);
1090 for (var i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
1091 return arr;
1092 }
1093 toggle.addEventListener('click', function () {
1094 pmsg.hidden = true;
1095 navigator.serviceWorker.ready.then(function (reg) {
1096 if (toggle.dataset.subscribed) {
1097 reg.pushManager.getSubscription().then(function (sub) {
1098 if (!sub) return;
1099 fetch('/push/unsubscribe', {
1100 method: 'POST', headers: { 'Content-Type': 'application/json' },
1101 body: JSON.stringify({ endpoint: sub.endpoint }),
1102 }).then(function () { return sub.unsubscribe(); }).then(pushState);
1103 });
1104 return;
1105 }
1106 fetch('/push/vapid').then(function (r) { return r.json(); }).then(function (v) {
1107 if (!v.publicKey) throw new Error('no key');
1108 return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlB64(v.publicKey) });
1109 }).then(function (sub) {
1110 return fetch('/push/subscribe', {
1111 method: 'POST', headers: { 'Content-Type': 'application/json' },
1112 body: JSON.stringify({
1113 subscription: sub.toJSON(),
1114 alerts: { help: 1, guardian: 1, dm: 1, follow: 0, reply: 0, like: 0, boost: 0 },
1115 uaLabel: 'Guardian PWA',
1116 }),
1117 });
1118 }).then(pushState).catch(function (e) {
1119 pmsg.hidden = false; pmsg.className = 'g-msg err';
1120 pmsg.textContent = (T.push_unavailable || 'Push unavailable') + ': ' + e.message;
1121 });
1122 });
1123 });
1124
1125 renderAll(); pushState(); loadFeed(); loadFollowReqs();
1126
1127 // De push die de melding brengt is meteen het teken dat de staat veranderd is.
1128 // Daarmee hoeft er geen tweede, open verbinding bij: hetzelfde kanaal doet het
1129 // werk, en het werkt ook als de app dicht is.
1130 if ('serviceWorker' in navigator) {
1131 navigator.serviceWorker.addEventListener('message', function (e) {
1132 if (e.data && e.data.klonkt === 'push') refresh();
1133 });
1134 }
1135 // Het tikje blijft als vangnet -- niet elke verandering geeft een melding, en
1136 // niet iedereen heeft meldingen aanstaan. Maar niet tikken terwijl niemand
1137 // kijkt: dat waren verzoeken voor een tabblad op de achtergrond. Bij terugkomen
1138 // meteen een keer, want dan is de kans op nieuws het grootst.
1139 // De lange poll doet het werk. Het tikje blijft als vangnet, maar veel trager:
1140 // valt er een wekker weg (een pad dat niet wekt, een herstart midden in een
1141 // verzoek), dan mag het scherm niet voorgoed stilstaan. Vijf minuten in plaats
1142 // van vijfenveertig seconden, en met de ETag kost zo'n tik meestal een lege 304.
1143 longPoll();
1144 setInterval(function () { if (!document.hidden) refresh(); }, 300000);
1145 document.addEventListener('visibilitychange', function () {
1146 if (document.hidden) return;
1147 refresh();
1148 longPoll(); // bij terugkomst weer een wachter openzetten
1149 });
1150 } catch (e) {
1151 fatal((e && e.message) || String(e));
1152 }
1153})();
Note: See TracBrowser for help on using the repository browser.