source: Klonkt/src/assets/js/guardian.js@ 2bd31d6

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

Waarschuwen voor wat er blijft hangen (shaer-nf9)

Barts opdracht: waarschuw bij het openen en het accepteren van een gate-voorstel.
Zijn zin was "Een geopende poort gaat niet meer dicht!"

DIE ZIN STAAT ER NIET, EN DAT IS MET OPZET. Als uitspraak over de INSTELLING is
hij onwaar: shaer-ahy eist het tegendeel en de code doet het -- een voorstel
draagt true of false. Een waarschuwing die aantoonbaar niet klopt neemt de rest
van het scherm mee in zijn val zodra iemand het merkt.

Wat wel onomkeerbaar is, is het GEVOLG, en die versie is zwaarder:

Wat hier doorheen komt, komt niet meer terug. Je kunt deze instelling later
weer dichtzetten -- wat je kind gezien heeft niet.

Independence krijgt zijn eigen tekst, want dat draagt gezag over en is wel echt
onomkeerbaar (shaer-90v). Welke tekst je krijgt volgt uit de catalogus, dus een
nieuwe gate hoeft niets te bedenken.

ONBEKEND KRIJGT DE ZWAARSTE. Een guardian elders kan iets voorstellen dat onze
catalogus niet kent. Bij twijfel waarschuwen we zwaarder, niet lichter -- de
faalstand die pijn doet is een guardian die iets doorlaat omdat het scherm er
licht over deed.

ALLEEN BIJ OPENZETTEN. Dichtzetten laat niets nieuws door, en een waarschuwing
die overal staat wordt nergens gelezen.

BIJ ACCEPTEREN KON HET GEVRAAGDE NIET. De bead stelde voor te tonen of jouw
antwoord de drempel haalt, want gatedProgress bestaat al. Maar die werkt op een
LOKALE slug, en er zijn geen lokale wards -- voor deze kaart komt hij altijd op
null uit. Een verzonnen "1 van 2" zou hier het gevaarlijkste getal op het scherm
zijn: het leest als "er kan nog iemand na mij". Er staat nu wat waar is: wij zien
de telling niet, die loopt op de server van het kind, en jouw ja kan de doorslag
geven -- want de tally settelt op het moment dat de drempel gehaald is.

Geen window.confirm, zoals bij het loslaten van een ward: de uitleg staat er, en
dan pas de knop. Drie talen. Vier toetsen, twee vallen om bij mutatie. 619/619.

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