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

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

Een afgehandelde hulpvraag gaat naar het archief (shaer-lgo, vervolg)

Barts melding na het eerste echte hulpverzoek: afhandelen werkte, maar de vraag
bleef daarna gewoon in beeld staan.

Mijn fout: ik zette de staat wel op de kaart maar filterde de lijst nooit.
renderHelp toonde de vijf recentste ongeacht of ze afgehandeld waren, en de
teller telde ze mee. Dus een gesloten vraag nam de ruimte in die voor openstaande
bedoeld is, en het cijfer bleef alarm slaan voor iets dat af was.

WEG is niet hetzelfde als AF. Verwijderen zou botsen met de regel die de rest van
deze feature draagt: er wordt niets herschreven, er wordt toegevoegd. Dus de
afgehandelde vragen blijven bestaan en gaan achter een klik: een regel
"{n} afgehandeld" die het archief openklapt. De volledige geschiedenis per kind
staat sowieso al in het paneel van dat kind.

De teller telt nu alleen wat nog open staat. "Geen hulpverzoeken. Mooi zo." mag
ook verschijnen als er wel een archief is -- er wacht dan immers niets.

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

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