source: Klonkt/src/assets/js/guardian.js@ 792af53

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

Het gate-paneel: een rij per gate, met het soort en de drempel (shaer-ahy.1)

Een guardian zag twee knoppen en moest zelf uitzoeken wat er verder voor dit kind
gold. Wat niet verstelbaar is stond nergens -- terwijl dat de helft is van het
antwoord op "wat mag dit kind".

EEN BRON VAN WAARHEID. GATE_CATALOGUE in gated.js is nu de lijst van gates die
deze Klonkt kent. Wat gated wordt is een ontwerpkeuze van de implementatie: de
FEP levert het mechanisme (voorstel, tally, settle) en een paar voorbeelden, niet
de lijst. Een gate erbij is daarmee een regel data en geen nieuw stuk scherm.

HET SOORT STAAT ERBIJ, want ze werken niet hetzelfde:

setting een stand, aan of uit, terug te draaien
perRequest geen stand maar een stroom beslissingen (5.3 volgverzoeken)
handover draagt gezag OVER, onomkeerbaar zodra de ward hem gebruikt

Die derde bestaat nog niet (shaer-90v), maar de rij kan hem al dragen -- inclusief
"niet terug te draaien", zodat dat er niet later ingebouwd hoeft te worden.

Volgverzoeken staan er nu ook in, met het aantal dat wacht. Ze zijn niet te
verzetten (altijd aan voor een ward, 5.3) en juist daarom horen ze zichtbaar te
zijn.

TWEE DINGEN DIE HET PANEEL NIET VERZINT. Een onbekende stand is ONBEKEND en niet
uit: bij een ward op een andere server staat die kolom daar. En zonder bekend
aantal guardians komt er geen drempel op het scherm -- nul of een gok leest als
een feit, en dit is precies waar een guardian op afgaat voordat hij iets
voorstelt.

De trap uit shaer-ahy blijft: afspelen is pas te bewegen als linkvoorbeelden
aanstaan. Met dezelfde uitzondering als voorheen -- onbekend telt niet als dicht,
want dat kostte ooit een hele voorstelronde.

Terugval ingebouwd: serveert een oudere Klonkt de catalogus nog niet, dan
verschijnen de twee knoppen zoals ze waren in plaats van een leeg vak.

9 tests op gateRows, dat pure stuk waar de regels in zitten. nl/en/de. Suite
565/565. Niet gedekt: de weergave zelf, dat is client-JS.

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