source: Klonkt/src/assets/js/guardian.js@ ea211b9

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

Je hoort te weten dat je de doorslag geeft (shaer-8vt)

De telling is een race naar de drempel: zodra het aantal gehaald is, is het
besluit gevallen. Bij 2 van 3 is de tweede ja meteen de beslissing -- en sinds
Barts meerderheidsbesluit van vandaag is bij een volgverzoek met twee guardians
de EERSTE ja dat al. Wie antwoordde wist dat niet, en het scherm zei het nergens.

Dat kon niet, en waarom niet was de vondst bij shaer-nf9: gatedProgress werkt op
een LOKALE slug en er zijn geen lokale wards. De telling loopt op de server van
het kind, en die stuurde hem niet mee. Nu wel: shaer:decisive reist mee met de
doorgestuurde Offer, voor gate-voorstellen en voor volgverzoeken in beide
richtingen.

EEN JA/NEE, GEEN TELLING, en dat is een besluit. Een getal ("1 van 2") reist mee,
veroudert onderweg en leest daarna als een feit; de beschikbare set schuift met
3.6 bovendien mee. En hoeveel guardians een kind heeft, en wie er al gestemd
heeft, is niet vanzelf iets dat elke mede-guardian hoort te zien. Een
waarschuwing veroudert ook, maar hij CLAIMT niets -- en dat scheelt.

BIJ TWIJFEL WAARSCHUWEN. Ontbreekt het veld (een oudere server), dan zeggen we
dat je beslist. De twee fouten zijn niet gelijk: zeggen dat je beslist terwijl
dat niet zo is maakt iemand voorzichtiger dan nodig; niets zeggen terwijl hij wel
beslist laat hem het onwetend doen. Daar staat een toets op, en de mutatie die
ertoe doet -- onbekend als "je beslist niets" lezen -- maakt hem rood.

Eerlijk over de mutatietest: mijn eerste poging (de Number.isFinite-guards
weghalen) gaf nul fouten, want die geven voor undefined dezelfde uitkomst. De
regel bijt wel tegen de realistische verkeerde versie.

Zes toetsen, drie talen. Suite 747/747.

  • Property mode set to 100644
