source: Klonkt/src/assets/js/guardian.js@ 88d7c8f

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

Een gated voorstel bereikte de andere guardians nooit

Op de vloot nagekeken waarom YouTube-voorbeelden bij beta uit blijven: het
voorstel van sound-fabrics staat er, met een ja van een van de drie guardians,
en het is stil verlopen. Niet omdat iemand bezwaar had, maar omdat niemand
anders het ooit gezien heeft.

Het voorstel wordt geadresseerd aan de server van het kind, want die telt en
handhaaft (5.6). Maar daarmee bereikt het alleen de voorsteller en het kind. De
twee guardians op andere servers weten van niets, kunnen dus niet antwoorden, en
een drempel van twee is onhaalbaar. Elk voorstel verloopt na een dag.

De ontbrekende schakel is het doorsturen, precies wat 5.3 al doet voor een
gated follow: de server van het kind kent de gezaghebbende guardian-lijst, dus
die stuurt het voorstel door. Elke guardian bewaart een kopie die hij kan
beantwoorden, en het antwoord reist terug naar het kind, dat telt.

Changed files:
src/config/database.js

  • tabel ap_gated_reviews, de guardian-kopie (zelfde vorm als ap_follow_reviews)

src/services/guardianship/gated.js

  • de kopie-opslag: bewaren, lezen, beantwoorden, opruimen

src/services/guardianship/handshake.js

  • ward-kant: doorsturen naar de andere guardians zodra het voorstel openstaat
  • guardian-kant: de doorgestuurde kopie bewaren om te kunnen antwoorden

src/routes/guardian.js

  • gatedReviews in de dashboardstaat; POST /guardian/api/gated/:id stuurt het antwoord naar de inbox van het kind

src/assets/js/guardian.js, src/assets/css/guardian.css

src/services/i18n.js

  • de teksten in nl, en, de

test/gated-settings.test.js

  • de hele keten: voorstellen, doorsturen naar allebei de anderen, de kopie opslaan, antwoorden, en pas bij twee van drie gaat de gate open

remarks: dit forceert niets; het maakt alleen mogelijk wat de spec al bedoelde.
Twee van de drie guardians moeten nog steeds akkoord gaan, en het venster is
24 uur.

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

  • Property mode set to 100644
