source: Klonkt/src/assets/js/guardian.js@ 31e63d1

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

Acties bovenaan, en de push wekt een openstaande pagina (Guardian PWA)

ACTIES BOVENAAN. Zwaaien en loslaten zijn de dingen die je DOET; de rest van het
ward-paneel is lezen. Wie het paneel opende om iets te doen moest eerst langs
vijf secties scrollen.

DE PUSH ALS LIVE-SIGNAAL. Op de vraag of het paneel een long-poll kon worden: dat
hoeft niet. De service worker kreeg de push al binnen en toonde de melding, maar
zei niets tegen een pagina die openstond -- die wachtte tot het volgende tikje
van 45 seconden. Nu stuurt de worker een postMessage naar elk open venster en
ververst de pagina meteen.

Waarom dit beter is dan een tweede, open verbinding: het is hetzelfde kanaal, en
dat kanaal werkt ook als de app DICHT is. Een long-poll zou alleen werken zolang
je kijkt, en dan naast een push-kanaal staan dat hetzelfde nog eens doet. Een
kanaal, twee doelen.

Het tikje blijft als vangnet -- niet elke verandering geeft een melding, en niet
iedereen heeft meldingen aanstaan -- maar tikt niet meer terwijl niemand kijkt.
Dat waren verzoeken voor een tabblad op de achtergrond. Bij terugkomen ververst
hij meteen een keer, want dan is de kans op nieuws het grootst.

Niet gedekt door de suite: dit is client-code en een inline service worker.
Nagelopen op de uitgeleverde /sw.js.

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