source: Klonkt/src/assets/js/guardian2.js@ 5c373b8

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

Guardian 2: follow-goedkeuring (FEP-633c §5.3)

Een Follow op een ward wordt niet meer automatisch geaccepteerd: hij wacht op
goedkeuring van de guardians. Afgebakend zoals de spec: gating geldt ALLEEN voor
ward-actors (die guardians hebben); een gewone site zonder guardians accepteert
als vanouds, geen gedragswijziging. Een gecommitte guardian die zelf volgt wordt
wel meteen geaccepteerd (Barts regel: die heeft geen gate nodig, en zo werkt het
meekijken). Quorum per ward: 'any' (default, één volstaat), 'all' of 'none'; een
enkele reject weigert.

De guardian ziet de verzoeken in /guardian2 (ward en guardian zitten op dezelfde
familie-Klonkt, dus lokaal leesbaar) en tikt Accept/Deny. Bij goedkeuring stuurt
Klonkt de Accept(Follow) en legt de follower vast, zodat bezorging (ook
followers-only) begint. Cross-instance federatie van de goedkeuring is een latere
verfijning (de daemon heeft het patroon).

Changed files:
src/services/ActivityPubService.js

  • inbound Follow: gate voor ward-actors; acceptGatedFollow/rejectGatedFollow

src/config/database.js

  • ap_pending_follows + ap_pending_follow_approvals

src/services/guardianship/index.js

  • follows-module geexporteerd

src/routes/guardian2.js

  • GET /api/follow-requests, POST /api/follow/:id (guardian-gecheckt)

src/views/pages/guardian2.ejs, src/assets/js/guardian2.js

src/services/i18n.js

  • guardian2 follow_title/follow_sub (nl/en/de)

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

  • de gating-store + quorum-beslissing (any/all), pure en testbaar

test/follow-gating.test.js

  • any/all-quorum en single-reject

remarks: npm test 167/167. v1 /guardian en niet-ward-sites onaangeraakt.

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

  • Property mode set to 100644
