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

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

Een hulpvraag oppikken en afsluiten, zichtbaar voor alle guardians (shaer-lgo)

Een hulpvraag (5.2.1) gaat naar ALLE guardians van een kind, op verschillende
servers. Zonder gedeelde staat denken er twee dat de ander het oppakt -- precies
het scenario waar de reddingsboei voor bestaat. Tot nu toe droeg een hulpvraag
geen enkele staat: een vlag op ap_mentions, verder niets.

DE FAALSTAND IS HIER NIET VEILIG, en dat stuurt het hele ontwerp. Bij een gate is
"dicht" het veilige antwoord. Hier is de faalstand "iedereen denkt dat het
geregeld is", en dat is gevaarlijker dan geen markering. Daarom staat een
hulpvraag bij twijfel OPEN: een lege lijst, een rij die we niet kunnen lezen, een
soort die we niet kennen -- alles wat geen expliciete afsluiting is telt als
"er wacht nog iemand".

Twee besluiten van Bart zitten in de vorm.

OPGEPIKT mag stapelen en vervalt niet, maar veroudert zichtbaar. Twee mensen die
tegelijk reageren op een kind is geen probleem; twee die allebei niets doen omdat
de ander het "geclaimd" had, wel. En een signaal dat vanzelf verdwijnt laat een
hulpvraag er onaangeroerd uitzien terwijl er iemand mee bezig is -- dus het blijft
staan en toont hoe oud het is.

AFGEHANDELD kent GEEN terugdraai. Sluiten gaat met een stevige bevestiging (geen
window.confirm, zelfde lijn als het loslaten van een ward), en leeft de vraag
daarna nog, dan wordt hij opnieuw gesteld -- een nieuwe hulpvraag. Er wordt niets
herschreven, er wordt toegevoegd. Een test bewaakt dat er geen undo() of
reopen() bestaat.

Geen nieuw protocol: de markering is een gewone directe note met een
shaer:-eigenschap, net als de zwaai en de afwezigheidsmelding. Daardoor reist hij
over de bestaande bezorging, ziet de WARD hem als bericht ("er komt iemand") en
houden de mede-guardians er staat aan over. Naar de ward en naar de andere
guardians tegelijk.

De eigen kopie wordt meteen weggeschreven, voordat er bezorgd is: het scherm van
degene die klikt hoort niet te liegen omdat een andere server traag is.

11 tests op het pure stuk. Gecontroleerd dat ze bijten: laat helpStatus altijd
"afgehandeld" zeggen en er vallen er vijf om. nl/en/de. Suite 576/576.

Niet gedekt: de weergave zelf (client-JS), en er is geen guardianship op dev om
het end-to-end te zien lopen.

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