source: Klonkt/src/assets/js/guardian.js@ 9588b51

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

De chip draagt de stand en wisselt mee (shaer-ahy.1, vervolg)

De chip op een gate-rij toonde het SOORT -- in het Nederlands "stand" -- vlak
boven de werkelijke stand. Twee dingen die hetzelfde heetten, waarvan er een
nooit veranderde. Wat je op zo'n rij zoekt is of de poort open is, en dat stond
op de tweede regel.

Nu draagt de chip de stand en het soort staat eronder bij de drempel: het
veranderlijke bovenaan, het beschrijvende daaronder.

Linkvoorbeelden [aan]
stand - 2 van 3 guardians
Voorstellen: dichtzetten

Drie chipstanden, drie gewichten. Aan valt op, uit is rustig, en "nog niets over
besloten" is lichter nog en gestippeld -- dat is namelijk geen besluit maar de
afwezigheid ervan, en dat verschil is precies waarom die derde stand bestaat.

Suite 599/599, al raakt die dit niet: client-JS.

  • Property mode set to 100644
File size: 44.6 KB
Line 
1/* Guardian PWA client (FEP-633c): renders the dashboard, adopts wards, and
2 manages the guardian push channel. No framework, no inline scripts (CSP).
3 All user-facing text comes from state.strings (server i18n). */
4(function () {
5 'use strict';
6 // A crash here used to fail silently (buttons just do nothing). Surface it on
7 // the page AND the console so the cause is visible instead of "everything hangs".
8 function fatal(msg) {
9 try {
10 var b = document.getElementById('g-fatal') || document.createElement('div');
11 b.id = 'g-fatal'; b.className = 'g-msg err';
12 b.style.cssText = 'display:block;margin:12px 0;padding:10px 14px';
13 b.textContent = 'Guardian: ' + msg;
14 var root = document.querySelector('main') || document.body;
15 if (!b.parentNode && root) root.insertBefore(b, root.firstChild);
16 } catch (e) { /* last resort */ }
17 try { console.error('[guardian]', msg); } catch (e) { /* no console */ }
18 }
19 try {
20 var S = JSON.parse(document.getElementById('guardian-state').textContent || '{}');
21 var T = S.strings || {};
22
23 function el(tag, cls, text) {
24 var n = document.createElement(tag);
25 if (cls) n.className = cls;
26 if (text != null) n.textContent = text;
27 return n;
28 }
29 function handleOf(uri, cached) {
30 if (cached && cached.charAt(0) === '@') return cached; // trust only real @handles
31 try { var u = new URL(uri); return '@' + u.pathname.split('/').filter(Boolean).pop() + '@' + u.host; }
32 catch (e) { return uri; }
33 }
34 // The server hands over a timestamp already formatted in the site's timezone
35 // (Beheer -> Instellingen), the same clock de Krant and Berichten show. The
36 // slice is only a fallback for a row that predates that field: it shows raw
37 // UTC, which is what made a 20:20 call for help read 18:20.
38 function when(item, raw) {
39 if (item && item.when_text) return item.when_text;
40 return String(raw || '').slice(0, 16).replace('T', ' ');
41 }
42 function show(id, on) { document.getElementById(id).hidden = !on; }
43
44 // ── 1. Help requests ───────────────────────────────────────────────────
45 // A call for help is not an alarm: it may well be settled quietly between a
46 // guardian and the child. So the card carries no siren, it just has to be
47 // impossible to miss. It shows up twice on purpose (Robins keuze): the recent
48 // ones across all children at the top, the full history of one child in that
49 // child's panel.
50 var HELP_TOP = 5;
51
52 /**
53 * Wacht deze hulpvraag nog op iemand?
54 *
55 * EEN plek, met opzet. Dit werd eerst op twee plekken los beslist -- de lijst
56 * en de teller op de wardregel -- en toen de lijst gefilterd werd bleef die
57 * teller een afgehandelde vraag meetellen. Een derde plek komt er vast, en dan
58 * hoort hij hier langs te komen.
59 *
60 * Bij TWIJFEL open: alles wat geen expliciete afsluiting draagt telt als
61 * wachtend. De omgekeerde fout -- iets als afgehandeld tonen dat het niet is --
62 * is hier de gevaarlijke.
63 */
64 function helpOpen(h) { return !h || !h.state || h.state.open; }
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 // Drie standen, niet twee. "Uit" en "voor zover wij weten uit" zijn niet
487 // hetzelfde: dat tweede betekent dat niemand er ooit over besloot, en dat
488 // hoort niet te lezen als een genomen besluit.
489 var stand = g.value === null || g.value === undefined
490 ? (T.gate_unknown || 'unknown')
491 : (g.value ? (T.prop_on || 'on') : (T.prop_off || 'off'));
492 if (!g.decided && g.value === false) stand = T.gate_default_off || stand;
493 var row = el('div', 'g-gate');
494 var head = el('div', 'g-gate-head');
495 head.appendChild(el('span', 'g-gate-name', gateLabel(g.feature)));
496 row.appendChild(head);
497
498 // De chip draagt de STAND en wisselt dus mee. Hij stond hier eerst op het
499 // SOORT -- in het Nederlands "stand" -- vlak boven de werkelijke stand, dus
500 // twee dingen die hetzelfde heetten waarvan er een nooit veranderde. Het
501 // veranderlijke hoort bovenaan; het beschrijvende gaat naar de regel eronder.
502 var chip = el('span', 'g-gate-chip g-gate-chip-' + (g.value === true ? 'on' : (g.decided ? 'off' : 'undecided')), stand);
503 head.appendChild(chip);
504
505 var meta = el('div', 'g-gate-meta small');
506 meta.appendChild(el('span', 'g-gate-kind', T['gate_kind_' + g.kind] || g.kind));
507 if (g.threshold) {
508 meta.appendChild(el('span', null, (T.gate_threshold || '{need} of {of} guardians')
509 .replace('{need}', g.threshold.need).replace('{of}', g.threshold.of)));
510 } else {
511 // Geen drempel verzinnen die we niet kennen.
512 meta.appendChild(el('span', 'g-dim', T.gate_threshold_unknown || ''));
513 }
514 if (!g.reversible) meta.appendChild(el('span', 'g-gate-warn', T.gate_irreversible || ''));
515 if (g.waiting) {
516 meta.appendChild(el('span', 'g-gate-wait', (T.gate_waiting || '{n} waiting').replace('{n}', g.waiting)));
517 }
518 row.appendChild(meta);
519
520 if (g.proposal) {
521 row.appendChild(el('p', 'small g-prop g-prop-' + g.proposal.status,
522 (T.prop_line || 'Proposal {what} {value}: {status}')
523 .replace('{what}', gateLabel(g.feature))
524 .replace('{value}', g.proposal.value ? (T.prop_on || 'on') : (T.prop_off || 'off'))
525 .replace('{status}', T['prop_st_' + g.proposal.status] || g.proposal.status)));
526 }
527 if (g.blockedBy) {
528 row.appendChild(el('p', 'small g-empty', (T.gate_blocked || 'needs {what} first')
529 .replace('{what}', gateLabel(g.blockedBy))));
530 }
531 if (g.adjustable) {
532 // De knop zegt wat er GEBEURT, niet wat er staat -- de stand staat hierboven
533 // al. Staat de poort open, dan is het enige wat je kunt voorstellen hem
534 // dichtzetten, en dat hoort op de knop te staan voordat je klikt.
535 //
536 // De richting volgt de stand die we uit de besluiten kennen, dus ook voor
537 // een ward elders kun je nu DICHTZETTEN voorstellen. Dat was de veilige
538 // richting die eerder ontbrak toen de stand altijd onbekend was.
539 var openzetten = T.gate_propose_open || T.gate_propose || 'Propose';
540 var dichtzetten = T.gate_propose_close || T.gate_propose || 'Propose';
541 row.appendChild(gateButton(w, g.feature, g.value, openzetten, dichtzetten, openzetten));
542 }
543 return row;
544 }
545
546 function wardPanel(w) {
547 var uri = w.other_uri;
548 var panel = el('div', 'g-panel');
549 panel.hidden = !openPanels[uri];
550
551 var set = el('div', 'g-panel-sec');
552 set.appendChild(el('h3', null, T.settings_title || 'Settings'));
553 // Een rij per gate, uit de catalogus van de server (shaer-ahy.1). Losse
554 // knoppen lieten een guardian zelf uitzoeken wat er allemaal geldt, en wat
555 // niet verstelbaar is stond nergens -- terwijl dat de helft van het antwoord
556 // is op "wat mag dit kind".
557 (w.gates || []).forEach(function (g) { set.appendChild(gateRow(w, g)); });
558 // Terugval voor een server die de catalogus nog niet stuurt: dan de twee
559 // knoppen zoals ze waren, zodat een oudere Klonkt niet met een leeg vak zit.
560 if (!(w.gates || []).length) {
561 var setRow = el('div', 'row');
562 setRow.appendChild(gateButton(w, 'shaer:externalEmbeds', w.embeds, T.embeds_propose, T.embeds_on, T.embeds_off));
563 if (w.embeds !== false) {
564 setRow.appendChild(gateButton(w, 'shaer:externalPlayback', w.playback, T.play_propose, T.play_on, T.play_off));
565 }
566 set.appendChild(setRow);
567 (w.proposals || []).forEach(function (p) {
568 var what = p.feature === 'shaer:externalPlayback' ? (T.prop_play || 'playback') : (T.prop_embeds || 'link previews');
569 set.appendChild(el('p', 'small g-prop g-prop-' + p.status, (T.prop_line || 'Proposal {what} {value}: {status}')
570 .replace('{what}', what).replace('{value}', p.value ? (T.prop_on || 'on') : (T.prop_off || 'off'))
571 .replace('{status}', T['prop_st_' + p.status] || p.status)));
572 });
573 }
574 panel.appendChild(set);
575
576 // The fellow guardians of this child, with availability (3.6). For a
577 // ward on another server the states live there, and saying so honestly
578 // beats guessing.
579 var gsec = el('div', 'g-panel-sec');
580 gsec.appendChild(el('h3', null, T.panel_guards || 'Guardians of this child'));
581 if (w.guardians && w.guardians.length) {
582 w.guardians.forEach(function (g) { gsec.appendChild(availRow(g, uri)); });
583 } else {
584 // A ward on another server: WHO guards it is public on its actor
585 // (shaer:guardians, 2.1), so list the seats; availability is the ward
586 // server's private ledger (3.6.1) and is not shown, only named.
587 var placeholder = el('p', 'g-empty small', '…');
588 gsec.appendChild(placeholder);
589 fetch('/guardian/wards/guardians?site=' + encodeURIComponent(S.site) + '&uri=' + encodeURIComponent(uri))
590 .then(function (r) { return r.json(); })
591 .then(function (j) {
592 if (!j || !j.guardians || !j.guardians.length) {
593 placeholder.textContent = T.panel_guards_remote || '';
594 return;
595 }
596 placeholder.remove();
597 j.guardians.forEach(function (g) {
598 var row = el('div', 'row g-guard');
599 row.appendChild(el('span', 'who grow', handleOf(g.uri, g.handle)));
600 gsec.appendChild(row);
601 });
602 gsec.appendChild(el('p', 'g-empty small', T.panel_guards_far || ''));
603 })
604 .catch(function () { placeholder.textContent = T.panel_guards_remote || ''; });
605 }
606 panel.appendChild(gsec);
607
608 sectionInto(panel, T.panel_follow || 'Follow requests',
609 FOLLOWS.filter(function (f) { return f.wardUri === uri; }),
610 T.panel_follow_empty || '', followCard);
611
612 // Alleen wat nog WACHT. De afgehandelde vragen staan onderaan het paneel als
613 // eigen sectie: dooreen gezet lees je niet meer of er iets van je gevraagd
614 // wordt, en dat is de enige vraag die deze sectie hoort te beantwoorden.
615 sectionInto(panel, T.panel_help || 'Calls for help',
616 (S.help || []).filter(function (h) { return h.actor_uri === uri && helpOpen(h); }),
617 T.panel_help_empty || '', helpCard);
618
619 sectionInto(panel, T.panel_posts || 'Recent posts',
620 FEED.filter(function (p) { return p.authorUri === uri; }),
621 T.panel_posts_empty || '', feedCard);
622
623 // De geschiedenis: apart, en helemaal onderaan. Wat af is hoort niet tussen
624 // wat nog wacht -- daar wordt "moet ik iets doen" onleesbaar van. Weg gaat
625 // hij niet: er wordt in dit systeem niets herschreven, er wordt toegevoegd.
626 // Geen lege sectie als er nog niets is afgehandeld; dat is geen informatie.
627 var gedaan = (S.help || []).filter(function (h) { return h.actor_uri === uri && !helpOpen(h); });
628 if (gedaan.length) {
629 sectionInto(panel, (T.panel_history || 'Handled ({n})').replace('{n}', gedaan.length),
630 gedaan, '', helpCard).classList.add('g-panel-history');
631 }
632
633 var act = el('div', 'g-panel-sec');
634 act.appendChild(el('h3', null, T.panel_actions || 'Actions'));
635 var actRow = el('div', 'row');
636 var wave = el('button', 'small', T.wave || '👋 Wave');
637 wave.addEventListener('click', function () { sendWave(uri, wave); });
638 actRow.appendChild(wave);
639 var rel = el('button', 'quiet small', T.release);
640 // Letting a child go is a decision, not a click. It opens a step that first
641 // asks the server what releasing this particular ward actually does, then
642 // says it plainly and asks yes or no. Never window.confirm: that hides a
643 // long explanation behind an OK button people press to make it go away.
644 rel.addEventListener('click', function () {
645 rel.disabled = true;
646 // site matters: with several of your own sites the server would otherwise
647 // check this ward against the wrong one and answer "not my ward".
648 fetch('/guardian/wards/release-check?site=' + encodeURIComponent(S.site) + '&uri=' + encodeURIComponent(uri))
649 .then(function (r) { return r.json(); })
650 .then(function (c) {
651 rel.hidden = true;
652 act.appendChild(releaseStep(w, c || {}, act, rel));
653 })
654 .catch(function () { rel.disabled = false; });
655 });
656 actRow.appendChild(rel);
657 act.appendChild(actRow);
658 // Bovenaan, niet onderaan: zwaaien en loslaten zijn de dingen die je DOET.
659 // De rest van het paneel is lezen -- instellingen, wie er nog meer op let,
660 // wat er binnenkwam. Wie het paneel opent om iets te doen hoorde eerst langs
661 // vijf secties te scrollen.
662 panel.insertBefore(act, panel.firstChild);
663 return panel;
664 }
665
666 function renderWards() {
667 var list = document.getElementById('wards-list');
668 list.textContent = '';
669 var wards = S.wards || [];
670 wards.forEach(function (w) {
671 var uri = w.other_uri;
672 var card = el('div', 'g-card ward');
673 var row = el('div', 'row');
674 row.appendChild(el('span', 'who grow', handleOf(uri, w.other_handle)));
675 // Counts on the row: whatever is waiting must be visible with the panel shut.
676 // Alleen wat nog WACHT, zoals het commentaar hierboven al zei: een
677 // afgehandelde vraag hoorde de boei niet te laten staan.
678 var nHelp = (S.help || []).filter(function (h) { return h.actor_uri === uri && helpOpen(h); }).length;
679 var nFollow = FOLLOWS.filter(function (f) { return f.wardUri === uri; }).length;
680 if (nHelp) row.appendChild(el('span', 'tag help', '🛟 ' + nHelp));
681 if (nFollow) row.appendChild(el('span', 'tag co', nFollow + ' ' + (nFollow === 1 ? (T.badge_follow_one || '') : (T.badge_follow || ''))));
682 row.appendChild(el('span', 'tag ok', T.active));
683 var toggle = el('button', 'quiet small', openPanels[uri] ? T.panel_close : T.panel_open);
684 row.appendChild(toggle);
685 card.appendChild(row);
686 var panel = wardPanel(w);
687 card.appendChild(panel);
688 toggle.addEventListener('click', function () {
689 openPanels[uri] = !openPanels[uri];
690 panel.hidden = !openPanels[uri];
691 toggle.textContent = openPanels[uri] ? T.panel_close : T.panel_open;
692 });
693 list.appendChild(card);
694 });
695 show('wards-empty', wards.length === 0);
696 // Step away (3.6.1) only means something with wards to tell.
697 show('away-section', wards.length > 0);
698 }
699
700 // ── 4b. Step away (FEP-633c 3.6.1) ─────────────────────────────────────
701 function declareAway(days, btn) {
702 btn.disabled = true;
703 fetch('/guardian/api/away', {
704 method: 'POST', headers: { 'Content-Type': 'application/json' },
705 body: JSON.stringify({ days: days, site: S.site }),
706 }).then(function (r) { return r.json(); })
707 .then(function (j) {
708 btn.disabled = false;
709 var msg = document.getElementById('away-msg');
710 msg.hidden = false;
711 if (j && j.ok) {
712 msg.className = 'g-msg';
713 msg.textContent = (T.away_done || 'Your wards know you are unavailable until {date}.')
714 .replace('{date}', new Date(j.until).toLocaleDateString());
715 } else {
716 msg.className = 'g-msg err';
717 msg.textContent = (j && j.error) || (T.failed || 'failed');
718 }
719 })
720 .catch(function () { btn.disabled = false; });
721 }
722 var awayWeek = document.getElementById('away-week');
723 var awayMonth = document.getElementById('away-month');
724 if (awayWeek) awayWeek.addEventListener('click', function () { declareAway(7, awayWeek); });
725 if (awayMonth) awayMonth.addEventListener('click', function () { declareAway(30, awayMonth); });
726
727 function sendWave(uri, btn) {
728 btn.disabled = true;
729 fetch('/guardian/api/wave', {
730 method: 'POST', headers: { 'Content-Type': 'application/json' },
731 body: JSON.stringify({ ward: uri, site: S.site }),
732 }).then(function (r) { return r.json(); })
733 .then(function (j) { btn.disabled = false; btn.textContent = (j && j.ok) ? (T.waved || '👋 sent') : (T.wave || '👋 Wave'); })
734 .catch(function () { btn.disabled = false; });
735 }
736
737 function remove(uri, btn, onError) {
738 btn.disabled = true;
739 fetch('/guardian/wards/remove', {
740 method: 'POST', headers: { 'Content-Type': 'application/json' },
741 body: JSON.stringify({ uri: uri, site: S.site }),
742 }).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
743 .then(function (res) {
744 // The server can refuse: emptying shaer:guardians is emancipation and
745 // not one guardian's call (§3.4). Say so instead of silently redrawing.
746 if (!res.ok) { if (onError) onError(res.j && res.j.error); return; }
747 refresh();
748 })
749 .catch(function () { if (onError) onError('network'); else btn.disabled = false; });
750 }
751
752 function renderAll() { renderHelp(); renderPending(); renderWards(); }
753
754 // ── 0. Wards' corner: read-only feed of your wards' posts ───────────────
755 // Lives inside each child's panel now, so the fetches only fill a cache and
756 // ask the ward list to redraw. A guardian watches, it does not publish.
757 function feedCard(p) {
758 var card = el('div', 'g-card feed');
759 var head = el('div', 'row');
760 head.appendChild(el('span', 'who grow', p.author));
761 if (p.published) head.appendChild(el('span', 'g-when', when(p, p.published)));
762 card.appendChild(head);
763 var body = el('div', 'feed-body');
764 // body_html is de gedeelde note-body-partial, serverside gerenderd: opmaak,
765 // media, quote-kaart en embed, precies als in de Krant en in Berichten.
766 // Valt terug op de kale content voor een client uit de cache.
767 var html = p.body_html || p.content || '';
768 if (p.cw) {
769 // De content warning blijft van de PWA zelf: note-body versluiert alleen
770 // bij nsfw, en een ward-post met alleen een cw hoort hier dicht te staan.
771 var d = document.createElement('details');
772 var sum = document.createElement('summary'); sum.textContent = p.cw; d.appendChild(sum);
773 var inner = el('div'); inner.innerHTML = html; d.appendChild(inner);
774 body.appendChild(d);
775 } else {
776 body.innerHTML = html; // server-sanitized HTML (same as Berichten)
777 }
778 card.appendChild(body);
779 return card;
780 }
781
782 function loadFeed() {
783 return fetch('/guardian/api/feed?site=' + encodeURIComponent(S.site))
784 .then(function (r) { return r.json(); })
785 .then(function (f) { if (f && !f.error) { FEED = f.items || []; renderWards(); } })
786 .catch(function () { /* panels just show "nothing yet" */ });
787 }
788
789 // ── 0b. Follow requests on your wards (§5.3) ────────────────────────────
790 function answerFollow(id, decision, btn) {
791 if (btn) btn.disabled = true;
792 fetch('/guardian/api/follow/' + encodeURIComponent(id), {
793 method: 'POST', headers: { 'Content-Type': 'application/json' },
794 body: JSON.stringify({ decision: decision, site: S.site }),
795 }).then(loadFollowReqs);
796 }
797 function followCard(f) {
798 var card = el('div', 'g-card');
799 var row = el('div', 'row');
800 // Inside the child's own panel the ward name is a given, so only the
801 // person asking is named here.
802 row.appendChild(el('span', 'who grow', f.follower));
803 var ok = el('button', 'small', T.accept || 'Accept');
804 ok.addEventListener('click', function () { answerFollow(f.id, 'approve', ok); });
805 var no = el('button', 'quiet small', T.reject || 'Deny');
806 no.addEventListener('click', function () { answerFollow(f.id, 'reject', no); });
807 row.appendChild(ok); row.appendChild(no);
808 card.appendChild(row);
809 return card;
810 }
811 function loadFollowReqs() {
812 return fetch('/guardian/api/follow-requests?site=' + encodeURIComponent(S.site))
813 .then(function (r) { return r.json(); })
814 .then(function (f) { if (f && !f.error) { FOLLOWS = f.items || []; renderWards(); } })
815 .catch(function () { /* panels just show "none waiting" */ });
816 }
817
818 function refresh() {
819 return fetch('/guardian/api/state?site=' + encodeURIComponent(S.site))
820 .then(function (r) { return r.json(); })
821 .then(function (s) { if (s && !s.error) { S = s; T = s.strings || T; renderAll(); } })
822 .then(loadFeed).then(loadFollowReqs);
823 }
824
825 // ── 2. Adopt ───────────────────────────────────────────────────────────
826 var form = document.getElementById('adopt-form');
827 var input = document.getElementById('adopt-handle');
828 var adoptBtn = document.getElementById('adopt-btn');
829 var msg = document.getElementById('adopt-msg');
830 function setMsg(text, isErr) { msg.hidden = false; msg.className = 'g-msg' + (isErr ? ' err' : ''); msg.textContent = text; }
831
832 form.addEventListener('submit', function (ev) {
833 ev.preventDefault();
834 var handle = input.value.trim();
835 if (!handle) return;
836 adoptBtn.disabled = true;
837 setMsg(T.sending || '…', false);
838 fetch('/guardian/adopt', {
839 method: 'POST', headers: { 'Content-Type': 'application/json' },
840 body: JSON.stringify({ handle: handle, site: S.site }),
841 }).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
842 .then(function (res) {
843 adoptBtn.disabled = false;
844 if (res.ok) {
845 input.value = '';
846 // Always refresh: the offer is recorded even if delivery is still
847 // in flight. Show it under "Verzonden aanvragen".
848 setMsg(res.j.delivered === false ? T.sent_retry : T.sent, false);
849 refresh();
850 } else {
851 setMsg((res.j.error === 'not_found' ? T.not_found : T.failed) , true);
852 }
853 })
854 .catch(function () { adoptBtn.disabled = false; setMsg(T.network, true); });
855 });
856
857 // ── Site picker ────────────────────────────────────────────────────────
858 var picker = document.getElementById('site-picker');
859 if (picker) picker.addEventListener('change', function () {
860 location.href = '/guardian?site=' + encodeURIComponent(picker.value);
861 });
862
863 // ── 5. Push ────────────────────────────────────────────────────────────
864 var toggle = document.getElementById('push-toggle');
865 var pmsg = document.getElementById('push-msg');
866 function pushState() {
867 if (!('serviceWorker' in navigator) || !('PushManager' in window)) { toggle.disabled = true; return; }
868 navigator.serviceWorker.register('/sw.js').catch(function () {});
869 navigator.serviceWorker.ready
870 .then(function (reg) { return reg.pushManager.getSubscription(); })
871 .then(function (sub) {
872 toggle.textContent = sub ? toggle.dataset.onLabel : toggle.dataset.offLabel;
873 toggle.dataset.subscribed = sub ? '1' : '';
874 toggle.classList.toggle('is-on', !!sub);
875 });
876 }
877 function urlB64(base64) {
878 var pad = '='.repeat((4 - (base64.length % 4)) % 4);
879 var b = (base64 + pad).replace(/-/g, '+').replace(/_/g, '/');
880 var raw = atob(b); var arr = new Uint8Array(raw.length);
881 for (var i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
882 return arr;
883 }
884 toggle.addEventListener('click', function () {
885 pmsg.hidden = true;
886 navigator.serviceWorker.ready.then(function (reg) {
887 if (toggle.dataset.subscribed) {
888 reg.pushManager.getSubscription().then(function (sub) {
889 if (!sub) return;
890 fetch('/push/unsubscribe', {
891 method: 'POST', headers: { 'Content-Type': 'application/json' },
892 body: JSON.stringify({ endpoint: sub.endpoint }),
893 }).then(function () { return sub.unsubscribe(); }).then(pushState);
894 });
895 return;
896 }
897 fetch('/push/vapid').then(function (r) { return r.json(); }).then(function (v) {
898 if (!v.publicKey) throw new Error('no key');
899 return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlB64(v.publicKey) });
900 }).then(function (sub) {
901 return fetch('/push/subscribe', {
902 method: 'POST', headers: { 'Content-Type': 'application/json' },
903 body: JSON.stringify({
904 subscription: sub.toJSON(),
905 alerts: { help: 1, guardian: 1, dm: 1, follow: 0, reply: 0, like: 0, boost: 0 },
906 uaLabel: 'Guardian PWA',
907 }),
908 });
909 }).then(pushState).catch(function (e) {
910 pmsg.hidden = false; pmsg.className = 'g-msg err';
911 pmsg.textContent = (T.push_unavailable || 'Push unavailable') + ': ' + e.message;
912 });
913 });
914 });
915
916 renderAll(); pushState(); loadFeed(); loadFollowReqs();
917
918 // De push die de melding brengt is meteen het teken dat de staat veranderd is.
919 // Daarmee hoeft er geen tweede, open verbinding bij: hetzelfde kanaal doet het
920 // werk, en het werkt ook als de app dicht is.
921 if ('serviceWorker' in navigator) {
922 navigator.serviceWorker.addEventListener('message', function (e) {
923 if (e.data && e.data.klonkt === 'push') refresh();
924 });
925 }
926 // Het tikje blijft als vangnet -- niet elke verandering geeft een melding, en
927 // niet iedereen heeft meldingen aanstaan. Maar niet tikken terwijl niemand
928 // kijkt: dat waren verzoeken voor een tabblad op de achtergrond. Bij terugkomen
929 // meteen een keer, want dan is de kans op nieuws het grootst.
930 setInterval(function () { if (!document.hidden) refresh(); }, 45000);
931 document.addEventListener('visibilitychange', function () { if (!document.hidden) refresh(); });
932 } catch (e) {
933 fatal((e && e.message) || String(e));
934 }
935})();
Note: See TracBrowser for help on using the repository browser.