source: Klonkt/src/assets/js/guardian.js@ 65abc85

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

Gated settings federeren: guardians beslissen samen, ook van een andere server

Ik had de knop alleen voor het co-located geval gebouwd, en dat is precies het
uitzonderingsgeval. In de echte opstelling staat de ward op de ene server en zijn
drie guardians op twee andere: er was dus nergens een knop. Dat botst met onze
eigen regel dat co-locatie een optimalisatie is en nooit de aanname.

Nu volgens FEP-633c 5.6 (deze week aan de spec toegevoegd): een guardian stelt
een wijziging voor met een Offer van een shaer:GatedSetting aan de server van de
WARD; de andere guardians antwoorden met Accept/Reject; de server van de ward
telt en handhaaft, want die serveert de feed. Co-locatie neemt dezelfde weg: ook
daar wordt voorgesteld en geteld, anders zou een guardian naast de deur meer te
zeggen hebben dan een op afstand.

De tally is een 3.5-beslissing en staat als pure functie apart: gesnapshotte set,
strikte meerderheid, venster van een dag. Omkeerbaar, dus race naar de drempel in
BEIDE richtingen (settelt ook zodra een meerderheid onhaalbaar is) en faalt dicht
op de deadline. Een Reject is een stem voor de andere waarde, geen schouderophalen.

New file:
src/services/guardianship/gated.js

  • tallyGatedSetting (puur), thresholdFor, featureColumn (onbekende features geweigerd i.p.v. geraden), recordGatedVote, en de Offer-vorm

test/gated-settings.test.js

  • 9 tests: drempel, vroeg settelen in beide richtingen, dicht op de deadline, vreemden tellen niet mee, geen guardians = niets toegekend, van gedachten veranderen vervangt je stem, en een onbekende feature raakt geen kolom

Changed files:
src/config/database.js

  • ap_gated_offers + ap_gated_votes

src/services/guardianship/handshake.js

  • inbox: Offer(shaer:GatedSetting) en Accept/Reject erop, met de stem van de voorsteller meegeteld (one-step-clausule)

src/services/guardianship/index.js

  • gated geexporteerd

src/routes/guardian.js

  • de knop stuurt een voorstel, lokaal en remote langs dezelfde weg

src/assets/js/guardian.js

  • knop bij ELKE ward, ook remote; toont 'wacht op de andere guardians'

src/services/i18n.js

  • embeds_propose / embeds_waiting in nl, en, de

remarks: 228 tests groen (was 219).

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

  • Property mode set to 100644
