source: Klonkt/src/assets/js/guardian.js@ 70677e96

main
Last change on this file since 70677e96 was 70677e96, checked in by Robin Genis <roboburr@…>, 6 weeks ago

Guardian-PWA: een paneel per kind

/guardian was een platte lijst van zeven secties: de wards' berichten, de
volgverzoeken, de hulpvragen, adopteren, verzonden aanvragen, wards, meldingen.
Wie een kind wilde overzien moest tussen die secties heen en weer, en nergens
stond bij elkaar wat er over dat ene kind speelt. Een guardian denkt niet per
functie maar per kind.

Nu is de wards-lijst de ingang: elk kind is een regel die opent naar een paneel
met zijn instellingen, zijn volgverzoeken, zijn hulpvragen en zijn recente
berichten. De regel zelf draagt de tellers (hulpvragen, volgverzoeken), zodat
een dicht paneel nooit iets verbergt dat een antwoord nodig heeft; dat is
belangrijk voor de volgverzoeken, want daar zit de follow-gating in.

De hulpvragen blijven daarnaast bovenaan staan, over alle kinderen heen, want
dat is waar deze app voor bestaat en het moet opvallen zonder dat je eerst een
kind opent. Ze verschijnen dus bewust twee keer: bovenaan de recente, in het
paneel de volledige geschiedenis van dat kind.

De hulpvraag is geen alarm. Er wordt niemand automatisch gewaarschuwd; een
guardian kan hem stil oplossen. De kaart is daarom rustig gehouden.

Changed files:
src/views/pages/guardian.ejs

  • feed-section en follow-section weg als losse secties

src/assets/js/guardian.js

  • helpCard, feedCard en followCard losgetrokken zodat beide plekken ze delen
  • wardPanel: instellingen, volgverzoeken, hulpvragen, berichten, acties
  • openPanels onthoudt wat open staat, zodat verversen niet dichtklapt
  • feed en volgverzoeken vullen nu een cache en hertekenen de wards-lijst

src/assets/css/guardian.css

  • opmaak voor het paneel en de tellers op de regel

src/routes/guardian.js

  • authorUri en wardUri meegeven: de sleutels waarop gegroepeerd wordt
  • de nieuwe labels toegevoegd aan uiStrings

src/services/i18n.js

  • 39 strings voor het paneel in nl, en, de

New file:
test/guardian-panel.test.js

  • de client schrijft nergens naar een verdwenen sectie
  • elke paneelsectie heeft zijn groepeersleutel
  • elk label dat de client gebruikt wordt ook geserveerd

remarks: geverifieerd in de browser op een wegwerp-database met twee kinderen:
elk paneel toont alleen zijn eigen volgverzoek, hulpvraag en post, een geopend
paneel blijft open over een ververs-ronde heen, en op 375px loopt niets buiten
beeld. De afhandel-lifecycle (oppakken/afgehandeld, shaer-jxi) en luid publiek
als tweede gated feature (shaer-3kp) komen hierna in dit paneel.

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

  • Property mode set to 100644
