source: Klonkt/src/assets/js/guardian.js@ 4f15f67

main
Last change on this file since 4f15f67 was fcd6964, checked in by Robin <roboburr@…>, 7 weeks ago

Guardianship: ward ziet z'n guardians, PWA-assets no-cache, handle-fix

De handshake commit werkte al aan beide kanten (geverifieerd live: sound-fabrics
ziet beta als ward, beta weet dat sound-fabrics guardian is), maar het TONEN
ontbrak op drie plekken. Dit lost de Klonkt-kant op.

  1. Ward-kant: in Berichten ziet een ward nu wie z'n guardians zijn (committed relaties, FEP-633c §2). Die view bestond nog niet.
  2. other_handle bewaarde per ongeluk de FEP-escalatiehandle (candidate/inbox) i.p.v. de @handle, dus overal waar we het toonden kwam een inbox-URL uit. Nu de echte @handle uit de offer; display self-healt oude rijen door @user@host uit de actor-URI af te leiden als de opgeslagen handle geen @ is.
  3. Guardian PWA-assets via /guardian/app.js|css met Cache-Control: no-cache, dus een update wordt nooit meer gemaskeerd door de 1-jaar /assets-cache of een vastgelopen install (dat was de "niks werkt na deploy"-bug). Client zit nu in try/catch met een zichtbare foutbanner i.p.v. stil te hangen.

Changed files:
src/services/guardianship/handshake.js

  • applyCommitLocally schrijft de @handle (offer.ward/candidate_handle) naar other_handle

src/routes/posts.js

  • messages-route levert myGuardians (committed guardians met afgeleide @handle)

src/views/pages/messages.ejs

  • "Jouw guardians"-sectie voor de ward

src/services/i18n.js

  • msg.guardians_label (nl/en/de)

src/routes/guardian.js

  • /guardian/app.js|css no-cache; ASSET_V-mtime-machinerie weg

src/assets/js/guardian.js

  • hele client in try/catch met zichtbare foutbanner; handleOf vertrouwt alleen echte @handles

src/views/pages/guardian.ejs

  • assets via no-cache routes i.p.v. /assets?v=

test/guardianship.test.js

  • assert dat other_handle de @handle is na commit

remarks: getest met npm test (6/6). sound-fabrics (stable-lane, handmatig) bewust
niet aangeraakt; wacht op akkoord.

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

  • Property mode set to 100644
