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

main
Last change on this file since f06ed56 was f06ed56, checked in by Bart <bart@…>, 4 weeks ago

Een logboek, zodat de reden ergens blijft staan

Guardianship-events waren vluchtig: onGuardianshipEvent wekte de long-poll,
zocht in een tabel van zeven soorten of er een push bij hoorde, en liet de rest
vallen. Elf van de achttien soorten verdwenen spoorloos, met hun inhoud. Een
weigering droeg reason: 'not_a_teapot' tot precies daar en niet verder --
terwijl §4.2 eist dat de ward en zijn guardians die reden te HOREN krijgen, en
niet dat ze hem afleiden uit een aanbod dat opeens weg is.

Vastleggen en melden zijn nu twee dingen. Alles komt in ap_guardian_events;
welke gebeurtenis een mens wakker maakt blijft de aparte, korte lijst die het
altijd al was.

Ingeklapt en onderaan, want hier vraagt niets om een antwoord. Zou dit tussen
de wachtrijen staan, dan wordt "moet ik iets doen" onleesbaar -- dezelfde reden
waarom de afgehandelde hulpvragen daar al staan.

200 per account, jongste eerst. Een logboek dat oneindig groeit wordt er een
die niemand opent, en afkappen aan de verkeerde kant zou hem onbruikbaar maken
op het moment dat er juist iets gebeurt. Schrijffouten worden geslikt: het
logboek is bijzaak en mag de commit of de weigering zelf nooit meesleuren.

Onbekende soorten vallen in de PWA terug op hun ruwe naam. Zichtbaar en lelijk
is beter dan netjes en afwezig.

Co-Authored-By: Claude Opus 5 <claude@…>

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