source: Klonkt/src/assets/js/guardian.js@ 2a76184

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

Guardians kunnen linkvoorbeelden aan- en uitzetten per ward

De eerste echte gated feature met een knop erbij. De gate bestond al server-side
(sites.external_embeds, uit voor een ward), maar er was geen enkele manier om
hem te bewegen: een ward zag dus nooit een linkvoorbeeld, en daar was niets aan
te doen behalve in de database duiken. Dat is precies de verkeerde plek voor een
beslissing die volgens het ontwerp bij de guardians hoort.

In de Guardian-PWA staat nu per ward een knop die de huidige stand toont en
omzet. Het endpoint accepteert het alleen van een COMMITTED guardian van die
ward, en alleen voor een ward die wij hosten: de instelling van een ward
elders hoort op de server van die ward thuis, dus die tonen we als
niet-instelbaar in plaats van te doen alsof.

Changed files:
src/routes/guardian.js

  • POST /guardian/wards/embeds, met controle op guardian-van-deze-ward en lokale ward
  • wardEmbedSetting(): huidige stand mee in de wards-lijst (null = niet van ons)

src/assets/js/guardian.js

  • knop per ward, toont de stand en zet hem om

src/services/i18n.js

  • guardian.embeds_on / embeds_off in nl, en, de

remarks: 219 tests groen. Federeren van deze instelling naar een remote ward is
een volgende stap (hoort bij shaer-3kp).

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

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