source: Klonkt/src/assets/js/guardian.js@ 3a0ca0f

main
Last change on this file since 3a0ca0f was 0b0cd54, checked in by roboburr <roboburr@…>, 5 weeks ago

De vraag van het kind staat apart van de wachtrij (shaer-8ru, vervolg)

Ik gooide het verzoek van de ward in dezelfde waiting-teller als de wachtende
volgverzoeken. Dat is fout: drie onbekenden die je kind willen volgen is iets
heel anders dan je kind dat een keer vraagt of muziek aan mag, en een gedeeld
getal maakt daar hetzelfde van -- dan leest de ene urgentie als de andere.

Nu een eigen veld requested op de poortrij, en in het paneel een eigen regel
onder de schakelaar in plaats van een getal in het statusveld: dit is het enige
op die rij dat van het KIND komt, en het hoort niet weg te vallen tussen de
drempels en de soorten. Drie talen.

Suite 714/714.

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