File size: 51.8 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 // ── 2. Adopt ───────────────────────────────────────────────────────────
970 var form = document.getElementById('adopt-form');
971 var input = document.getElementById('adopt-handle');
972 var adoptBtn = document.getElementById('adopt-btn');
973 var msg = document.getElementById('adopt-msg');
974 function setMsg(text, isErr) { msg.hidden = false; msg.className = 'g-msg' + (isErr ? ' err' : ''); msg.textContent = text; }
975
976 form.addEventListener('submit', function (ev) {
977 ev.preventDefault();
978 var handle = input.value.trim();
979 if (!handle) return;
980 adoptBtn.disabled = true;
981 setMsg(T.sending || '…', false);
982 fetch('/guardian/adopt', {
983 method: 'POST', headers: { 'Content-Type': 'application/json' },
984 body: JSON.stringify({ handle: handle, site: S.site }),
985 }).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
986 .then(function (res) {
987 adoptBtn.disabled = false;
988 if (res.ok) {
989 input.value = '';
990 // Always refresh: the offer is recorded even if delivery is still
991 // in flight. Show it under "Verzonden aanvragen".
992 setMsg(res.j.delivered === false ? T.sent_retry : T.sent, false);
993 refresh();
994 } else {
995 setMsg((res.j.error === 'not_found' ? T.not_found : T.failed) , true);
996 }
997 })
998 .catch(function () { adoptBtn.disabled = false; setMsg(T.network, true); });
999 });
1000
1001 // ── Site picker ────────────────────────────────────────────────────────
1002 var picker = document.getElementById('site-picker');
1003 if (picker) picker.addEventListener('change', function () {
1004 location.href = '/guardian?site=' + encodeURIComponent(picker.value);
1005 });
1006
1007 // ── 5. Push ────────────────────────────────────────────────────────────
1008 var toggle = document.getElementById('push-toggle');
1009 var pmsg = document.getElementById('push-msg');
1010 function pushState() {
1011 if (!('serviceWorker' in navigator) || !('PushManager' in window)) { toggle.disabled = true; return; }
1012 navigator.serviceWorker.register('/sw.js').catch(function () {});
1013 navigator.serviceWorker.ready
1014 .then(function (reg) { return reg.pushManager.getSubscription(); })
1015 .then(function (sub) {
1016 toggle.textContent = sub ? toggle.dataset.onLabel : toggle.dataset.offLabel;
1017 toggle.dataset.subscribed = sub ? '1' : '';
1018 toggle.classList.toggle('is-on', !!sub);
1019 });
1020 }
1021 function urlB64(base64) {
1022 var pad = '='.repeat((4 - (base64.length % 4)) % 4);
1023 var b = (base64 + pad).replace(/-/g, '+').replace(/_/g, '/');
1024 var raw = atob(b); var arr = new Uint8Array(raw.length);
1025 for (var i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
1026 return arr;
1027 }
1028 toggle.addEventListener('click', function () {
1029 pmsg.hidden = true;
1030 navigator.serviceWorker.ready.then(function (reg) {
1031 if (toggle.dataset.subscribed) {
1032 reg.pushManager.getSubscription().then(function (sub) {
1033 if (!sub) return;
1034 fetch('/push/unsubscribe', {
1035 method: 'POST', headers: { 'Content-Type': 'application/json' },
1036 body: JSON.stringify({ endpoint: sub.endpoint }),
1037 }).then(function () { return sub.unsubscribe(); }).then(pushState);
1038 });
1039 return;
1040 }
1041 fetch('/push/vapid').then(function (r) { return r.json(); }).then(function (v) {
1042 if (!v.publicKey) throw new Error('no key');
1043 return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlB64(v.publicKey) });
1044 }).then(function (sub) {
1045 return fetch('/push/subscribe', {
1046 method: 'POST', headers: { 'Content-Type': 'application/json' },
1047 body: JSON.stringify({
1048 subscription: sub.toJSON(),
1049 alerts: { help: 1, guardian: 1, dm: 1, follow: 0, reply: 0, like: 0, boost: 0 },
1050 uaLabel: 'Guardian PWA',
1051 }),
1052 });
1053 }).then(pushState).catch(function (e) {
1054 pmsg.hidden = false; pmsg.className = 'g-msg err';
1055 pmsg.textContent = (T.push_unavailable || 'Push unavailable') + ': ' + e.message;
1056 });
1057 });
1058 });
1059
1060 renderAll(); pushState(); loadFeed(); loadFollowReqs();
1061
1062 // De push die de melding brengt is meteen het teken dat de staat veranderd is.
1063 // Daarmee hoeft er geen tweede, open verbinding bij: hetzelfde kanaal doet het
1064 // werk, en het werkt ook als de app dicht is.
1065 if ('serviceWorker' in navigator) {
1066 navigator.serviceWorker.addEventListener('message', function (e) {
1067 if (e.data && e.data.klonkt === 'push') refresh();
1068 });
1069 }
1070 // Het tikje blijft als vangnet -- niet elke verandering geeft een melding, en
1071 // niet iedereen heeft meldingen aanstaan. Maar niet tikken terwijl niemand
1072 // kijkt: dat waren verzoeken voor een tabblad op de achtergrond. Bij terugkomen
1073 // meteen een keer, want dan is de kans op nieuws het grootst.
1074 setInterval(function () { if (!document.hidden) refresh(); }, 45000);
1075 document.addEventListener('visibilitychange', function () { if (!document.hidden) refresh(); });
1076 } catch (e) {
1077 fatal((e && e.message) || String(e));
1078 }
1079})();
Note: See TracBrowser for help on using the repository browser.