File size: 30.0 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 }
[a7bcf66]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 }
[c26cc18]42 function show(id, on) { document.getElementById(id).hidden = !on; }
[318d0c2]43
[c26cc18]44 // ── 1. Help requests ───────────────────────────────────────────────────
[70677e96]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
[318d0c2]77 function renderHelp() {
78 var list = document.getElementById('help-list');
79 list.textContent = '';
[c26cc18]80 var help = S.help || [];
[70677e96]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 }
[c26cc18]85 var badge = document.getElementById('help-count');
86 badge.textContent = help.length; badge.hidden = help.length === 0;
87 show('help-empty', help.length === 0);
[318d0c2]88 }
89
[780a7c6]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 }
[0202104]125 /** A running lapse (FEP-633c 3.6.3): the available co-guardians deciding
126 * to release a dormant one. Votes ride the same Accept/Reject wire as the
127 * offers; buttons appear only for set members (the ward watches, it does
128 * not vote). */
129 function lapseCard(l) {
130 var card = el('div', 'g-card lapse');
131 card.appendChild(el('div', 'who', (T.lapse_line || '{who} has stopped answering as a guardian of {ward}.')
132 .replace('{who}', handleOf(l.object.object)).replace('{ward}', handleOf(l.object['shaer:ward']))));
133 card.appendChild(el('div', 'g-avlabel', (T.lapse_tally || '{n} of {need} agreed; closes {date}.')
134 .replace('{n}', l['shaer:accepts']).replace('{need}', l['shaer:threshold'])
135 .replace('{date}', new Date(l['shaer:closesAt']).toLocaleDateString())));
136 var row = el('div', 'row');
137 var inSet = (l['shaer:set'] || []).indexOf(S.me) >= 0;
138 if (inSet && !l['shaer:myVote']) {
139 var yes = el('button', 'small', T.lapse_agree || 'Agree');
140 yes.addEventListener('click', function () { answer(l.id, 'accept', yes); });
141 var no = el('button', 'quiet small', T.lapse_disagree || 'Disagree');
142 no.addEventListener('click', function () { answer(l.id, 'reject', no); });
143 row.appendChild(yes); row.appendChild(no);
144 } else if (inSet) {
145 row.appendChild(el('span', 'g-avlabel', T.voted || 'You voted'));
146 }
147 card.appendChild(row);
148 card.appendChild(el('p', 'g-empty small', T.lapse_note || ''));
149 return card;
150 }
151
[88d7c8f]152 /** A gated-setting proposal (FEP-633c 5.6) a fellow guardian opened on a
153 * ward we share, forwarded here by the ward's server. Answering is the
154 * whole point: without a second voice the threshold is never met and the
155 * proposal quietly expires. */
156 function gatedCard(g) {
157 var card = el('div', 'g-card gated');
158 var line = g.value ? (T.gated_line_on || '') : (T.gated_line_off || '');
159 card.appendChild(el('div', 'who', line
160 .replace('{who}', handleOf(g.proposer || ''))
161 .replace('{ward}', handleOf(g.ward))));
162 var row = el('div', 'row');
163 var yes = el('button', 'small', T.gated_agree || 'Agree');
164 var no = el('button', 'quiet small', T.gated_disagree || 'Disagree');
165 function answerGated(decision, btn) {
166 btn.disabled = true;
167 fetch('/guardian/api/gated/' + encodeURIComponent(g.id), {
168 method: 'POST', headers: { 'Content-Type': 'application/json' },
169 body: JSON.stringify({ answer: decision, site: S.site }),
170 }).then(refresh).catch(function () { btn.disabled = false; });
171 }
172 yes.addEventListener('click', function () { answerGated('accept', yes); });
173 no.addEventListener('click', function () { answerGated('reject', no); });
174 row.appendChild(yes); row.appendChild(no);
175 card.appendChild(row);
176 return card;
177 }
178
[c26cc18]179 function renderPending() {
180 var list = document.getElementById('pending-list');
181 list.textContent = '';
[780a7c6]182 var offers = S.offers || [];
[88d7c8f]183 var gated = S.gatedReviews || [];
[0202104]184 // The offers state carries the adoption offers; the lapse proposals ride
185 // separately so a lapse never renders as an adoption.
186 var lapses = (S.lapses || []).filter(function (l) { return l['shaer:outcome'] === 'open'; });
187 offers.forEach(function (o) {
188 if (o.object && o.object.type === 'shaer:Lapse') return; // rendered below
189 list.appendChild(offerCard(o));
190 });
191 lapses.forEach(function (l) { list.appendChild(lapseCard(l)); });
[88d7c8f]192 gated.forEach(function (g) { list.appendChild(gatedCard(g)); });
193 show('pending-section', offers.length > 0 || lapses.length > 0 || gated.length > 0);
[318d0c2]194 }
[c26cc18]195
[70677e96]196 // ── 4. Accepted wards: one panel per child ─────────────────────────────
197 // A guardian thinks per child, not per function, so everything about one
198 // child sits behind that child's row: the gated settings, the follow requests
199 // waiting on them, their calls for help, their recent posts. The row itself
200 // carries counts, so nothing that needs an answer hides inside a closed
201 // panel.
202 var openPanels = {}; // ward uri -> open, so a refresh does not close it
203 // The follow requests and the wards' posts arrive from their own endpoints
204 // and are grouped into the panels by ward, so they are cached here rather
205 // than rendered into a section of their own.
206 var FEED = [], FOLLOWS = [];
207
208 function sectionInto(panel, title, items, empty, build) {
209 var h = el('div', 'g-panel-sec');
210 h.appendChild(el('h3', null, title));
211 if (!items.length) h.appendChild(el('p', 'g-empty small', empty));
212 else items.forEach(function (it) { h.appendChild(build(it)); });
213 panel.appendChild(h);
214 return h;
215 }
216
217 function embedsButton(w) {
218 // Gated feature: external (non-fediverse) embeds. Off by default for a
219 // ward; only a guardian can open it, and the gate is enforced server-side
220 // when the feed is built, so this button is the only thing that moves it.
221 // Shown for EVERY ward, including one on another server. There the value is
222 // unknown (it lives on the ward's server), but proposing is exactly as
223 // possible: the proposal travels, the ward's server tallies the guardians
224 // and enforces. A guardian next door must not have more say than one far
225 // away.
226 var known = w.embeds === true || w.embeds === false;
227 var emb = el('button', 'quiet small',
228 (known ? (w.embeds ? T.embeds_on : T.embeds_off) : T.embeds_propose) || 'Link previews');
229 emb.addEventListener('click', function () {
230 emb.disabled = true;
231 fetch('/guardian/wards/embeds', {
232 method: 'POST', headers: { 'Content-Type': 'application/json' },
233 body: JSON.stringify({ uri: w.other_uri, allow: known ? !w.embeds : true }),
234 }).then(function (r) { return r.json(); })
235 .then(function (j) {
236 // Not settled yet: the other guardians still have to answer.
237 if (j && j.state === 'open') {
238 emb.textContent = (T.embeds_waiting || 'waiting for the other guardians');
239 emb.disabled = true;
240 return;
241 }
242 refresh();
243 })
244 .catch(function () { emb.disabled = false; });
245 });
246 return emb;
247 }
248
[742ba7e]249 /**
250 * The second step of releasing a ward: what it does, then yes or no.
251 *
252 * The warning is assembled from what the server found, not from a fixed
253 * sentence, because releasing means two different things (FEP-633c): stepping
254 * down while other guardians remain (§3.3), or being the last one, which is
255 * emancipation and explicitly not one guardian's call (§3.4). And as long as
256 * the Undo does not federate, the ward's server keeps listing you either way
257 * — a guardian has to know that before pressing, not after.
258 */
259 function releaseStep(w, check, host, relBtn) {
260 var uri = w.other_uri;
261 var who = handleOf(uri, w.other_handle);
262 var box = el('div', 'g-warn');
263 box.appendChild(el('strong', null, (T.release_title || 'Release {who}?').replace('{who}', who)));
264 box.appendChild(el('p', null, T.release_effect || ''));
265 if (check.last === true) box.appendChild(el('p', 'grave', T.release_last || ''));
266 else if (check.last === false) box.appendChild(el('p', null, T.release_step_down || ''));
267 else box.appendChild(el('p', 'grave', T.release_unknown || ''));
[6c152a5]268 box.appendChild(el('p', null, T.release_local || ''));
[742ba7e]269
270 var row = el('div', 'row');
271 var no = el('button', 'small', T.release_no || 'No');
272 no.addEventListener('click', function () {
273 host.removeChild(box);
274 relBtn.hidden = false; relBtn.disabled = false;
275 });
276 // No first: the way out should be the easy one to hit.
[6c152a5]277 row.appendChild(no);
278 // Being the last guardian is not a warning but a wall: the server refuses
279 // it (§3.4), so offering a yes here would only produce an error. The text
280 // above already says what has to happen instead.
281 if (check.last !== true) {
282 var yes = el('button', 'danger small', T.release_yes || 'Yes');
283 yes.addEventListener('click', function () {
284 yes.disabled = true;
285 remove(uri, yes, function (err) {
286 // The guardian set can change between the check and the click.
287 yes.disabled = false;
288 box.appendChild(el('p', 'grave', err === 'would_emancipate' ? (T.release_last || '') : (T.failed || '')));
289 if (err === 'would_emancipate') yes.remove();
290 });
291 });
292 row.appendChild(yes);
293 }
[742ba7e]294 box.appendChild(row);
295 return box;
296 }
297
[0202104]298 /** The availability dot (FEP-633c 3.6): buddy-list language on the
299 * responsibility axis. Green available, yellow declared away with an end,
300 * grey observed dormant (one answer restores). */
301 function availLabel(g) {
302 if (g.availability === 'away') {
303 var date = g.awayUntil ? new Date(g.awayUntil).toLocaleDateString() : '?';
304 return (T.avail_away || 'Unavailable till {date}').replace('{date}', date);
305 }
306 if (g.availability === 'dormant') return T.avail_dormant || 'Offline';
307 return T.avail_available || 'Available';
308 }
309 function availRow(g, wardUri) {
310 var row = el('div', 'row g-guard');
311 var dot = el('span', 'g-avdot ' + (g.availability === 'away' ? 'is-away' : g.availability === 'dormant' ? 'is-dormant' : 'is-active'));
312 row.appendChild(dot);
313 row.appendChild(el('span', 'who grow', handleOf(g.uri, g.handle)));
314 row.appendChild(el('span', 'g-avlabel', availLabel(g)));
315 // A dormant fellow guardian without a running lapse: the deliberate,
316 // rare next step (3.6.3). Never shown for anyone still answering.
317 if (g.availability === 'dormant' && !g.lapse && g.uri !== S.me) {
318 var btn = el('button', 'quiet small', T.lapse_propose || 'Propose release');
319 btn.addEventListener('click', function () {
320 btn.disabled = true;
321 fetch('/guardian/api/lapse', {
322 method: 'POST', headers: { 'Content-Type': 'application/json' },
323 body: JSON.stringify({ ward: wardUri, target: g.uri, site: S.site }),
324 }).then(refresh).catch(function () { btn.disabled = false; });
325 });
326 row.appendChild(btn);
327 }
328 return row;
329 }
330
[70677e96]331 function wardPanel(w) {
332 var uri = w.other_uri;
333 var panel = el('div', 'g-panel');
334 panel.hidden = !openPanels[uri];
335
336 var set = el('div', 'g-panel-sec');
337 set.appendChild(el('h3', null, T.settings_title || 'Settings'));
338 var setRow = el('div', 'row');
339 setRow.appendChild(embedsButton(w));
340 set.appendChild(setRow);
341 panel.appendChild(set);
342
[0202104]343 // The fellow guardians of this child, with availability (3.6). For a
344 // ward on another server the states live there, and saying so honestly
345 // beats guessing.
346 var gsec = el('div', 'g-panel-sec');
347 gsec.appendChild(el('h3', null, T.panel_guards || 'Guardians of this child'));
348 if (w.guardians && w.guardians.length) {
349 w.guardians.forEach(function (g) { gsec.appendChild(availRow(g, uri)); });
350 } else {
351 gsec.appendChild(el('p', 'g-empty small', T.panel_guards_remote || ''));
352 }
353 panel.appendChild(gsec);
354
[70677e96]355 sectionInto(panel, T.panel_follow || 'Follow requests',
356 FOLLOWS.filter(function (f) { return f.wardUri === uri; }),
357 T.panel_follow_empty || '', followCard);
358
359 sectionInto(panel, T.panel_help || 'Calls for help',
360 (S.help || []).filter(function (h) { return h.actor_uri === uri; }),
361 T.panel_help_empty || '', helpCard);
362
363 sectionInto(panel, T.panel_posts || 'Recent posts',
364 FEED.filter(function (p) { return p.authorUri === uri; }),
365 T.panel_posts_empty || '', feedCard);
366
367 var act = el('div', 'g-panel-sec');
368 act.appendChild(el('h3', null, T.panel_actions || 'Actions'));
369 var actRow = el('div', 'row');
370 var wave = el('button', 'small', T.wave || '👋 Wave');
371 wave.addEventListener('click', function () { sendWave(uri, wave); });
372 actRow.appendChild(wave);
373 var rel = el('button', 'quiet small', T.release);
[742ba7e]374 // Letting a child go is a decision, not a click. It opens a step that first
375 // asks the server what releasing this particular ward actually does, then
376 // says it plainly and asks yes or no. Never window.confirm: that hides a
377 // long explanation behind an OK button people press to make it go away.
[70677e96]378 rel.addEventListener('click', function () {
[742ba7e]379 rel.disabled = true;
380 // site matters: with several of your own sites the server would otherwise
381 // check this ward against the wrong one and answer "not my ward".
382 fetch('/guardian/wards/release-check?site=' + encodeURIComponent(S.site) + '&uri=' + encodeURIComponent(uri))
383 .then(function (r) { return r.json(); })
384 .then(function (c) {
385 rel.hidden = true;
386 act.appendChild(releaseStep(w, c || {}, act, rel));
387 })
388 .catch(function () { rel.disabled = false; });
[70677e96]389 });
390 actRow.appendChild(rel);
391 act.appendChild(actRow);
392 panel.appendChild(act);
393 return panel;
394 }
395
[318d0c2]396 function renderWards() {
[c26cc18]397 var list = document.getElementById('wards-list');
398 list.textContent = '';
399 var wards = S.wards || [];
400 wards.forEach(function (w) {
[70677e96]401 var uri = w.other_uri;
402 var card = el('div', 'g-card ward');
[c26cc18]403 var row = el('div', 'row');
[70677e96]404 row.appendChild(el('span', 'who grow', handleOf(uri, w.other_handle)));
405 // Counts on the row: whatever is waiting must be visible with the panel shut.
406 var nHelp = (S.help || []).filter(function (h) { return h.actor_uri === uri; }).length;
407 var nFollow = FOLLOWS.filter(function (f) { return f.wardUri === uri; }).length;
408 if (nHelp) row.appendChild(el('span', 'tag help', '🛟 ' + nHelp));
409 if (nFollow) row.appendChild(el('span', 'tag co', nFollow + ' ' + (nFollow === 1 ? (T.badge_follow_one || '') : (T.badge_follow || ''))));
[c26cc18]410 row.appendChild(el('span', 'tag ok', T.active));
[70677e96]411 var toggle = el('button', 'quiet small', openPanels[uri] ? T.panel_close : T.panel_open);
412 row.appendChild(toggle);
[c26cc18]413 card.appendChild(row);
[70677e96]414 var panel = wardPanel(w);
415 card.appendChild(panel);
416 toggle.addEventListener('click', function () {
417 openPanels[uri] = !openPanels[uri];
418 panel.hidden = !openPanels[uri];
419 toggle.textContent = openPanels[uri] ? T.panel_close : T.panel_open;
420 });
[c26cc18]421 list.appendChild(card);
422 });
423 show('wards-empty', wards.length === 0);
[0202104]424 // Step away (3.6.1) only means something with wards to tell.
425 show('away-section', wards.length > 0);
426 }
427
428 // ── 4b. Step away (FEP-633c 3.6.1) ─────────────────────────────────────
429 function declareAway(days, btn) {
430 btn.disabled = true;
431 fetch('/guardian/api/away', {
432 method: 'POST', headers: { 'Content-Type': 'application/json' },
433 body: JSON.stringify({ days: days, site: S.site }),
434 }).then(function (r) { return r.json(); })
435 .then(function (j) {
436 btn.disabled = false;
437 var msg = document.getElementById('away-msg');
438 msg.hidden = false;
439 if (j && j.ok) {
440 msg.className = 'g-msg';
441 msg.textContent = (T.away_done || 'Your wards know you are unavailable until {date}.')
442 .replace('{date}', new Date(j.until).toLocaleDateString());
443 } else {
444 msg.className = 'g-msg err';
445 msg.textContent = (j && j.error) || (T.failed || 'failed');
446 }
447 })
448 .catch(function () { btn.disabled = false; });
[c26cc18]449 }
[0202104]450 var awayWeek = document.getElementById('away-week');
451 var awayMonth = document.getElementById('away-month');
452 if (awayWeek) awayWeek.addEventListener('click', function () { declareAway(7, awayWeek); });
453 if (awayMonth) awayMonth.addEventListener('click', function () { declareAway(30, awayMonth); });
[c26cc18]454
[f1c50f9]455 function sendWave(uri, btn) {
456 btn.disabled = true;
457 fetch('/guardian/api/wave', {
458 method: 'POST', headers: { 'Content-Type': 'application/json' },
459 body: JSON.stringify({ ward: uri, site: S.site }),
460 }).then(function (r) { return r.json(); })
461 .then(function (j) { btn.disabled = false; btn.textContent = (j && j.ok) ? (T.waved || '👋 sent') : (T.wave || '👋 Wave'); })
462 .catch(function () { btn.disabled = false; });
463 }
464
[6c152a5]465 function remove(uri, btn, onError) {
[c26cc18]466 btn.disabled = true;
467 fetch('/guardian/wards/remove', {
468 method: 'POST', headers: { 'Content-Type': 'application/json' },
469 body: JSON.stringify({ uri: uri, site: S.site }),
[6c152a5]470 }).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
471 .then(function (res) {
472 // The server can refuse: emptying shaer:guardians is emancipation and
473 // not one guardian's call (§3.4). Say so instead of silently redrawing.
474 if (!res.ok) { if (onError) onError(res.j && res.j.error); return; }
475 refresh();
476 })
477 .catch(function () { if (onError) onError('network'); else btn.disabled = false; });
[318d0c2]478 }
479
[c26cc18]480 function renderAll() { renderHelp(); renderPending(); renderWards(); }
481
[f1c50f9]482 // ── 0. Wards' corner: read-only feed of your wards' posts ───────────────
[70677e96]483 // Lives inside each child's panel now, so the fetches only fill a cache and
484 // ask the ward list to redraw. A guardian watches, it does not publish.
485 function feedCard(p) {
486 var card = el('div', 'g-card feed');
487 var head = el('div', 'row');
488 head.appendChild(el('span', 'who grow', p.author));
489 if (p.published) head.appendChild(el('span', 'g-when', when(p, p.published)));
490 card.appendChild(head);
491 var body = el('div', 'feed-body');
492 if (p.cw) {
493 var d = document.createElement('details');
494 var sum = document.createElement('summary'); sum.textContent = p.cw; d.appendChild(sum);
495 var inner = el('div'); inner.innerHTML = p.content || ''; d.appendChild(inner);
496 body.appendChild(d);
497 } else {
498 body.innerHTML = p.content || ''; // server-sanitized HTML (same as Berichten)
499 }
500 card.appendChild(body);
501 return card;
[f1c50f9]502 }
503
504 function loadFeed() {
505 return fetch('/guardian/api/feed?site=' + encodeURIComponent(S.site))
506 .then(function (r) { return r.json(); })
[70677e96]507 .then(function (f) { if (f && !f.error) { FEED = f.items || []; renderWards(); } })
508 .catch(function () { /* panels just show "nothing yet" */ });
[f1c50f9]509 }
510
511 // ── 0b. Follow requests on your wards (§5.3) ────────────────────────────
512 function answerFollow(id, decision, btn) {
513 if (btn) btn.disabled = true;
514 fetch('/guardian/api/follow/' + encodeURIComponent(id), {
515 method: 'POST', headers: { 'Content-Type': 'application/json' },
516 body: JSON.stringify({ decision: decision, site: S.site }),
517 }).then(loadFollowReqs);
518 }
[70677e96]519 function followCard(f) {
520 var card = el('div', 'g-card');
521 var row = el('div', 'row');
522 // Inside the child's own panel the ward name is a given, so only the
523 // person asking is named here.
524 row.appendChild(el('span', 'who grow', f.follower));
525 var ok = el('button', 'small', T.accept || 'Accept');
526 ok.addEventListener('click', function () { answerFollow(f.id, 'approve', ok); });
527 var no = el('button', 'quiet small', T.reject || 'Deny');
528 no.addEventListener('click', function () { answerFollow(f.id, 'reject', no); });
529 row.appendChild(ok); row.appendChild(no);
530 card.appendChild(row);
531 return card;
[f1c50f9]532 }
533 function loadFollowReqs() {
534 return fetch('/guardian/api/follow-requests?site=' + encodeURIComponent(S.site))
535 .then(function (r) { return r.json(); })
[70677e96]536 .then(function (f) { if (f && !f.error) { FOLLOWS = f.items || []; renderWards(); } })
537 .catch(function () { /* panels just show "none waiting" */ });
[f1c50f9]538 }
539
[318d0c2]540 function refresh() {
[c26cc18]541 return fetch('/guardian/api/state?site=' + encodeURIComponent(S.site))
[318d0c2]542 .then(function (r) { return r.json(); })
[f1c50f9]543 .then(function (s) { if (s && !s.error) { S = s; T = s.strings || T; renderAll(); } })
544 .then(loadFeed).then(loadFollowReqs);
[318d0c2]545 }
546
[c26cc18]547 // ── 2. Adopt ───────────────────────────────────────────────────────────
548 var form = document.getElementById('adopt-form');
549 var input = document.getElementById('adopt-handle');
550 var adoptBtn = document.getElementById('adopt-btn');
551 var msg = document.getElementById('adopt-msg');
552 function setMsg(text, isErr) { msg.hidden = false; msg.className = 'g-msg' + (isErr ? ' err' : ''); msg.textContent = text; }
553
554 form.addEventListener('submit', function (ev) {
[318d0c2]555 ev.preventDefault();
556 var handle = input.value.trim();
557 if (!handle) return;
[c26cc18]558 adoptBtn.disabled = true;
559 setMsg(T.sending || '…', false);
[318d0c2]560 fetch('/guardian/adopt', {
561 method: 'POST', headers: { 'Content-Type': 'application/json' },
[c26cc18]562 body: JSON.stringify({ handle: handle, site: S.site }),
[318d0c2]563 }).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
564 .then(function (res) {
[c26cc18]565 adoptBtn.disabled = false;
566 if (res.ok) {
567 input.value = '';
568 // Always refresh: the offer is recorded even if delivery is still
569 // in flight. Show it under "Verzonden aanvragen".
570 setMsg(res.j.delivered === false ? T.sent_retry : T.sent, false);
571 refresh();
572 } else {
573 setMsg((res.j.error === 'not_found' ? T.not_found : T.failed) , true);
574 }
[318d0c2]575 })
[c26cc18]576 .catch(function () { adoptBtn.disabled = false; setMsg(T.network, true); });
[318d0c2]577 });
578
579 // ── Site picker ────────────────────────────────────────────────────────
580 var picker = document.getElementById('site-picker');
581 if (picker) picker.addEventListener('change', function () {
582 location.href = '/guardian?site=' + encodeURIComponent(picker.value);
583 });
584
[c26cc18]585 // ── 5. Push ────────────────────────────────────────────────────────────
[318d0c2]586 var toggle = document.getElementById('push-toggle');
587 var pmsg = document.getElementById('push-msg');
588 function pushState() {
589 if (!('serviceWorker' in navigator) || !('PushManager' in window)) { toggle.disabled = true; return; }
590 navigator.serviceWorker.register('/sw.js').catch(function () {});
591 navigator.serviceWorker.ready
592 .then(function (reg) { return reg.pushManager.getSubscription(); })
593 .then(function (sub) {
594 toggle.textContent = sub ? toggle.dataset.onLabel : toggle.dataset.offLabel;
595 toggle.dataset.subscribed = sub ? '1' : '';
[c26cc18]596 toggle.classList.toggle('is-on', !!sub);
[318d0c2]597 });
598 }
599 function urlB64(base64) {
600 var pad = '='.repeat((4 - (base64.length % 4)) % 4);
601 var b = (base64 + pad).replace(/-/g, '+').replace(/_/g, '/');
602 var raw = atob(b); var arr = new Uint8Array(raw.length);
603 for (var i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
604 return arr;
605 }
606 toggle.addEventListener('click', function () {
607 pmsg.hidden = true;
608 navigator.serviceWorker.ready.then(function (reg) {
609 if (toggle.dataset.subscribed) {
610 reg.pushManager.getSubscription().then(function (sub) {
611 if (!sub) return;
612 fetch('/push/unsubscribe', {
613 method: 'POST', headers: { 'Content-Type': 'application/json' },
614 body: JSON.stringify({ endpoint: sub.endpoint }),
615 }).then(function () { return sub.unsubscribe(); }).then(pushState);
616 });
617 return;
618 }
619 fetch('/push/vapid').then(function (r) { return r.json(); }).then(function (v) {
620 if (!v.publicKey) throw new Error('no key');
621 return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlB64(v.publicKey) });
622 }).then(function (sub) {
623 return fetch('/push/subscribe', {
624 method: 'POST', headers: { 'Content-Type': 'application/json' },
625 body: JSON.stringify({
626 subscription: sub.toJSON(),
627 alerts: { help: 1, guardian: 1, dm: 1, follow: 0, reply: 0, like: 0, boost: 0 },
628 uaLabel: 'Guardian PWA',
629 }),
630 });
631 }).then(pushState).catch(function (e) {
[c26cc18]632 pmsg.hidden = false; pmsg.className = 'g-msg err';
633 pmsg.textContent = (T.push_unavailable || 'Push unavailable') + ': ' + e.message;
[318d0c2]634 });
635 });
636 });
637
[f1c50f9]638 renderAll(); pushState(); loadFeed(); loadFollowReqs();
[c26cc18]639 setInterval(refresh, 45000); // live-ish while open
[fcd6964]640 } catch (e) {
641 fatal((e && e.message) || String(e));
642 }
[318d0c2]643})();
Note: See TracBrowser for help on using the repository browser.