source: Klonkt/src/assets/js/guardian.js@ 7d7b36d

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

De geschiedenis apart, en onderaan het wardpaneel (shaer-lgo, vervolg)

Barts wens: afgehandelde hulpvragen los van de openstaande, en helemaal onderaan.

De hulpvraag-sectie in een wardpaneel toont nu alleen wat nog WACHT. Sorteren was
niet genoeg: dooreen gezet lees je niet meer of er iets van je gevraagd wordt, en
dat is de enige vraag die die sectie hoort te beantwoorden.

De afgehandelde vragen staan als eigen sectie onder de recente berichten, met een
lijn erboven en het aantal in de kop. Weg gaan ze niet -- er wordt in dit systeem
niets herschreven -- maar ze staan wel achteraan, waar geschiedenis hoort.

Geen lege sectie als er nog niets is afgehandeld: dat is geen informatie.

nl/en/de. Suite 576/576, al raakt die dit niet: client-JS.

  • Property mode set to 100644
File size: 43.5 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; }
65
66 function helpCard(h) {
67 var card = el('div', 'g-card help');
68 var row = el('div', 'row');
69 var who = el('span', 'who grow');
70 // name_html carries the custom emojis (FEP-9098) of the display name, the
71 // same way de Krant renders a byline. Falls back to the plain name.
72 if (h.name_html) who.innerHTML = h.name_html;
73 else who.textContent = h.actor_name || handleOf(h.actor_uri, h.actor_handle);
74 row.appendChild(who);
75 row.appendChild(el('span', 'when', when(h, h.published || h.created_at)));
76 card.appendChild(row);
77 var body = el('div', 'body g-note');
78 // body_html is the shared note-body partial, rendered server-side: the
79 // content with its emojis, the quote / link-preview card and the media.
80 // Falls back to the bare content for rows stored before that existed.
81 body.innerHTML = h.body_html || h.content || ''; // sanitized server-side on ingest
82 card.appendChild(body);
83 if (h.note_url) {
84 var a = el('a', 'g-link', T.open || 'open');
85 a.href = h.note_url; a.target = '_blank'; a.rel = 'noopener';
86 card.appendChild(a);
87 }
88 card.appendChild(helpState(h));
89 return card;
90 }
91
92 /** Hoe lang geleden, grof. Een oppik vervalt niet maar hoort wel te verouderen. */
93 function ago(ms) {
94 var u = Math.floor(ms / 3600000);
95 if (u < 1) return T.help_just_now || 'just now';
96 if (u < 24) return (T.help_hours || '{n}h ago').replace('{n}', u);
97 return (T.help_days || '{n}d ago').replace('{n}', Math.floor(u / 24));
98 }
99
100 /**
101 * De gedeelde staat van een hulpvraag (shaer-lgo): wie er al op af is, of het
102 * is afgesloten, en de twee knoppen.
103 *
104 * De faalstand hier is NIET veilig: 'iedereen denkt dat het geregeld is' is
105 * gevaarlijker dan geen markering. Dus bij twijfel toont dit OPEN, en een oude
106 * oppik verkleurt in plaats van te verdwijnen -- anders ziet een hulpvraag er
107 * onaangeroerd uit terwijl er iemand mee bezig is.
108 */
109 function helpState(h) {
110 var st = h.state || { open: true, pickedUpBy: [], handled: null, ageMs: null };
111 var box = el('div', 'g-help-state');
112
113 if (st.handled) {
114 var wie = st.handled.handle || handleOf(st.handled.uri);
115 box.appendChild(el('div', 'g-help-done', (T.help_handled_by || 'Handled by {who}').replace('{who}', wie)));
116 // Geen terugdraaiknop: leeft de vraag nog, dan wordt hij opnieuw gesteld.
117 box.appendChild(el('p', 'small g-empty', T.help_handled_note || ''));
118 return box;
119 }
120
121 (st.pickedUpBy || []).forEach(function (p) {
122 var line = (T.help_picked_by || '{who} is looking into this').replace('{who}', p.handle || handleOf(p.uri));
123 box.appendChild(el('div', 'g-help-pick', line));
124 });
125 // Oud, maar niet weg. Dit is het verschil tussen 'er is iemand mee bezig' en
126 // 'er was ooit iemand mee bezig'.
127 if (st.ageMs != null && st.ageMs > 3600000) {
128 box.appendChild(el('div', 'g-help-age small', ago(st.ageMs)));
129 }
130
131 var row = el('div', 'row');
132 var mij = (st.pickedUpBy || []).some(function (p) { return p.uri === S.me; });
133 if (!mij) {
134 var pick = el('button', 'small', T.help_pick || 'I am on it');
135 pick.addEventListener('click', function () { markHelp(h, 'pickup', pick); });
136 row.appendChild(pick);
137 }
138 var done = el('button', 'quiet small', T.help_close || 'Mark handled');
139 // Een stevige bevestiging, en nooit een window.confirm: dat verstopt een
140 // uitleg achter een OK die mensen wegklikken. Zelfde lijn als het loslaten
141 // van een ward.
142 done.addEventListener('click', function () {
143 done.hidden = true;
144 var ask = el('div', 'g-confirm');
145 ask.appendChild(el('p', 'small', T.help_close_ask || ''));
146 var ja = el('button', 'small', T.help_close_yes || 'Yes, handled');
147 ja.addEventListener('click', function () { markHelp(h, 'handled', ja); });
148 var nee = el('button', 'quiet small', T.release_no || 'No');
149 nee.addEventListener('click', function () { ask.remove(); done.hidden = false; });
150 ask.appendChild(ja); ask.appendChild(nee);
151 box.appendChild(ask);
152 });
153 row.appendChild(done);
154 box.appendChild(row);
155 return box;
156 }
157
158 function markHelp(h, kind, btn) {
159 if (btn) btn.disabled = true;
160 fetch('/guardian/api/help/' + kind, {
161 method: 'POST', headers: { 'Content-Type': 'application/json' },
162 body: JSON.stringify({ note: h.object_uri, ward: h.actor_uri, site: S.site }),
163 }).then(refresh).catch(function () { if (btn) btn.disabled = false; });
164 }
165
166 function renderHelp() {
167 var list = document.getElementById('help-list');
168 list.textContent = '';
169 var alle = S.help || [];
170 // Afgehandeld hoort niet meer in de lijst die om aandacht vraagt, en niet
171 // meer in de teller: anders blijft het cijfer alarm slaan voor iets dat af
172 // is, en neemt een gesloten vraag de ruimte in van een open vraag.
173 var open = alle.filter(helpOpen);
174 var klaar = alle.filter(function (h) { return !helpOpen(h); });
175
176 open.slice(0, HELP_TOP).forEach(function (h) { list.appendChild(helpCard(h)); });
177 if (open.length > HELP_TOP) {
178 list.appendChild(el('p', 'g-sec-sub', '+ ' + (open.length - HELP_TOP) + ' — ' + (T.panel_help || '')));
179 }
180
181 // WEG is niet hetzelfde als AF. Een afgehandelde hulpvraag blijft bestaan --
182 // er wordt in dit systeem niets herschreven -- maar hij hoort achter een
183 // klik, niet voor je neus. De volledige geschiedenis per kind staat sowieso
184 // in het paneel van dat kind.
185 if (klaar.length) {
186 var doos = el('div', 'g-help-archive');
187 doos.hidden = true;
188 klaar.forEach(function (h) { doos.appendChild(helpCard(h)); });
189 var knop = el('button', 'quiet small', (T.help_archive || '{n} handled').replace('{n}', klaar.length));
190 knop.addEventListener('click', function () {
191 doos.hidden = !doos.hidden;
192 knop.textContent = doos.hidden
193 ? (T.help_archive || '{n} handled').replace('{n}', klaar.length)
194 : (T.help_archive_hide || 'hide');
195 });
196 list.appendChild(knop);
197 list.appendChild(doos);
198 }
199
200 var badge = document.getElementById('help-count');
201 badge.textContent = open.length; badge.hidden = open.length === 0;
202 // "Geen hulpverzoeken. Mooi zo." mag ook staan als er wel een archief is:
203 // er wacht dan immers niets.
204 show('help-empty', open.length === 0);
205 }
206
207 // ── 3. Offers I am a party to (sent, or a co-guardianship to co-approve) ─
208 function answer(offerId, decision, btn) {
209 if (btn) btn.disabled = true;
210 fetch('/guardian/offer', {
211 method: 'POST', headers: { 'Content-Type': 'application/json' },
212 body: JSON.stringify({ offer: offerId, answer: decision, site: S.site }),
213 }).then(refresh);
214 }
215 function offerCard(o) {
216 var card = el('div', 'g-card');
217 var row = el('div', 'row');
218 var subject = o['shaer:iAmCandidate']
219 ? handleOf(o['shaer:ward'], o['shaer:wardHandle']) // my sent offer: about the ward
220 : handleOf(o['shaer:candidate'], o['shaer:candidateHandle']); // co-guard: who wants in
221 row.appendChild(el('span', 'who grow', subject));
222 if (o['shaer:iAmCandidate']) {
223 // My own offer, waiting for the others to accept.
224 row.appendChild(el('span', 'tag wait', T.pending));
225 var rt = el('button', 'quiet small', T.retract);
226 rt.addEventListener('click', function () { answer(o.id, 'reject', rt); });
227 row.appendChild(rt);
228 } else if (o['shaer:needsMyAccept']) {
229 // A co-guardianship offer for a ward I already guard: my call.
230 row.appendChild(el('span', 'tag co', T.coguard));
231 var ac = el('button', 'small', T.accept);
232 ac.addEventListener('click', function () { answer(o.id, 'accept', ac); });
233 var rj = el('button', 'quiet small', T.reject);
234 rj.addEventListener('click', function () { answer(o.id, 'reject', rj); });
235 row.appendChild(ac); row.appendChild(rj);
236 } else {
237 row.appendChild(el('span', 'tag wait', T.awaiting_others));
238 }
239 card.appendChild(row);
240 return card;
241 }
242 /** A running lapse (FEP-633c 3.6.3): the available co-guardians deciding
243 * to release a dormant one. Votes ride the same Accept/Reject wire as the
244 * offers; buttons appear only for set members (the ward watches, it does
245 * not vote). */
246 function lapseCard(l) {
247 var card = el('div', 'g-card lapse');
248 card.appendChild(el('div', 'who', (T.lapse_line || '{who} has stopped answering as a guardian of {ward}.')
249 .replace('{who}', handleOf(l.object.object)).replace('{ward}', handleOf(l.object['shaer:ward']))));
250 card.appendChild(el('div', 'g-avlabel', (T.lapse_tally || '{n} of {need} agreed; closes {date}.')
251 .replace('{n}', l['shaer:accepts']).replace('{need}', l['shaer:threshold'])
252 .replace('{date}', new Date(l['shaer:closesAt']).toLocaleDateString())));
253 var row = el('div', 'row');
254 var inSet = (l['shaer:set'] || []).indexOf(S.me) >= 0;
255 if (inSet && !l['shaer:myVote']) {
256 var yes = el('button', 'small', T.lapse_agree || 'Agree');
257 yes.addEventListener('click', function () { answer(l.id, 'accept', yes); });
258 var no = el('button', 'quiet small', T.lapse_disagree || 'Disagree');
259 no.addEventListener('click', function () { answer(l.id, 'reject', no); });
260 row.appendChild(yes); row.appendChild(no);
261 } else if (inSet) {
262 row.appendChild(el('span', 'g-avlabel', T.voted || 'You voted'));
263 }
264 card.appendChild(row);
265 card.appendChild(el('p', 'g-empty small', T.lapse_note || ''));
266 return card;
267 }
268
269 /** A gated-setting proposal (FEP-633c 5.6) a fellow guardian opened on a
270 * ward we share, forwarded here by the ward's server. Answering is the
271 * whole point: without a second voice the threshold is never met and the
272 * proposal quietly expires. */
273 function gatedCard(g) {
274 var card = el('div', 'g-card gated');
275 var line = g.value ? (T.gated_line_on || '') : (T.gated_line_off || '');
276 card.appendChild(el('div', 'who', line
277 .replace('{who}', handleOf(g.proposer || ''))
278 .replace('{ward}', handleOf(g.ward))));
279 var row = el('div', 'row');
280 var yes = el('button', 'small', T.gated_agree || 'Agree');
281 var no = el('button', 'quiet small', T.gated_disagree || 'Disagree');
282 function answerGated(decision, btn) {
283 btn.disabled = true;
284 fetch('/guardian/api/gated/' + encodeURIComponent(g.id), {
285 method: 'POST', headers: { 'Content-Type': 'application/json' },
286 body: JSON.stringify({ answer: decision, site: S.site }),
287 }).then(refresh).catch(function () { btn.disabled = false; });
288 }
289 yes.addEventListener('click', function () { answerGated('accept', yes); });
290 no.addEventListener('click', function () { answerGated('reject', no); });
291 row.appendChild(yes); row.appendChild(no);
292 card.appendChild(row);
293 return card;
294 }
295
296 function renderPending() {
297 var list = document.getElementById('pending-list');
298 list.textContent = '';
299 var offers = S.offers || [];
300 var gated = S.gatedReviews || [];
301 // The offers state carries the adoption offers; the lapse proposals ride
302 // separately so a lapse never renders as an adoption.
303 var lapses = (S.lapses || []).filter(function (l) { return l['shaer:outcome'] === 'open'; });
304 offers.forEach(function (o) {
305 if (o.object && o.object.type === 'shaer:Lapse') return; // rendered below
306 list.appendChild(offerCard(o));
307 });
308 lapses.forEach(function (l) { list.appendChild(lapseCard(l)); });
309 gated.forEach(function (g) { list.appendChild(gatedCard(g)); });
310 show('pending-section', offers.length > 0 || lapses.length > 0 || gated.length > 0);
311 }
312
313 // ── 4. Accepted wards: one panel per child ─────────────────────────────
314 // A guardian thinks per child, not per function, so everything about one
315 // child sits behind that child's row: the gated settings, the follow requests
316 // waiting on them, their calls for help, their recent posts. The row itself
317 // carries counts, so nothing that needs an answer hides inside a closed
318 // panel.
319 var openPanels = {}; // ward uri -> open, so a refresh does not close it
320 // The follow requests and the wards' posts arrive from their own endpoints
321 // and are grouped into the panels by ward, so they are cached here rather
322 // than rendered into a section of their own.
323 var FEED = [], FOLLOWS = [];
324
325 function sectionInto(panel, title, items, empty, build) {
326 var h = el('div', 'g-panel-sec');
327 h.appendChild(el('h3', null, title));
328 if (!items.length) h.appendChild(el('p', 'g-empty small', empty));
329 else items.forEach(function (it) { h.appendChild(build(it)); });
330 panel.appendChild(h);
331 return h;
332 }
333
334 function gateButton(w, feature, current, proposeLabel, onLabel, offLabel) {
335 var known = current === true || current === false;
336 var btn = el('button', 'quiet small', (known ? (current ? onLabel : offLabel) : proposeLabel) || feature);
337 btn.addEventListener('click', function () {
338 btn.disabled = true;
339 fetch('/guardian/wards/embeds', {
340 method: 'POST', headers: { 'Content-Type': 'application/json' },
341 body: JSON.stringify({ uri: w.other_uri, feature: feature, allow: known ? !current : true }),
342 }).then(function (r) { return r.json(); })
343 .then(function (j) {
344 if (j && j.state === 'open') {
345 btn.textContent = (T.embeds_waiting || 'waiting for the other guardians');
346 btn.disabled = true;
347 return;
348 }
349 refresh();
350 })
351 .catch(function () { btn.disabled = false; });
352 });
353 return btn;
354 }
355
356 function embedsButton(w) {
357 // Gated feature: external (non-fediverse) embeds. Off by default for a
358 // ward; only a guardian can open it, and the gate is enforced server-side
359 // when the feed is built, so this button is the only thing that moves it.
360 // Shown for EVERY ward, including one on another server. There the value is
361 // unknown (it lives on the ward's server), but proposing is exactly as
362 // possible: the proposal travels, the ward's server tallies the guardians
363 // and enforces. A guardian next door must not have more say than one far
364 // away.
365 var known = w.embeds === true || w.embeds === false;
366 var emb = el('button', 'quiet small',
367 (known ? (w.embeds ? T.embeds_on : T.embeds_off) : T.embeds_propose) || 'Link previews');
368 emb.addEventListener('click', function () {
369 emb.disabled = true;
370 fetch('/guardian/wards/embeds', {
371 method: 'POST', headers: { 'Content-Type': 'application/json' },
372 body: JSON.stringify({ uri: w.other_uri, allow: known ? !w.embeds : true }),
373 }).then(function (r) { return r.json(); })
374 .then(function (j) {
375 // Not settled yet: the other guardians still have to answer.
376 if (j && j.state === 'open') {
377 emb.textContent = (T.embeds_waiting || 'waiting for the other guardians');
378 emb.disabled = true;
379 return;
380 }
381 refresh();
382 })
383 .catch(function () { emb.disabled = false; });
384 });
385 return emb;
386 }
387
388 /**
389 * The second step of releasing a ward: what it does, then yes or no.
390 *
391 * The warning is assembled from what the server found, not from a fixed
392 * sentence, because releasing means two different things (FEP-633c): stepping
393 * down while other guardians remain (§3.3), or being the last one, which is
394 * emancipation and explicitly not one guardian's call (§3.4). And as long as
395 * the Undo does not federate, the ward's server keeps listing you either way
396 * — a guardian has to know that before pressing, not after.
397 */
398 function releaseStep(w, check, host, relBtn) {
399 var uri = w.other_uri;
400 var who = handleOf(uri, w.other_handle);
401 var box = el('div', 'g-warn');
402 box.appendChild(el('strong', null, (T.release_title || 'Release {who}?').replace('{who}', who)));
403 box.appendChild(el('p', null, T.release_effect || ''));
404 if (check.last === true) box.appendChild(el('p', 'grave', T.release_last || ''));
405 else if (check.last === false) box.appendChild(el('p', null, T.release_step_down || ''));
406 else box.appendChild(el('p', 'grave', T.release_unknown || ''));
407 box.appendChild(el('p', null, T.release_local || ''));
408
409 var row = el('div', 'row');
410 var no = el('button', 'small', T.release_no || 'No');
411 no.addEventListener('click', function () {
412 host.removeChild(box);
413 relBtn.hidden = false; relBtn.disabled = false;
414 });
415 // No first: the way out should be the easy one to hit.
416 row.appendChild(no);
417 // Being the last guardian is not a warning but a wall: the server refuses
418 // it (§3.4), so offering a yes here would only produce an error. The text
419 // above already says what has to happen instead.
420 if (check.last !== true) {
421 var yes = el('button', 'danger small', T.release_yes || 'Yes');
422 yes.addEventListener('click', function () {
423 yes.disabled = true;
424 remove(uri, yes, function (err) {
425 // The guardian set can change between the check and the click.
426 yes.disabled = false;
427 box.appendChild(el('p', 'grave', err === 'would_emancipate' ? (T.release_last || '') : (T.failed || '')));
428 if (err === 'would_emancipate') yes.remove();
429 });
430 });
431 row.appendChild(yes);
432 }
433 box.appendChild(row);
434 return box;
435 }
436
437 /** The availability dot (FEP-633c 3.6): buddy-list language on the
438 * responsibility axis. Green available, yellow declared away with an end,
439 * grey observed dormant (one answer restores). */
440 function availLabel(g) {
441 if (g.availability === 'away') {
442 var date = g.awayUntil ? new Date(g.awayUntil).toLocaleDateString() : '?';
443 return (T.avail_away || 'Unavailable till {date}').replace('{date}', date);
444 }
445 if (g.availability === 'dormant') return T.avail_dormant || 'Offline';
446 return T.avail_available || 'Available';
447 }
448 function availRow(g, wardUri) {
449 var row = el('div', 'row g-guard');
450 var dot = el('span', 'g-avdot ' + (g.availability === 'away' ? 'is-away' : g.availability === 'dormant' ? 'is-dormant' : 'is-active'));
451 row.appendChild(dot);
452 row.appendChild(el('span', 'who grow', handleOf(g.uri, g.handle)));
453 row.appendChild(el('span', 'g-avlabel', availLabel(g)));
454 // A dormant fellow guardian without a running lapse: the deliberate,
455 // rare next step (3.6.3). Never shown for anyone still answering.
456 if (g.availability === 'dormant' && !g.lapse && g.uri !== S.me) {
457 var btn = el('button', 'quiet small', T.lapse_propose || 'Propose release');
458 btn.addEventListener('click', function () {
459 btn.disabled = true;
460 fetch('/guardian/api/lapse', {
461 method: 'POST', headers: { 'Content-Type': 'application/json' },
462 body: JSON.stringify({ ward: wardUri, target: g.uri, site: S.site }),
463 }).then(refresh).catch(function () { btn.disabled = false; });
464 });
465 row.appendChild(btn);
466 }
467 return row;
468 }
469
470 /** Het woord dat we voor een gate gebruiken; onbekend valt terug op de naam. */
471 function gateLabel(feature) {
472 return T['gate_' + feature.replace('shaer:', '')] || feature.replace('shaer:', '');
473 }
474
475 /**
476 * Een rij in het gate-paneel. Toont WAT er geldt, van welk SOORT het is, welke
477 * drempel er hoort en wat er loopt -- en pas daarna een knop, als er iets te
478 * verzetten valt.
479 *
480 * Het soort staat erbij omdat de gates niet hetzelfde werken: een stand is aan
481 * of uit, volgverzoeken zijn een stroom beslissingen, en een overdracht is
482 * onomkeerbaar zodra het kind hem gebruikt. Vier rijen met dezelfde schakelaar
483 * zouden dat verschil wegpoetsen.
484 */
485 function gateRow(w, g) {
486 var row = el('div', 'g-gate');
487 var head = el('div', 'g-gate-head');
488 head.appendChild(el('span', 'g-gate-name', gateLabel(g.feature)));
489 head.appendChild(el('span', 'g-gate-kind', T['gate_kind_' + g.kind] || g.kind));
490 row.appendChild(head);
491
492 // De stand. NULL is onbekend en dat is iets anders dan uit: bij een ward op
493 // een andere server wordt die kolom daar bijgehouden.
494 var stand = g.value === null || g.value === undefined
495 ? (T.gate_unknown || 'unknown')
496 : (g.value ? (T.prop_on || 'on') : (T.prop_off || 'off'));
497 var meta = el('div', 'g-gate-meta small');
498 meta.appendChild(el('span', null, stand));
499 if (g.threshold) {
500 meta.appendChild(el('span', null, (T.gate_threshold || '{need} of {of} guardians')
501 .replace('{need}', g.threshold.need).replace('{of}', g.threshold.of)));
502 } else {
503 // Geen drempel verzinnen die we niet kennen.
504 meta.appendChild(el('span', 'g-dim', T.gate_threshold_unknown || ''));
505 }
506 if (!g.reversible) meta.appendChild(el('span', 'g-gate-warn', T.gate_irreversible || ''));
507 if (g.waiting) {
508 meta.appendChild(el('span', 'g-gate-wait', (T.gate_waiting || '{n} waiting').replace('{n}', g.waiting)));
509 }
510 row.appendChild(meta);
511
512 if (g.proposal) {
513 row.appendChild(el('p', 'small g-prop g-prop-' + g.proposal.status,
514 (T.prop_line || 'Proposal {what} {value}: {status}')
515 .replace('{what}', gateLabel(g.feature))
516 .replace('{value}', g.proposal.value ? (T.prop_on || 'on') : (T.prop_off || 'off'))
517 .replace('{status}', T['prop_st_' + g.proposal.status] || g.proposal.status)));
518 }
519 if (g.blockedBy) {
520 row.appendChild(el('p', 'small g-empty', (T.gate_blocked || 'needs {what} first')
521 .replace('{what}', gateLabel(g.blockedBy))));
522 }
523 if (g.adjustable) {
524 row.appendChild(gateButton(w, g.feature, g.value, T.gate_propose || T.embeds_propose,
525 T.prop_on || 'on', T.prop_off || 'off'));
526 }
527 return row;
528 }
529
530 function wardPanel(w) {
531 var uri = w.other_uri;
532 var panel = el('div', 'g-panel');
533 panel.hidden = !openPanels[uri];
534
535 var set = el('div', 'g-panel-sec');
536 set.appendChild(el('h3', null, T.settings_title || 'Settings'));
537 // Een rij per gate, uit de catalogus van de server (shaer-ahy.1). Losse
538 // knoppen lieten een guardian zelf uitzoeken wat er allemaal geldt, en wat
539 // niet verstelbaar is stond nergens -- terwijl dat de helft van het antwoord
540 // is op "wat mag dit kind".
541 (w.gates || []).forEach(function (g) { set.appendChild(gateRow(w, g)); });
542 // Terugval voor een server die de catalogus nog niet stuurt: dan de twee
543 // knoppen zoals ze waren, zodat een oudere Klonkt niet met een leeg vak zit.
544 if (!(w.gates || []).length) {
545 var setRow = el('div', 'row');
546 setRow.appendChild(gateButton(w, 'shaer:externalEmbeds', w.embeds, T.embeds_propose, T.embeds_on, T.embeds_off));
547 if (w.embeds !== false) {
548 setRow.appendChild(gateButton(w, 'shaer:externalPlayback', w.playback, T.play_propose, T.play_on, T.play_off));
549 }
550 set.appendChild(setRow);
551 (w.proposals || []).forEach(function (p) {
552 var what = p.feature === 'shaer:externalPlayback' ? (T.prop_play || 'playback') : (T.prop_embeds || 'link previews');
553 set.appendChild(el('p', 'small g-prop g-prop-' + p.status, (T.prop_line || 'Proposal {what} {value}: {status}')
554 .replace('{what}', what).replace('{value}', p.value ? (T.prop_on || 'on') : (T.prop_off || 'off'))
555 .replace('{status}', T['prop_st_' + p.status] || p.status)));
556 });
557 }
558 panel.appendChild(set);
559
560 // The fellow guardians of this child, with availability (3.6). For a
561 // ward on another server the states live there, and saying so honestly
562 // beats guessing.
563 var gsec = el('div', 'g-panel-sec');
564 gsec.appendChild(el('h3', null, T.panel_guards || 'Guardians of this child'));
565 if (w.guardians && w.guardians.length) {
566 w.guardians.forEach(function (g) { gsec.appendChild(availRow(g, uri)); });
567 } else {
568 // A ward on another server: WHO guards it is public on its actor
569 // (shaer:guardians, 2.1), so list the seats; availability is the ward
570 // server's private ledger (3.6.1) and is not shown, only named.
571 var placeholder = el('p', 'g-empty small', '…');
572 gsec.appendChild(placeholder);
573 fetch('/guardian/wards/guardians?site=' + encodeURIComponent(S.site) + '&uri=' + encodeURIComponent(uri))
574 .then(function (r) { return r.json(); })
575 .then(function (j) {
576 if (!j || !j.guardians || !j.guardians.length) {
577 placeholder.textContent = T.panel_guards_remote || '';
578 return;
579 }
580 placeholder.remove();
581 j.guardians.forEach(function (g) {
582 var row = el('div', 'row g-guard');
583 row.appendChild(el('span', 'who grow', handleOf(g.uri, g.handle)));
584 gsec.appendChild(row);
585 });
586 gsec.appendChild(el('p', 'g-empty small', T.panel_guards_far || ''));
587 })
588 .catch(function () { placeholder.textContent = T.panel_guards_remote || ''; });
589 }
590 panel.appendChild(gsec);
591
592 sectionInto(panel, T.panel_follow || 'Follow requests',
593 FOLLOWS.filter(function (f) { return f.wardUri === uri; }),
594 T.panel_follow_empty || '', followCard);
595
596 // Alleen wat nog WACHT. De afgehandelde vragen staan onderaan het paneel als
597 // eigen sectie: dooreen gezet lees je niet meer of er iets van je gevraagd
598 // wordt, en dat is de enige vraag die deze sectie hoort te beantwoorden.
599 sectionInto(panel, T.panel_help || 'Calls for help',
600 (S.help || []).filter(function (h) { return h.actor_uri === uri && helpOpen(h); }),
601 T.panel_help_empty || '', helpCard);
602
603 sectionInto(panel, T.panel_posts || 'Recent posts',
604 FEED.filter(function (p) { return p.authorUri === uri; }),
605 T.panel_posts_empty || '', feedCard);
606
607 // De geschiedenis: apart, en helemaal onderaan. Wat af is hoort niet tussen
608 // wat nog wacht -- daar wordt "moet ik iets doen" onleesbaar van. Weg gaat
609 // hij niet: er wordt in dit systeem niets herschreven, er wordt toegevoegd.
610 // Geen lege sectie als er nog niets is afgehandeld; dat is geen informatie.
611 var gedaan = (S.help || []).filter(function (h) { return h.actor_uri === uri && !helpOpen(h); });
612 if (gedaan.length) {
613 sectionInto(panel, (T.panel_history || 'Handled ({n})').replace('{n}', gedaan.length),
614 gedaan, '', helpCard).classList.add('g-panel-history');
615 }
616
617 var act = el('div', 'g-panel-sec');
618 act.appendChild(el('h3', null, T.panel_actions || 'Actions'));
619 var actRow = el('div', 'row');
620 var wave = el('button', 'small', T.wave || '👋 Wave');
621 wave.addEventListener('click', function () { sendWave(uri, wave); });
622 actRow.appendChild(wave);
623 var rel = el('button', 'quiet small', T.release);
624 // Letting a child go is a decision, not a click. It opens a step that first
625 // asks the server what releasing this particular ward actually does, then
626 // says it plainly and asks yes or no. Never window.confirm: that hides a
627 // long explanation behind an OK button people press to make it go away.
628 rel.addEventListener('click', function () {
629 rel.disabled = true;
630 // site matters: with several of your own sites the server would otherwise
631 // check this ward against the wrong one and answer "not my ward".
632 fetch('/guardian/wards/release-check?site=' + encodeURIComponent(S.site) + '&uri=' + encodeURIComponent(uri))
633 .then(function (r) { return r.json(); })
634 .then(function (c) {
635 rel.hidden = true;
636 act.appendChild(releaseStep(w, c || {}, act, rel));
637 })
638 .catch(function () { rel.disabled = false; });
639 });
640 actRow.appendChild(rel);
641 act.appendChild(actRow);
642 // Bovenaan, niet onderaan: zwaaien en loslaten zijn de dingen die je DOET.
643 // De rest van het paneel is lezen -- instellingen, wie er nog meer op let,
644 // wat er binnenkwam. Wie het paneel opent om iets te doen hoorde eerst langs
645 // vijf secties te scrollen.
646 panel.insertBefore(act, panel.firstChild);
647 return panel;
648 }
649
650 function renderWards() {
651 var list = document.getElementById('wards-list');
652 list.textContent = '';
653 var wards = S.wards || [];
654 wards.forEach(function (w) {
655 var uri = w.other_uri;
656 var card = el('div', 'g-card ward');
657 var row = el('div', 'row');
658 row.appendChild(el('span', 'who grow', handleOf(uri, w.other_handle)));
659 // Counts on the row: whatever is waiting must be visible with the panel shut.
660 // Alleen wat nog WACHT, zoals het commentaar hierboven al zei: een
661 // afgehandelde vraag hoorde de boei niet te laten staan.
662 var nHelp = (S.help || []).filter(function (h) { return h.actor_uri === uri && helpOpen(h); }).length;
663 var nFollow = FOLLOWS.filter(function (f) { return f.wardUri === uri; }).length;
664 if (nHelp) row.appendChild(el('span', 'tag help', '🛟 ' + nHelp));
665 if (nFollow) row.appendChild(el('span', 'tag co', nFollow + ' ' + (nFollow === 1 ? (T.badge_follow_one || '') : (T.badge_follow || ''))));
666 row.appendChild(el('span', 'tag ok', T.active));
667 var toggle = el('button', 'quiet small', openPanels[uri] ? T.panel_close : T.panel_open);
668 row.appendChild(toggle);
669 card.appendChild(row);
670 var panel = wardPanel(w);
671 card.appendChild(panel);
672 toggle.addEventListener('click', function () {
673 openPanels[uri] = !openPanels[uri];
674 panel.hidden = !openPanels[uri];
675 toggle.textContent = openPanels[uri] ? T.panel_close : T.panel_open;
676 });
677 list.appendChild(card);
678 });
679 show('wards-empty', wards.length === 0);
680 // Step away (3.6.1) only means something with wards to tell.
681 show('away-section', wards.length > 0);
682 }
683
684 // ── 4b. Step away (FEP-633c 3.6.1) ─────────────────────────────────────
685 function declareAway(days, btn) {
686 btn.disabled = true;
687 fetch('/guardian/api/away', {
688 method: 'POST', headers: { 'Content-Type': 'application/json' },
689 body: JSON.stringify({ days: days, site: S.site }),
690 }).then(function (r) { return r.json(); })
691 .then(function (j) {
692 btn.disabled = false;
693 var msg = document.getElementById('away-msg');
694 msg.hidden = false;
695 if (j && j.ok) {
696 msg.className = 'g-msg';
697 msg.textContent = (T.away_done || 'Your wards know you are unavailable until {date}.')
698 .replace('{date}', new Date(j.until).toLocaleDateString());
699 } else {
700 msg.className = 'g-msg err';
701 msg.textContent = (j && j.error) || (T.failed || 'failed');
702 }
703 })
704 .catch(function () { btn.disabled = false; });
705 }
706 var awayWeek = document.getElementById('away-week');
707 var awayMonth = document.getElementById('away-month');
708 if (awayWeek) awayWeek.addEventListener('click', function () { declareAway(7, awayWeek); });
709 if (awayMonth) awayMonth.addEventListener('click', function () { declareAway(30, awayMonth); });
710
711 function sendWave(uri, btn) {
712 btn.disabled = true;
713 fetch('/guardian/api/wave', {
714 method: 'POST', headers: { 'Content-Type': 'application/json' },
715 body: JSON.stringify({ ward: uri, site: S.site }),
716 }).then(function (r) { return r.json(); })
717 .then(function (j) { btn.disabled = false; btn.textContent = (j && j.ok) ? (T.waved || '👋 sent') : (T.wave || '👋 Wave'); })
718 .catch(function () { btn.disabled = false; });
719 }
720
721 function remove(uri, btn, onError) {
722 btn.disabled = true;
723 fetch('/guardian/wards/remove', {
724 method: 'POST', headers: { 'Content-Type': 'application/json' },
725 body: JSON.stringify({ uri: uri, site: S.site }),
726 }).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
727 .then(function (res) {
728 // The server can refuse: emptying shaer:guardians is emancipation and
729 // not one guardian's call (§3.4). Say so instead of silently redrawing.
730 if (!res.ok) { if (onError) onError(res.j && res.j.error); return; }
731 refresh();
732 })
733 .catch(function () { if (onError) onError('network'); else btn.disabled = false; });
734 }
735
736 function renderAll() { renderHelp(); renderPending(); renderWards(); }
737
738 // ── 0. Wards' corner: read-only feed of your wards' posts ───────────────
739 // Lives inside each child's panel now, so the fetches only fill a cache and
740 // ask the ward list to redraw. A guardian watches, it does not publish.
741 function feedCard(p) {
742 var card = el('div', 'g-card feed');
743 var head = el('div', 'row');
744 head.appendChild(el('span', 'who grow', p.author));
745 if (p.published) head.appendChild(el('span', 'g-when', when(p, p.published)));
746 card.appendChild(head);
747 var body = el('div', 'feed-body');
748 // body_html is de gedeelde note-body-partial, serverside gerenderd: opmaak,
749 // media, quote-kaart en embed, precies als in de Krant en in Berichten.
750 // Valt terug op de kale content voor een client uit de cache.
751 var html = p.body_html || p.content || '';
752 if (p.cw) {
753 // De content warning blijft van de PWA zelf: note-body versluiert alleen
754 // bij nsfw, en een ward-post met alleen een cw hoort hier dicht te staan.
755 var d = document.createElement('details');
756 var sum = document.createElement('summary'); sum.textContent = p.cw; d.appendChild(sum);
757 var inner = el('div'); inner.innerHTML = html; d.appendChild(inner);
758 body.appendChild(d);
759 } else {
760 body.innerHTML = html; // server-sanitized HTML (same as Berichten)
761 }
762 card.appendChild(body);
763 return card;
764 }
765
766 function loadFeed() {
767 return fetch('/guardian/api/feed?site=' + encodeURIComponent(S.site))
768 .then(function (r) { return r.json(); })
769 .then(function (f) { if (f && !f.error) { FEED = f.items || []; renderWards(); } })
770 .catch(function () { /* panels just show "nothing yet" */ });
771 }
772
773 // ── 0b. Follow requests on your wards (§5.3) ────────────────────────────
774 function answerFollow(id, decision, btn) {
775 if (btn) btn.disabled = true;
776 fetch('/guardian/api/follow/' + encodeURIComponent(id), {
777 method: 'POST', headers: { 'Content-Type': 'application/json' },
778 body: JSON.stringify({ decision: decision, site: S.site }),
779 }).then(loadFollowReqs);
780 }
781 function followCard(f) {
782 var card = el('div', 'g-card');
783 var row = el('div', 'row');
784 // Inside the child's own panel the ward name is a given, so only the
785 // person asking is named here.
786 row.appendChild(el('span', 'who grow', f.follower));
787 var ok = el('button', 'small', T.accept || 'Accept');
788 ok.addEventListener('click', function () { answerFollow(f.id, 'approve', ok); });
789 var no = el('button', 'quiet small', T.reject || 'Deny');
790 no.addEventListener('click', function () { answerFollow(f.id, 'reject', no); });
791 row.appendChild(ok); row.appendChild(no);
792 card.appendChild(row);
793 return card;
794 }
795 function loadFollowReqs() {
796 return fetch('/guardian/api/follow-requests?site=' + encodeURIComponent(S.site))
797 .then(function (r) { return r.json(); })
798 .then(function (f) { if (f && !f.error) { FOLLOWS = f.items || []; renderWards(); } })
799 .catch(function () { /* panels just show "none waiting" */ });
800 }
801
802 function refresh() {
803 return fetch('/guardian/api/state?site=' + encodeURIComponent(S.site))
804 .then(function (r) { return r.json(); })
805 .then(function (s) { if (s && !s.error) { S = s; T = s.strings || T; renderAll(); } })
806 .then(loadFeed).then(loadFollowReqs);
807 }
808
809 // ── 2. Adopt ───────────────────────────────────────────────────────────
810 var form = document.getElementById('adopt-form');
811 var input = document.getElementById('adopt-handle');
812 var adoptBtn = document.getElementById('adopt-btn');
813 var msg = document.getElementById('adopt-msg');
814 function setMsg(text, isErr) { msg.hidden = false; msg.className = 'g-msg' + (isErr ? ' err' : ''); msg.textContent = text; }
815
816 form.addEventListener('submit', function (ev) {
817 ev.preventDefault();
818 var handle = input.value.trim();
819 if (!handle) return;
820 adoptBtn.disabled = true;
821 setMsg(T.sending || '…', false);
822 fetch('/guardian/adopt', {
823 method: 'POST', headers: { 'Content-Type': 'application/json' },
824 body: JSON.stringify({ handle: handle, site: S.site }),
825 }).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
826 .then(function (res) {
827 adoptBtn.disabled = false;
828 if (res.ok) {
829 input.value = '';
830 // Always refresh: the offer is recorded even if delivery is still
831 // in flight. Show it under "Verzonden aanvragen".
832 setMsg(res.j.delivered === false ? T.sent_retry : T.sent, false);
833 refresh();
834 } else {
835 setMsg((res.j.error === 'not_found' ? T.not_found : T.failed) , true);
836 }
837 })
838 .catch(function () { adoptBtn.disabled = false; setMsg(T.network, true); });
839 });
840
841 // ── Site picker ────────────────────────────────────────────────────────
842 var picker = document.getElementById('site-picker');
843 if (picker) picker.addEventListener('change', function () {
844 location.href = '/guardian?site=' + encodeURIComponent(picker.value);
845 });
846
847 // ── 5. Push ────────────────────────────────────────────────────────────
848 var toggle = document.getElementById('push-toggle');
849 var pmsg = document.getElementById('push-msg');
850 function pushState() {
851 if (!('serviceWorker' in navigator) || !('PushManager' in window)) { toggle.disabled = true; return; }
852 navigator.serviceWorker.register('/sw.js').catch(function () {});
853 navigator.serviceWorker.ready
854 .then(function (reg) { return reg.pushManager.getSubscription(); })
855 .then(function (sub) {
856 toggle.textContent = sub ? toggle.dataset.onLabel : toggle.dataset.offLabel;
857 toggle.dataset.subscribed = sub ? '1' : '';
858 toggle.classList.toggle('is-on', !!sub);
859 });
860 }
861 function urlB64(base64) {
862 var pad = '='.repeat((4 - (base64.length % 4)) % 4);
863 var b = (base64 + pad).replace(/-/g, '+').replace(/_/g, '/');
864 var raw = atob(b); var arr = new Uint8Array(raw.length);
865 for (var i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
866 return arr;
867 }
868 toggle.addEventListener('click', function () {
869 pmsg.hidden = true;
870 navigator.serviceWorker.ready.then(function (reg) {
871 if (toggle.dataset.subscribed) {
872 reg.pushManager.getSubscription().then(function (sub) {
873 if (!sub) return;
874 fetch('/push/unsubscribe', {
875 method: 'POST', headers: { 'Content-Type': 'application/json' },
876 body: JSON.stringify({ endpoint: sub.endpoint }),
877 }).then(function () { return sub.unsubscribe(); }).then(pushState);
878 });
879 return;
880 }
881 fetch('/push/vapid').then(function (r) { return r.json(); }).then(function (v) {
882 if (!v.publicKey) throw new Error('no key');
883 return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlB64(v.publicKey) });
884 }).then(function (sub) {
885 return fetch('/push/subscribe', {
886 method: 'POST', headers: { 'Content-Type': 'application/json' },
887 body: JSON.stringify({
888 subscription: sub.toJSON(),
889 alerts: { help: 1, guardian: 1, dm: 1, follow: 0, reply: 0, like: 0, boost: 0 },
890 uaLabel: 'Guardian PWA',
891 }),
892 });
893 }).then(pushState).catch(function (e) {
894 pmsg.hidden = false; pmsg.className = 'g-msg err';
895 pmsg.textContent = (T.push_unavailable || 'Push unavailable') + ': ' + e.message;
896 });
897 });
898 });
899
900 renderAll(); pushState(); loadFeed(); loadFollowReqs();
901
902 // De push die de melding brengt is meteen het teken dat de staat veranderd is.
903 // Daarmee hoeft er geen tweede, open verbinding bij: hetzelfde kanaal doet het
904 // werk, en het werkt ook als de app dicht is.
905 if ('serviceWorker' in navigator) {
906 navigator.serviceWorker.addEventListener('message', function (e) {
907 if (e.data && e.data.klonkt === 'push') refresh();
908 });
909 }
910 // Het tikje blijft als vangnet -- niet elke verandering geeft een melding, en
911 // niet iedereen heeft meldingen aanstaan. Maar niet tikken terwijl niemand
912 // kijkt: dat waren verzoeken voor een tabblad op de achtergrond. Bij terugkomen
913 // meteen een keer, want dan is de kans op nieuws het grootst.
914 setInterval(function () { if (!document.hidden) refresh(); }, 45000);
915 document.addEventListener('visibilitychange', function () { if (!document.hidden) refresh(); });
916 } catch (e) {
917 fatal((e && e.message) || String(e));
918 }
919})();
Note: See TracBrowser for help on using the repository browser.