source: Klonkt/src/assets/js/guardian.js@ 72d8eff

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

De gate-stand komt uit de besluiten, want er zijn geen lokale accounts

Barts correctie, en hij raakt twee dingen die ik fout had.

ER ZIJN GEEN LOKALE ACCOUNTS. wardGateSetting() leest de kolom alleen als de ward
op onze eigen base staat, en met een Klonkt per gebruiker woont elke ward elders.
Die functie gaf dus voor IEDERE ward null, en het gate-paneel toonde overal
"onbekend". Ik noemde dat een randgeval terwijl het het enige geval is.

EN DE GUARDIAN WEET HET WEL: hij kreeg de uitslag van het besluit door.
ap_gated_sent bewaart per voorstel de feature, de waarde en de uitkomst, dus een
geaccepteerd voorstel met waarde true IS de stand. knownSetting() leest dat.

Drie standen in plaats van twee. "Uit" en "voor zover wij weten uit" zijn niet
hetzelfde: dat tweede betekent dat niemand er ooit over besloot, en dat hoort
niet te lezen als een genomen besluit. De rij draagt nu decided en het scherm
zegt "uit (nog niets over besloten)".

Daarmee vervalt ook het gat dat ik een commit eerder noteerde: de richting van de
voorstelknop volgt nu een BEKENDE stand, dus voor een ward elders kun je ook
DICHTZETTEN voorstellen. Dat was precies de veilige richting die ontbrak.

De trap blokkeert alleen op een BESLOTEN dicht, niet op de standaard. Anders kan
afspelen nooit als eerste voorgesteld worden, en zo ging er ooit een hele
voorstelronde de verkeerde gate in.

BEKEND GAT, in het commentaar bij knownSetting gezet en niet hier opgelost: dit
ziet alleen onze EIGEN voorstellen. Antwoordde je op dat van een mede-guardian,
dan komt de uitslag wel binnen (gated_outcome) maar wordt hij niet bewaard --
handshake.js legt alleen vast voor sent-rijen die van ons zijn. Een gate die een
ander heeft geopend leest hier dus als uit, en dat is de onveilige kant.

4 tests erbij, waaronder de terugval op de oude kale-boolean vorm. Suite 599/599.

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