File size: 13.5 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('/guardian2/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 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('/guardian2/wards/remove', {
128 method: 'POST', headers: { 'Content-Type': 'application/json' },
129 body: JSON.stringify({ uri: uri, site: S.site }),
130 }).then(refresh);
131 }
132
133 function renderAll() { renderHelp(); renderPending(); renderWards(); }
134
135 // ── 0. Wards' corner: read-only feed of your wards' posts ───────────────
136 function renderFeed(items) {
137 var list = document.getElementById('feed-list');
138 list.textContent = '';
139 (items || []).forEach(function (p) {
140 var card = el('div', 'g-card feed');
141 var head = el('div', 'row');
142 head.appendChild(el('span', 'who grow', p.author));
143 if (p.published) head.appendChild(el('span', 'g-when', when(p.published)));
144 card.appendChild(head);
145 var body = el('div', 'feed-body');
146 if (p.cw) {
147 var d = document.createElement('details');
148 var sum = document.createElement('summary'); sum.textContent = p.cw; d.appendChild(sum);
149 var inner = el('div'); inner.innerHTML = p.content || ''; d.appendChild(inner);
150 body.appendChild(d);
151 } else {
152 body.innerHTML = p.content || ''; // server-sanitized HTML (same as Berichten)
153 }
154 card.appendChild(body);
155 list.appendChild(card);
156 });
157 show('feed-section', (items || []).length > 0);
158 }
159
160 function loadFeed() {
161 return fetch('/guardian2/api/feed?site=' + encodeURIComponent(S.site))
162 .then(function (r) { return r.json(); })
163 .then(function (f) { if (f && !f.error) renderFeed(f.items); })
164 .catch(function () { /* corner just stays hidden */ });
165 }
166
167 // ── 0b. Follow requests on your wards (§5.3) ────────────────────────────
168 function answerFollow(id, decision, btn) {
169 if (btn) btn.disabled = true;
170 fetch('/guardian2/api/follow/' + encodeURIComponent(id), {
171 method: 'POST', headers: { 'Content-Type': 'application/json' },
172 body: JSON.stringify({ decision: decision, site: S.site }),
173 }).then(loadFollowReqs);
174 }
175 function renderFollowReqs(items) {
176 var list = document.getElementById('follow-list');
177 list.textContent = '';
178 (items || []).forEach(function (f) {
179 var card = el('div', 'g-card');
180 var row = el('div', 'row');
181 row.appendChild(el('span', 'who grow', f.follower + ' → @' + f.ward));
182 var ok = el('button', 'small', T.accept || 'Accept');
183 ok.addEventListener('click', function () { answerFollow(f.id, 'approve', ok); });
184 var no = el('button', 'quiet small', T.reject || 'Deny');
185 no.addEventListener('click', function () { answerFollow(f.id, 'reject', no); });
186 row.appendChild(ok); row.appendChild(no);
187 card.appendChild(row);
188 list.appendChild(card);
189 });
190 show('follow-section', (items || []).length > 0);
191 }
192 function loadFollowReqs() {
193 return fetch('/guardian2/api/follow-requests?site=' + encodeURIComponent(S.site))
194 .then(function (r) { return r.json(); })
195 .then(function (f) { if (f && !f.error) renderFollowReqs(f.items); })
196 .catch(function () { /* stays hidden */ });
197 }
198
199 function refresh() {
200 return fetch('/guardian2/api/state?site=' + encodeURIComponent(S.site))
201 .then(function (r) { return r.json(); })
202 .then(function (s) { if (s && !s.error) { S = s; T = s.strings || T; renderAll(); } })
203 .then(loadFeed).then(loadFollowReqs);
204 }
205
206 // ── 2. Adopt ───────────────────────────────────────────────────────────
207 var form = document.getElementById('adopt-form');
208 var input = document.getElementById('adopt-handle');
209 var adoptBtn = document.getElementById('adopt-btn');
210 var msg = document.getElementById('adopt-msg');
211 function setMsg(text, isErr) { msg.hidden = false; msg.className = 'g-msg' + (isErr ? ' err' : ''); msg.textContent = text; }
212
213 form.addEventListener('submit', function (ev) {
214 ev.preventDefault();
215 var handle = input.value.trim();
216 if (!handle) return;
217 adoptBtn.disabled = true;
218 setMsg(T.sending || '…', false);
219 fetch('/guardian2/adopt', {
220 method: 'POST', headers: { 'Content-Type': 'application/json' },
221 body: JSON.stringify({ handle: handle, site: S.site }),
222 }).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
223 .then(function (res) {
224 adoptBtn.disabled = false;
225 if (res.ok) {
226 input.value = '';
227 // Always refresh: the offer is recorded even if delivery is still
228 // in flight. Show it under "Verzonden aanvragen".
229 setMsg(res.j.delivered === false ? T.sent_retry : T.sent, false);
230 refresh();
231 } else {
232 setMsg((res.j.error === 'not_found' ? T.not_found : T.failed) , true);
233 }
234 })
235 .catch(function () { adoptBtn.disabled = false; setMsg(T.network, true); });
236 });
237
238 // ── Site picker ────────────────────────────────────────────────────────
239 var picker = document.getElementById('site-picker');
240 if (picker) picker.addEventListener('change', function () {
241 location.href = '/guardian?site=' + encodeURIComponent(picker.value);
242 });
243
244 // ── 5. Push ────────────────────────────────────────────────────────────
245 var toggle = document.getElementById('push-toggle');
246 var pmsg = document.getElementById('push-msg');
247 function pushState() {
248 if (!('serviceWorker' in navigator) || !('PushManager' in window)) { toggle.disabled = true; return; }
249 navigator.serviceWorker.register('/sw.js').catch(function () {});
250 navigator.serviceWorker.ready
251 .then(function (reg) { return reg.pushManager.getSubscription(); })
252 .then(function (sub) {
253 toggle.textContent = sub ? toggle.dataset.onLabel : toggle.dataset.offLabel;
254 toggle.dataset.subscribed = sub ? '1' : '';
255 toggle.classList.toggle('is-on', !!sub);
256 });
257 }
258 function urlB64(base64) {
259 var pad = '='.repeat((4 - (base64.length % 4)) % 4);
260 var b = (base64 + pad).replace(/-/g, '+').replace(/_/g, '/');
261 var raw = atob(b); var arr = new Uint8Array(raw.length);
262 for (var i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
263 return arr;
264 }
265 toggle.addEventListener('click', function () {
266 pmsg.hidden = true;
267 navigator.serviceWorker.ready.then(function (reg) {
268 if (toggle.dataset.subscribed) {
269 reg.pushManager.getSubscription().then(function (sub) {
270 if (!sub) return;
271 fetch('/push/unsubscribe', {
272 method: 'POST', headers: { 'Content-Type': 'application/json' },
273 body: JSON.stringify({ endpoint: sub.endpoint }),
274 }).then(function () { return sub.unsubscribe(); }).then(pushState);
275 });
276 return;
277 }
278 fetch('/push/vapid').then(function (r) { return r.json(); }).then(function (v) {
279 if (!v.publicKey) throw new Error('no key');
280 return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlB64(v.publicKey) });
281 }).then(function (sub) {
282 return fetch('/push/subscribe', {
283 method: 'POST', headers: { 'Content-Type': 'application/json' },
284 body: JSON.stringify({
285 subscription: sub.toJSON(),
286 alerts: { help: 1, guardian: 1, dm: 1, follow: 0, reply: 0, like: 0, boost: 0 },
287 uaLabel: 'Guardian PWA',
288 }),
289 });
290 }).then(pushState).catch(function (e) {
291 pmsg.hidden = false; pmsg.className = 'g-msg err';
292 pmsg.textContent = (T.push_unavailable || 'Push unavailable') + ': ' + e.message;
293 });
294 });
295 });
296
297 renderAll(); pushState(); loadFeed(); loadFollowReqs();
298 setInterval(refresh, 45000); // live-ish while open
299 } catch (e) {
300 fatal((e && e.message) || String(e));
301 }
302})();
Note: See TracBrowser for help on using the repository browser.