File size: 16.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 function when(s) { return String(s || '').slice(0, 16).replace('T', ' '); }
35 function show(id, on) { document.getElementById(id).hidden = !on; }
36
37 // ── 1. Help requests ───────────────────────────────────────────────────
38 function renderHelp() {
39 var list = document.getElementById('help-list');
40 list.textContent = '';
41 var help = S.help || [];
42 help.forEach(function (h) {
43 var card = el('div', 'g-card help');
44 var row = el('div', 'row');
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)));
47 card.appendChild(row);
48 var body = el('div', 'body');
49 body.innerHTML = h.content || ''; // sanitized server-side on ingest
50 card.appendChild(body);
51 if (h.note_url) {
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);
55 }
56 list.appendChild(card);
57 });
58 var badge = document.getElementById('help-count');
59 badge.textContent = help.length; badge.hidden = help.length === 0;
60 show('help-empty', help.length === 0);
61 }
62
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 }
98 function renderPending() {
99 var list = document.getElementById('pending-list');
100 list.textContent = '';
101 var offers = S.offers || [];
102 offers.forEach(function (o) { list.appendChild(offerCard(o)); });
103 show('pending-section', offers.length > 0);
104 }
105
106 // ── 4. Accepted wards ──────────────────────────────────────────────────
107 function renderWards() {
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 wave = el('button', 'small', T.wave || '👋 Wave');
117 wave.addEventListener('click', function () { sendWave(w.other_uri, wave); });
118 row.appendChild(wave);
119 // Gated feature: external (non-fediverse) embeds. Off by default for a
120 // ward; only a guardian can open it, and the gate is enforced server-side
121 // when the feed is built, so this button is the only thing that moves it.
122 // Shown for EVERY ward, including one on another server. There the value
123 // is unknown (it lives on the ward's server), but proposing is exactly as
124 // possible: the proposal travels, the ward's server tallies the guardians
125 // and enforces. A guardian next door must not have more say than one far
126 // away.
127 var known = w.embeds === true || w.embeds === false;
128 var emb = el('button', 'quiet small',
129 (known ? (w.embeds ? T.embeds_on : T.embeds_off) : T.embeds_propose) || 'Link previews');
130 emb.addEventListener('click', function () {
131 emb.disabled = true;
132 fetch('/guardian/wards/embeds', {
133 method: 'POST', headers: { 'Content-Type': 'application/json' },
134 body: JSON.stringify({ uri: w.other_uri, allow: known ? !w.embeds : true }),
135 }).then(function (r) { return r.json(); })
136 .then(function (j) {
137 // Not settled yet: the other guardians still have to answer.
138 if (j && j.state === 'open') {
139 emb.textContent = (T.embeds_waiting || 'waiting for the other guardians');
140 emb.disabled = true;
141 return;
142 }
143 refresh();
144 })
145 .catch(function () { emb.disabled = false; });
146 });
147 row.appendChild(emb);
148 var btn = el('button', 'quiet small', T.release);
149 // Releasing a ward is heavy and hard to undo (coming back needs a fresh
150 // offer the ward accepts), so it asks first and spells out what changes.
151 btn.addEventListener('click', function () {
152 var who = handleOf(w.other_uri, w.other_handle);
153 var msg = (T.release_confirm || 'Release {who}?').replace('{who}', who);
154 if (window.confirm(msg)) remove(w.other_uri, btn);
155 });
156 row.appendChild(btn);
157 card.appendChild(row);
158 list.appendChild(card);
159 });
160 show('wards-empty', wards.length === 0);
161 }
162
163 function sendWave(uri, btn) {
164 btn.disabled = true;
165 fetch('/guardian/api/wave', {
166 method: 'POST', headers: { 'Content-Type': 'application/json' },
167 body: JSON.stringify({ ward: uri, site: S.site }),
168 }).then(function (r) { return r.json(); })
169 .then(function (j) { btn.disabled = false; btn.textContent = (j && j.ok) ? (T.waved || '👋 sent') : (T.wave || '👋 Wave'); })
170 .catch(function () { btn.disabled = false; });
171 }
172
173 function remove(uri, btn) {
174 btn.disabled = true;
175 fetch('/guardian/wards/remove', {
176 method: 'POST', headers: { 'Content-Type': 'application/json' },
177 body: JSON.stringify({ uri: uri, site: S.site }),
178 }).then(refresh);
179 }
180
181 function renderAll() { renderHelp(); renderPending(); renderWards(); }
182
183 // ── 0. Wards' corner: read-only feed of your wards' posts ───────────────
184 function renderFeed(items) {
185 var list = document.getElementById('feed-list');
186 list.textContent = '';
187 (items || []).forEach(function (p) {
188 var card = el('div', 'g-card feed');
189 var head = el('div', 'row');
190 head.appendChild(el('span', 'who grow', p.author));
191 if (p.published) head.appendChild(el('span', 'g-when', when(p.published)));
192 card.appendChild(head);
193 var body = el('div', 'feed-body');
194 if (p.cw) {
195 var d = document.createElement('details');
196 var sum = document.createElement('summary'); sum.textContent = p.cw; d.appendChild(sum);
197 var inner = el('div'); inner.innerHTML = p.content || ''; d.appendChild(inner);
198 body.appendChild(d);
199 } else {
200 body.innerHTML = p.content || ''; // server-sanitized HTML (same as Berichten)
201 }
202 card.appendChild(body);
203 list.appendChild(card);
204 });
205 show('feed-section', (items || []).length > 0);
206 }
207
208 function loadFeed() {
209 return fetch('/guardian/api/feed?site=' + encodeURIComponent(S.site))
210 .then(function (r) { return r.json(); })
211 .then(function (f) { if (f && !f.error) renderFeed(f.items); })
212 .catch(function () { /* corner just stays hidden */ });
213 }
214
215 // ── 0b. Follow requests on your wards (§5.3) ────────────────────────────
216 function answerFollow(id, decision, btn) {
217 if (btn) btn.disabled = true;
218 fetch('/guardian/api/follow/' + encodeURIComponent(id), {
219 method: 'POST', headers: { 'Content-Type': 'application/json' },
220 body: JSON.stringify({ decision: decision, site: S.site }),
221 }).then(loadFollowReqs);
222 }
223 function renderFollowReqs(items) {
224 var list = document.getElementById('follow-list');
225 list.textContent = '';
226 (items || []).forEach(function (f) {
227 var card = el('div', 'g-card');
228 var row = el('div', 'row');
229 row.appendChild(el('span', 'who grow', f.follower + ' → ' + f.ward));
230 var ok = el('button', 'small', T.accept || 'Accept');
231 ok.addEventListener('click', function () { answerFollow(f.id, 'approve', ok); });
232 var no = el('button', 'quiet small', T.reject || 'Deny');
233 no.addEventListener('click', function () { answerFollow(f.id, 'reject', no); });
234 row.appendChild(ok); row.appendChild(no);
235 card.appendChild(row);
236 list.appendChild(card);
237 });
238 show('follow-section', (items || []).length > 0);
239 }
240 function loadFollowReqs() {
241 return fetch('/guardian/api/follow-requests?site=' + encodeURIComponent(S.site))
242 .then(function (r) { return r.json(); })
243 .then(function (f) { if (f && !f.error) renderFollowReqs(f.items); })
244 .catch(function () { /* stays hidden */ });
245 }
246
247 function refresh() {
248 return fetch('/guardian/api/state?site=' + encodeURIComponent(S.site))
249 .then(function (r) { return r.json(); })
250 .then(function (s) { if (s && !s.error) { S = s; T = s.strings || T; renderAll(); } })
251 .then(loadFeed).then(loadFollowReqs);
252 }
253
254 // ── 2. Adopt ───────────────────────────────────────────────────────────
255 var form = document.getElementById('adopt-form');
256 var input = document.getElementById('adopt-handle');
257 var adoptBtn = document.getElementById('adopt-btn');
258 var msg = document.getElementById('adopt-msg');
259 function setMsg(text, isErr) { msg.hidden = false; msg.className = 'g-msg' + (isErr ? ' err' : ''); msg.textContent = text; }
260
261 form.addEventListener('submit', function (ev) {
262 ev.preventDefault();
263 var handle = input.value.trim();
264 if (!handle) return;
265 adoptBtn.disabled = true;
266 setMsg(T.sending || '…', false);
267 fetch('/guardian/adopt', {
268 method: 'POST', headers: { 'Content-Type': 'application/json' },
269 body: JSON.stringify({ handle: handle, site: S.site }),
270 }).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
271 .then(function (res) {
272 adoptBtn.disabled = false;
273 if (res.ok) {
274 input.value = '';
275 // Always refresh: the offer is recorded even if delivery is still
276 // in flight. Show it under "Verzonden aanvragen".
277 setMsg(res.j.delivered === false ? T.sent_retry : T.sent, false);
278 refresh();
279 } else {
280 setMsg((res.j.error === 'not_found' ? T.not_found : T.failed) , true);
281 }
282 })
283 .catch(function () { adoptBtn.disabled = false; setMsg(T.network, true); });
284 });
285
286 // ── Site picker ────────────────────────────────────────────────────────
287 var picker = document.getElementById('site-picker');
288 if (picker) picker.addEventListener('change', function () {
289 location.href = '/guardian?site=' + encodeURIComponent(picker.value);
290 });
291
292 // ── 5. Push ────────────────────────────────────────────────────────────
293 var toggle = document.getElementById('push-toggle');
294 var pmsg = document.getElementById('push-msg');
295 function pushState() {
296 if (!('serviceWorker' in navigator) || !('PushManager' in window)) { toggle.disabled = true; return; }
297 navigator.serviceWorker.register('/sw.js').catch(function () {});
298 navigator.serviceWorker.ready
299 .then(function (reg) { return reg.pushManager.getSubscription(); })
300 .then(function (sub) {
301 toggle.textContent = sub ? toggle.dataset.onLabel : toggle.dataset.offLabel;
302 toggle.dataset.subscribed = sub ? '1' : '';
303 toggle.classList.toggle('is-on', !!sub);
304 });
305 }
306 function urlB64(base64) {
307 var pad = '='.repeat((4 - (base64.length % 4)) % 4);
308 var b = (base64 + pad).replace(/-/g, '+').replace(/_/g, '/');
309 var raw = atob(b); var arr = new Uint8Array(raw.length);
310 for (var i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
311 return arr;
312 }
313 toggle.addEventListener('click', function () {
314 pmsg.hidden = true;
315 navigator.serviceWorker.ready.then(function (reg) {
316 if (toggle.dataset.subscribed) {
317 reg.pushManager.getSubscription().then(function (sub) {
318 if (!sub) return;
319 fetch('/push/unsubscribe', {
320 method: 'POST', headers: { 'Content-Type': 'application/json' },
321 body: JSON.stringify({ endpoint: sub.endpoint }),
322 }).then(function () { return sub.unsubscribe(); }).then(pushState);
323 });
324 return;
325 }
326 fetch('/push/vapid').then(function (r) { return r.json(); }).then(function (v) {
327 if (!v.publicKey) throw new Error('no key');
328 return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlB64(v.publicKey) });
329 }).then(function (sub) {
330 return fetch('/push/subscribe', {
331 method: 'POST', headers: { 'Content-Type': 'application/json' },
332 body: JSON.stringify({
333 subscription: sub.toJSON(),
334 alerts: { help: 1, guardian: 1, dm: 1, follow: 0, reply: 0, like: 0, boost: 0 },
335 uaLabel: 'Guardian PWA',
336 }),
337 });
338 }).then(pushState).catch(function (e) {
339 pmsg.hidden = false; pmsg.className = 'g-msg err';
340 pmsg.textContent = (T.push_unavailable || 'Push unavailable') + ': ' + e.message;
341 });
342 });
343 });
344
345 renderAll(); pushState(); loadFeed(); loadFollowReqs();
346 setInterval(refresh, 45000); // live-ish while open
347 } catch (e) {
348 fatal((e && e.message) || String(e));
349 }
350})();
Note: See TracBrowser for help on using the repository browser.