File size: 20.0 KB
Line 
1/* Guardian PWA client (FEP-633c): renders the dashboard, adopts wards, and
2 manages the guardian push channel. No framework, no inline scripts (CSP).
3 All user-facing text comes from state.strings (server i18n). */
4(function () {
5 'use strict';
6 // A crash here used to fail silently (buttons just do nothing). Surface it on
7 // the page AND the console so the cause is visible instead of "everything hangs".
8 function fatal(msg) {
9 try {
10 var b = document.getElementById('g-fatal') || document.createElement('div');
11 b.id = 'g-fatal'; b.className = 'g-msg err';
12 b.style.cssText = 'display:block;margin:12px 0;padding:10px 14px';
13 b.textContent = 'Guardian: ' + msg;
14 var root = document.querySelector('main') || document.body;
15 if (!b.parentNode && root) root.insertBefore(b, root.firstChild);
16 } catch (e) { /* last resort */ }
17 try { console.error('[guardian]', msg); } catch (e) { /* no console */ }
18 }
19 try {
20 var S = JSON.parse(document.getElementById('guardian-state').textContent || '{}');
21 var T = S.strings || {};
22
23 function el(tag, cls, text) {
24 var n = document.createElement(tag);
25 if (cls) n.className = cls;
26 if (text != null) n.textContent = text;
27 return n;
28 }
29 function handleOf(uri, cached) {
30 if (cached && cached.charAt(0) === '@') return cached; // trust only real @handles
31 try { var u = new URL(uri); return '@' + u.pathname.split('/').filter(Boolean).pop() + '@' + u.host; }
32 catch (e) { return uri; }
33 }
34 // The server hands over a timestamp already formatted in the site's timezone
35 // (Beheer -> Instellingen), the same clock de Krant and Berichten show. The
36 // slice is only a fallback for a row that predates that field: it shows raw
37 // UTC, which is what made a 20:20 call for help read 18:20.
38 function when(item, raw) {
39 if (item && item.when_text) return item.when_text;
40 return String(raw || '').slice(0, 16).replace('T', ' ');
41 }
42 function show(id, on) { document.getElementById(id).hidden = !on; }
43
44 // ── 1. Help requests ───────────────────────────────────────────────────
45 // A call for help is not an alarm: it may well be settled quietly between a
46 // guardian and the child. So the card carries no siren, it just has to be
47 // impossible to miss. It shows up twice on purpose (Robins keuze): the recent
48 // ones across all children at the top, the full history of one child in that
49 // child's panel.
50 var HELP_TOP = 5;
51
52 function helpCard(h) {
53 var card = el('div', 'g-card help');
54 var row = el('div', 'row');
55 var who = el('span', 'who grow');
56 // name_html carries the custom emojis (FEP-9098) of the display name, the
57 // same way de Krant renders a byline. Falls back to the plain name.
58 if (h.name_html) who.innerHTML = h.name_html;
59 else who.textContent = h.actor_name || handleOf(h.actor_uri, h.actor_handle);
60 row.appendChild(who);
61 row.appendChild(el('span', 'when', when(h, h.published || h.created_at)));
62 card.appendChild(row);
63 var body = el('div', 'body g-note');
64 // body_html is the shared note-body partial, rendered server-side: the
65 // content with its emojis, the quote / link-preview card and the media.
66 // Falls back to the bare content for rows stored before that existed.
67 body.innerHTML = h.body_html || h.content || ''; // sanitized server-side on ingest
68 card.appendChild(body);
69 if (h.note_url) {
70 var a = el('a', 'g-link', T.open || 'open');
71 a.href = h.note_url; a.target = '_blank'; a.rel = 'noopener';
72 card.appendChild(a);
73 }
74 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 function renderPending() {
126 var list = document.getElementById('pending-list');
127 list.textContent = '';
128 var offers = S.offers || [];
129 offers.forEach(function (o) { list.appendChild(offerCard(o)); });
130 show('pending-section', offers.length > 0);
131 }
132
133 // ── 4. Accepted wards: one panel per child ─────────────────────────────
134 // A guardian thinks per child, not per function, so everything about one
135 // child sits behind that child's row: the gated settings, the follow requests
136 // waiting on them, their calls for help, their recent posts. The row itself
137 // carries counts, so nothing that needs an answer hides inside a closed
138 // panel.
139 var openPanels = {}; // ward uri -> open, so a refresh does not close it
140 // The follow requests and the wards' posts arrive from their own endpoints
141 // and are grouped into the panels by ward, so they are cached here rather
142 // than rendered into a section of their own.
143 var FEED = [], FOLLOWS = [];
144
145 function sectionInto(panel, title, items, empty, build) {
146 var h = el('div', 'g-panel-sec');
147 h.appendChild(el('h3', null, title));
148 if (!items.length) h.appendChild(el('p', 'g-empty small', empty));
149 else items.forEach(function (it) { h.appendChild(build(it)); });
150 panel.appendChild(h);
151 return h;
152 }
153
154 function embedsButton(w) {
155 // Gated feature: external (non-fediverse) embeds. Off by default for a
156 // ward; only a guardian can open it, and the gate is enforced server-side
157 // when the feed is built, so this button is the only thing that moves it.
158 // Shown for EVERY ward, including one on another server. There the value is
159 // unknown (it lives on the ward's server), but proposing is exactly as
160 // possible: the proposal travels, the ward's server tallies the guardians
161 // and enforces. A guardian next door must not have more say than one far
162 // away.
163 var known = w.embeds === true || w.embeds === false;
164 var emb = el('button', 'quiet small',
165 (known ? (w.embeds ? T.embeds_on : T.embeds_off) : T.embeds_propose) || 'Link previews');
166 emb.addEventListener('click', function () {
167 emb.disabled = true;
168 fetch('/guardian/wards/embeds', {
169 method: 'POST', headers: { 'Content-Type': 'application/json' },
170 body: JSON.stringify({ uri: w.other_uri, allow: known ? !w.embeds : true }),
171 }).then(function (r) { return r.json(); })
172 .then(function (j) {
173 // Not settled yet: the other guardians still have to answer.
174 if (j && j.state === 'open') {
175 emb.textContent = (T.embeds_waiting || 'waiting for the other guardians');
176 emb.disabled = true;
177 return;
178 }
179 refresh();
180 })
181 .catch(function () { emb.disabled = false; });
182 });
183 return emb;
184 }
185
186 function wardPanel(w) {
187 var uri = w.other_uri;
188 var panel = el('div', 'g-panel');
189 panel.hidden = !openPanels[uri];
190
191 var set = el('div', 'g-panel-sec');
192 set.appendChild(el('h3', null, T.settings_title || 'Settings'));
193 var setRow = el('div', 'row');
194 setRow.appendChild(embedsButton(w));
195 set.appendChild(setRow);
196 panel.appendChild(set);
197
198 sectionInto(panel, T.panel_follow || 'Follow requests',
199 FOLLOWS.filter(function (f) { return f.wardUri === uri; }),
200 T.panel_follow_empty || '', followCard);
201
202 sectionInto(panel, T.panel_help || 'Calls for help',
203 (S.help || []).filter(function (h) { return h.actor_uri === uri; }),
204 T.panel_help_empty || '', helpCard);
205
206 sectionInto(panel, T.panel_posts || 'Recent posts',
207 FEED.filter(function (p) { return p.authorUri === uri; }),
208 T.panel_posts_empty || '', feedCard);
209
210 var act = el('div', 'g-panel-sec');
211 act.appendChild(el('h3', null, T.panel_actions || 'Actions'));
212 var actRow = el('div', 'row');
213 var wave = el('button', 'small', T.wave || '👋 Wave');
214 wave.addEventListener('click', function () { sendWave(uri, wave); });
215 actRow.appendChild(wave);
216 var rel = el('button', 'quiet small', T.release);
217 // Releasing a ward is heavy and hard to undo (coming back needs a fresh
218 // offer the ward accepts), so it asks first and spells out what changes.
219 rel.addEventListener('click', function () {
220 var who = handleOf(uri, w.other_handle);
221 var msg = (T.release_confirm || 'Release {who}?').replace('{who}', who);
222 if (window.confirm(msg)) remove(uri, rel);
223 });
224 actRow.appendChild(rel);
225 act.appendChild(actRow);
226 panel.appendChild(act);
227 return panel;
228 }
229
230 function renderWards() {
231 var list = document.getElementById('wards-list');
232 list.textContent = '';
233 var wards = S.wards || [];
234 wards.forEach(function (w) {
235 var uri = w.other_uri;
236 var card = el('div', 'g-card ward');
237 var row = el('div', 'row');
238 row.appendChild(el('span', 'who grow', handleOf(uri, w.other_handle)));
239 // Counts on the row: whatever is waiting must be visible with the panel shut.
240 var nHelp = (S.help || []).filter(function (h) { return h.actor_uri === uri; }).length;
241 var nFollow = FOLLOWS.filter(function (f) { return f.wardUri === uri; }).length;
242 if (nHelp) row.appendChild(el('span', 'tag help', '🛟 ' + nHelp));
243 if (nFollow) row.appendChild(el('span', 'tag co', nFollow + ' ' + (nFollow === 1 ? (T.badge_follow_one || '') : (T.badge_follow || ''))));
244 row.appendChild(el('span', 'tag ok', T.active));
245 var toggle = el('button', 'quiet small', openPanels[uri] ? T.panel_close : T.panel_open);
246 row.appendChild(toggle);
247 card.appendChild(row);
248 var panel = wardPanel(w);
249 card.appendChild(panel);
250 toggle.addEventListener('click', function () {
251 openPanels[uri] = !openPanels[uri];
252 panel.hidden = !openPanels[uri];
253 toggle.textContent = openPanels[uri] ? T.panel_close : T.panel_open;
254 });
255 list.appendChild(card);
256 });
257 show('wards-empty', wards.length === 0);
258 }
259
260 function sendWave(uri, btn) {
261 btn.disabled = true;
262 fetch('/guardian/api/wave', {
263 method: 'POST', headers: { 'Content-Type': 'application/json' },
264 body: JSON.stringify({ ward: uri, site: S.site }),
265 }).then(function (r) { return r.json(); })
266 .then(function (j) { btn.disabled = false; btn.textContent = (j && j.ok) ? (T.waved || '👋 sent') : (T.wave || '👋 Wave'); })
267 .catch(function () { btn.disabled = false; });
268 }
269
270 function remove(uri, btn) {
271 btn.disabled = true;
272 fetch('/guardian/wards/remove', {
273 method: 'POST', headers: { 'Content-Type': 'application/json' },
274 body: JSON.stringify({ uri: uri, site: S.site }),
275 }).then(refresh);
276 }
277
278 function renderAll() { renderHelp(); renderPending(); renderWards(); }
279
280 // ── 0. Wards' corner: read-only feed of your wards' posts ───────────────
281 // Lives inside each child's panel now, so the fetches only fill a cache and
282 // ask the ward list to redraw. A guardian watches, it does not publish.
283 function feedCard(p) {
284 var card = el('div', 'g-card feed');
285 var head = el('div', 'row');
286 head.appendChild(el('span', 'who grow', p.author));
287 if (p.published) head.appendChild(el('span', 'g-when', when(p, p.published)));
288 card.appendChild(head);
289 var body = el('div', 'feed-body');
290 if (p.cw) {
291 var d = document.createElement('details');
292 var sum = document.createElement('summary'); sum.textContent = p.cw; d.appendChild(sum);
293 var inner = el('div'); inner.innerHTML = p.content || ''; d.appendChild(inner);
294 body.appendChild(d);
295 } else {
296 body.innerHTML = p.content || ''; // server-sanitized HTML (same as Berichten)
297 }
298 card.appendChild(body);
299 return card;
300 }
301
302 function loadFeed() {
303 return fetch('/guardian/api/feed?site=' + encodeURIComponent(S.site))
304 .then(function (r) { return r.json(); })
305 .then(function (f) { if (f && !f.error) { FEED = f.items || []; renderWards(); } })
306 .catch(function () { /* panels just show "nothing yet" */ });
307 }
308
309 // ── 0b. Follow requests on your wards (§5.3) ────────────────────────────
310 function answerFollow(id, decision, btn) {
311 if (btn) btn.disabled = true;
312 fetch('/guardian/api/follow/' + encodeURIComponent(id), {
313 method: 'POST', headers: { 'Content-Type': 'application/json' },
314 body: JSON.stringify({ decision: decision, site: S.site }),
315 }).then(loadFollowReqs);
316 }
317 function followCard(f) {
318 var card = el('div', 'g-card');
319 var row = el('div', 'row');
320 // Inside the child's own panel the ward name is a given, so only the
321 // person asking is named here.
322 row.appendChild(el('span', 'who grow', f.follower));
323 var ok = el('button', 'small', T.accept || 'Accept');
324 ok.addEventListener('click', function () { answerFollow(f.id, 'approve', ok); });
325 var no = el('button', 'quiet small', T.reject || 'Deny');
326 no.addEventListener('click', function () { answerFollow(f.id, 'reject', no); });
327 row.appendChild(ok); row.appendChild(no);
328 card.appendChild(row);
329 return card;
330 }
331 function loadFollowReqs() {
332 return fetch('/guardian/api/follow-requests?site=' + encodeURIComponent(S.site))
333 .then(function (r) { return r.json(); })
334 .then(function (f) { if (f && !f.error) { FOLLOWS = f.items || []; renderWards(); } })
335 .catch(function () { /* panels just show "none waiting" */ });
336 }
337
338 function refresh() {
339 return fetch('/guardian/api/state?site=' + encodeURIComponent(S.site))
340 .then(function (r) { return r.json(); })
341 .then(function (s) { if (s && !s.error) { S = s; T = s.strings || T; renderAll(); } })
342 .then(loadFeed).then(loadFollowReqs);
343 }
344
345 // ── 2. Adopt ───────────────────────────────────────────────────────────
346 var form = document.getElementById('adopt-form');
347 var input = document.getElementById('adopt-handle');
348 var adoptBtn = document.getElementById('adopt-btn');
349 var msg = document.getElementById('adopt-msg');
350 function setMsg(text, isErr) { msg.hidden = false; msg.className = 'g-msg' + (isErr ? ' err' : ''); msg.textContent = text; }
351
352 form.addEventListener('submit', function (ev) {
353 ev.preventDefault();
354 var handle = input.value.trim();
355 if (!handle) return;
356 adoptBtn.disabled = true;
357 setMsg(T.sending || '…', false);
358 fetch('/guardian/adopt', {
359 method: 'POST', headers: { 'Content-Type': 'application/json' },
360 body: JSON.stringify({ handle: handle, site: S.site }),
361 }).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
362 .then(function (res) {
363 adoptBtn.disabled = false;
364 if (res.ok) {
365 input.value = '';
366 // Always refresh: the offer is recorded even if delivery is still
367 // in flight. Show it under "Verzonden aanvragen".
368 setMsg(res.j.delivered === false ? T.sent_retry : T.sent, false);
369 refresh();
370 } else {
371 setMsg((res.j.error === 'not_found' ? T.not_found : T.failed) , true);
372 }
373 })
374 .catch(function () { adoptBtn.disabled = false; setMsg(T.network, true); });
375 });
376
377 // ── Site picker ────────────────────────────────────────────────────────
378 var picker = document.getElementById('site-picker');
379 if (picker) picker.addEventListener('change', function () {
380 location.href = '/guardian?site=' + encodeURIComponent(picker.value);
381 });
382
383 // ── 5. Push ────────────────────────────────────────────────────────────
384 var toggle = document.getElementById('push-toggle');
385 var pmsg = document.getElementById('push-msg');
386 function pushState() {
387 if (!('serviceWorker' in navigator) || !('PushManager' in window)) { toggle.disabled = true; return; }
388 navigator.serviceWorker.register('/sw.js').catch(function () {});
389 navigator.serviceWorker.ready
390 .then(function (reg) { return reg.pushManager.getSubscription(); })
391 .then(function (sub) {
392 toggle.textContent = sub ? toggle.dataset.onLabel : toggle.dataset.offLabel;
393 toggle.dataset.subscribed = sub ? '1' : '';
394 toggle.classList.toggle('is-on', !!sub);
395 });
396 }
397 function urlB64(base64) {
398 var pad = '='.repeat((4 - (base64.length % 4)) % 4);
399 var b = (base64 + pad).replace(/-/g, '+').replace(/_/g, '/');
400 var raw = atob(b); var arr = new Uint8Array(raw.length);
401 for (var i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
402 return arr;
403 }
404 toggle.addEventListener('click', function () {
405 pmsg.hidden = true;
406 navigator.serviceWorker.ready.then(function (reg) {
407 if (toggle.dataset.subscribed) {
408 reg.pushManager.getSubscription().then(function (sub) {
409 if (!sub) return;
410 fetch('/push/unsubscribe', {
411 method: 'POST', headers: { 'Content-Type': 'application/json' },
412 body: JSON.stringify({ endpoint: sub.endpoint }),
413 }).then(function () { return sub.unsubscribe(); }).then(pushState);
414 });
415 return;
416 }
417 fetch('/push/vapid').then(function (r) { return r.json(); }).then(function (v) {
418 if (!v.publicKey) throw new Error('no key');
419 return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlB64(v.publicKey) });
420 }).then(function (sub) {
421 return fetch('/push/subscribe', {
422 method: 'POST', headers: { 'Content-Type': 'application/json' },
423 body: JSON.stringify({
424 subscription: sub.toJSON(),
425 alerts: { help: 1, guardian: 1, dm: 1, follow: 0, reply: 0, like: 0, boost: 0 },
426 uaLabel: 'Guardian PWA',
427 }),
428 });
429 }).then(pushState).catch(function (e) {
430 pmsg.hidden = false; pmsg.className = 'g-msg err';
431 pmsg.textContent = (T.push_unavailable || 'Push unavailable') + ': ' + e.message;
432 });
433 });
434 });
435
436 renderAll(); pushState(); loadFeed(); loadFollowReqs();
437 setInterval(refresh, 45000); // live-ish while open
438 } catch (e) {
439 fatal((e && e.message) || String(e));
440 }
441})();
Note: See TracBrowser for help on using the repository browser.