source: Klonkt/src/assets/js/guardian.js@ 6fab3f3

main
Last change on this file since 6fab3f3 was 6fab3f3, checked in by roboburr <roboburr@…>, 4 weeks ago

Een lange poll voor alles wat de Guardian te verwerken krijgt (Barts opdracht, 9-8)

Het paneel tikte elke 45 seconden, ongeacht of er iets gebeurd was. Nu hangt er
een verzoek open tot er iets IS. Dat is niet alleen zuiniger -- het is vooral
sneller: een hulproep stond eerst tot drie kwart minuut te wachten op de klok.

WAKKER OP ALLES, en dat is de kern van de opdracht. De guardianship-module zendt
veertien soorten gebeurtenissen uit; die gingen tot nu toe alleen naar push, en
push kiest bewust een handvol. De tien soorten zonder pushtekst -- een stem op
een lapse, een uitkomst, een review -- zag je pas bij de volgende tik. Wie maak
je WAKKER en wat moet een openstaand scherm WETEN zijn twee verschillende
vragen, en ze hadden hetzelfde antwoord.

Naast die bus wekt ook de tijdlijn (onNews): de berichten van je wards staan in
ditzelfde scherm. En de twee paden die niet via notify() lopen -- een
hulpmarkering van een mede-guardian, en een poortverzoek van een kind -- wekken
nu zelf.

EERST KIJKEN, DAN WACHTEN. Veranderde er iets tussen het vorige antwoord en dit
verzoek, dan is de merksteen al anders en gaat het antwoord meteen de deur uit.
Zou je eerst gaan wachten, dan blijft nieuws dat net in dat gaatje viel 25
seconden liggen -- en bij een hulpvraag is dat de verkeerde vertraging.

Het tikje blijft als vangnet, maar op vijf minuten in plaats van 45 seconden:
valt er een wekker weg (een pad dat niet wekt, een herstart midden in een
verzoek), dan mag het scherm niet voorgoed stilstaan. Met de ETag kost zo'n tik
meestal een lege 304.

EEN NAAD DIE IK EERST NIET KON RAKEN. Het wekken zat verstopt in de deps-literal,
en de mutatie die het weghaalde bleef groen -- mijn toetsen dekten alleen de bus.
onGuardianshipEvent is nu een functie met een naam, precies zoals
guardianEventPush dat al was, en de mutatie is nu rood. Een lange poll die niet
wekt is niet te onderscheiden van een trage server, en dat is het soort fout dat
niemand meldt.

Tien toetsen. Suite 770/770.

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