File size: 10.6 KB
RevLine 
[c26cc18]1/* Guardian PWA client (FEP-633c): renders the dashboard, adopts wards, and
2 manages the guardian push channel. No framework, no inline scripts (CSP).
3 All user-facing text comes from state.strings (server i18n). */
[318d0c2]4(function () {
5 'use strict';
[fcd6964]6 // A crash here used to fail silently (buttons just do nothing). Surface it on
7 // the page AND the console so the cause is visible instead of "everything hangs".
8 function fatal(msg) {
9 try {
10 var b = document.getElementById('g-fatal') || document.createElement('div');
11 b.id = 'g-fatal'; b.className = 'g-msg err';
12 b.style.cssText = 'display:block;margin:12px 0;padding:10px 14px';
13 b.textContent = 'Guardian: ' + msg;
14 var root = document.querySelector('main') || document.body;
15 if (!b.parentNode && root) root.insertBefore(b, root.firstChild);
16 } catch (e) { /* last resort */ }
17 try { console.error('[guardian]', msg); } catch (e) { /* no console */ }
18 }
19 try {
[c26cc18]20 var S = JSON.parse(document.getElementById('guardian-state').textContent || '{}');
21 var T = S.strings || {};
[318d0c2]22
23 function el(tag, cls, text) {
24 var n = document.createElement(tag);
25 if (cls) n.className = cls;
26 if (text != null) n.textContent = text;
27 return n;
28 }
29 function handleOf(uri, cached) {
[fcd6964]30 if (cached && cached.charAt(0) === '@') return cached; // trust only real @handles
[318d0c2]31 try { var u = new URL(uri); return '@' + u.pathname.split('/').filter(Boolean).pop() + '@' + u.host; }
32 catch (e) { return uri; }
33 }
[c26cc18]34 function when(s) { return String(s || '').slice(0, 16).replace('T', ' '); }
35 function show(id, on) { document.getElementById(id).hidden = !on; }
[318d0c2]36
[c26cc18]37 // ── 1. Help requests ───────────────────────────────────────────────────
[318d0c2]38 function renderHelp() {
39 var list = document.getElementById('help-list');
40 list.textContent = '';
[c26cc18]41 var help = S.help || [];
42 help.forEach(function (h) {
[318d0c2]43 var card = el('div', 'g-card help');
44 var row = el('div', 'row');
[c26cc18]45 row.appendChild(el('span', 'who grow', h.actor_name || handleOf(h.actor_uri, h.actor_handle)));
46 row.appendChild(el('span', 'when', when(h.published || h.created_at)));
[318d0c2]47 card.appendChild(row);
48 var body = el('div', 'body');
[c26cc18]49 body.innerHTML = h.content || ''; // sanitized server-side on ingest
[318d0c2]50 card.appendChild(body);
51 if (h.note_url) {
[c26cc18]52 var a = el('a', 'g-link', T.open || 'open');
53 a.href = h.note_url; a.target = '_blank'; a.rel = 'noopener';
54 card.appendChild(a);
[318d0c2]55 }
56 list.appendChild(card);
57 });
[c26cc18]58 var badge = document.getElementById('help-count');
59 badge.textContent = help.length; badge.hidden = help.length === 0;
60 show('help-empty', help.length === 0);
[318d0c2]61 }
62
[780a7c6]63 // ── 3. Offers I am a party to (sent, or a co-guardianship to co-approve) ─
64 function answer(offerId, decision, btn) {
65 if (btn) btn.disabled = true;
66 fetch('/guardian/offer', {
67 method: 'POST', headers: { 'Content-Type': 'application/json' },
68 body: JSON.stringify({ offer: offerId, answer: decision, site: S.site }),
69 }).then(refresh);
70 }
71 function offerCard(o) {
72 var card = el('div', 'g-card');
73 var row = el('div', 'row');
74 var subject = o['shaer:iAmCandidate']
75 ? handleOf(o['shaer:ward'], o['shaer:wardHandle']) // my sent offer: about the ward
76 : handleOf(o['shaer:candidate'], o['shaer:candidateHandle']); // co-guard: who wants in
77 row.appendChild(el('span', 'who grow', subject));
78 if (o['shaer:iAmCandidate']) {
79 // My own offer, waiting for the others to accept.
80 row.appendChild(el('span', 'tag wait', T.pending));
81 var rt = el('button', 'quiet small', T.retract);
82 rt.addEventListener('click', function () { answer(o.id, 'reject', rt); });
83 row.appendChild(rt);
84 } else if (o['shaer:needsMyAccept']) {
85 // A co-guardianship offer for a ward I already guard: my call.
86 row.appendChild(el('span', 'tag co', T.coguard));
87 var ac = el('button', 'small', T.accept);
88 ac.addEventListener('click', function () { answer(o.id, 'accept', ac); });
89 var rj = el('button', 'quiet small', T.reject);
90 rj.addEventListener('click', function () { answer(o.id, 'reject', rj); });
91 row.appendChild(ac); row.appendChild(rj);
92 } else {
93 row.appendChild(el('span', 'tag wait', T.awaiting_others));
94 }
95 card.appendChild(row);
96 return card;
97 }
[c26cc18]98 function renderPending() {
99 var list = document.getElementById('pending-list');
100 list.textContent = '';
[780a7c6]101 var offers = S.offers || [];
102 offers.forEach(function (o) { list.appendChild(offerCard(o)); });
103 show('pending-section', offers.length > 0);
[318d0c2]104 }
[c26cc18]105
106 // ── 4. Accepted wards ──────────────────────────────────────────────────
[318d0c2]107 function renderWards() {
[c26cc18]108 var list = document.getElementById('wards-list');
109 list.textContent = '';
110 var wards = S.wards || [];
111 wards.forEach(function (w) {
112 var card = el('div', 'g-card');
113 var row = el('div', 'row');
114 row.appendChild(el('span', 'who grow', handleOf(w.other_uri, w.other_handle)));
115 row.appendChild(el('span', 'tag ok', T.active));
116 var btn = el('button', 'quiet small', T.release);
117 btn.addEventListener('click', function () { remove(w.other_uri, btn); });
118 row.appendChild(btn);
119 card.appendChild(row);
120 list.appendChild(card);
121 });
122 show('wards-empty', wards.length === 0);
123 }
124
125 function remove(uri, btn) {
126 btn.disabled = true;
127 fetch('/guardian/wards/remove', {
128 method: 'POST', headers: { 'Content-Type': 'application/json' },
129 body: JSON.stringify({ uri: uri, site: S.site }),
130 }).then(refresh);
[318d0c2]131 }
132
[c26cc18]133 function renderAll() { renderHelp(); renderPending(); renderWards(); }
134
[318d0c2]135 function refresh() {
[c26cc18]136 return fetch('/guardian/api/state?site=' + encodeURIComponent(S.site))
[318d0c2]137 .then(function (r) { return r.json(); })
[c26cc18]138 .then(function (s) { if (s && !s.error) { S = s; T = s.strings || T; renderAll(); } });
[318d0c2]139 }
140
[c26cc18]141 // ── 2. Adopt ───────────────────────────────────────────────────────────
142 var form = document.getElementById('adopt-form');
143 var input = document.getElementById('adopt-handle');
144 var adoptBtn = document.getElementById('adopt-btn');
145 var msg = document.getElementById('adopt-msg');
146 function setMsg(text, isErr) { msg.hidden = false; msg.className = 'g-msg' + (isErr ? ' err' : ''); msg.textContent = text; }
147
148 form.addEventListener('submit', function (ev) {
[318d0c2]149 ev.preventDefault();
150 var handle = input.value.trim();
151 if (!handle) return;
[c26cc18]152 adoptBtn.disabled = true;
153 setMsg(T.sending || '…', false);
[318d0c2]154 fetch('/guardian/adopt', {
155 method: 'POST', headers: { 'Content-Type': 'application/json' },
[c26cc18]156 body: JSON.stringify({ handle: handle, site: S.site }),
[318d0c2]157 }).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
158 .then(function (res) {
[c26cc18]159 adoptBtn.disabled = false;
160 if (res.ok) {
161 input.value = '';
162 // Always refresh: the offer is recorded even if delivery is still
163 // in flight. Show it under "Verzonden aanvragen".
164 setMsg(res.j.delivered === false ? T.sent_retry : T.sent, false);
165 refresh();
166 } else {
167 setMsg((res.j.error === 'not_found' ? T.not_found : T.failed) , true);
168 }
[318d0c2]169 })
[c26cc18]170 .catch(function () { adoptBtn.disabled = false; setMsg(T.network, true); });
[318d0c2]171 });
172
173 // ── Site picker ────────────────────────────────────────────────────────
174 var picker = document.getElementById('site-picker');
175 if (picker) picker.addEventListener('change', function () {
176 location.href = '/guardian?site=' + encodeURIComponent(picker.value);
177 });
178
[c26cc18]179 // ── 5. Push ────────────────────────────────────────────────────────────
[318d0c2]180 var toggle = document.getElementById('push-toggle');
181 var pmsg = document.getElementById('push-msg');
182 function pushState() {
183 if (!('serviceWorker' in navigator) || !('PushManager' in window)) { toggle.disabled = true; return; }
184 navigator.serviceWorker.register('/sw.js').catch(function () {});
185 navigator.serviceWorker.ready
186 .then(function (reg) { return reg.pushManager.getSubscription(); })
187 .then(function (sub) {
188 toggle.textContent = sub ? toggle.dataset.onLabel : toggle.dataset.offLabel;
189 toggle.dataset.subscribed = sub ? '1' : '';
[c26cc18]190 toggle.classList.toggle('is-on', !!sub);
[318d0c2]191 });
192 }
193 function urlB64(base64) {
194 var pad = '='.repeat((4 - (base64.length % 4)) % 4);
195 var b = (base64 + pad).replace(/-/g, '+').replace(/_/g, '/');
196 var raw = atob(b); var arr = new Uint8Array(raw.length);
197 for (var i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
198 return arr;
199 }
200 toggle.addEventListener('click', function () {
201 pmsg.hidden = true;
202 navigator.serviceWorker.ready.then(function (reg) {
203 if (toggle.dataset.subscribed) {
204 reg.pushManager.getSubscription().then(function (sub) {
205 if (!sub) return;
206 fetch('/push/unsubscribe', {
207 method: 'POST', headers: { 'Content-Type': 'application/json' },
208 body: JSON.stringify({ endpoint: sub.endpoint }),
209 }).then(function () { return sub.unsubscribe(); }).then(pushState);
210 });
211 return;
212 }
213 fetch('/push/vapid').then(function (r) { return r.json(); }).then(function (v) {
214 if (!v.publicKey) throw new Error('no key');
215 return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlB64(v.publicKey) });
216 }).then(function (sub) {
217 return fetch('/push/subscribe', {
218 method: 'POST', headers: { 'Content-Type': 'application/json' },
219 body: JSON.stringify({
220 subscription: sub.toJSON(),
221 alerts: { help: 1, guardian: 1, dm: 1, follow: 0, reply: 0, like: 0, boost: 0 },
222 uaLabel: 'Guardian PWA',
223 }),
224 });
225 }).then(pushState).catch(function (e) {
[c26cc18]226 pmsg.hidden = false; pmsg.className = 'g-msg err';
227 pmsg.textContent = (T.push_unavailable || 'Push unavailable') + ': ' + e.message;
[318d0c2]228 });
229 });
230 });
231
[c26cc18]232 renderAll(); pushState();
233 setInterval(refresh, 45000); // live-ish while open
[fcd6964]234 } catch (e) {
235 fatal((e && e.message) || String(e));
236 }
[318d0c2]237})();
Note: See TracBrowser for help on using the repository browser.