source: Klonkt/src/assets/js/guardian.js@ 6100ce9

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

Afspelen in de app als tweede gated feature, en het gat in de gate

Bij het uitzoeken van de YouTube-vraag bleek de gate lek. De web-Krant bouwt de
speler uit de inhoud van de post via timelineEmbedHtml, en dat pad raakte
gateEmbeds nooit. Een ward wiens guardians niets hadden toegestaan kreeg dus de
volledige YouTube-speler op het web, terwijl de app niets liet zien: het zware
ding open, het lichte dicht. Precies omgekeerd.

Nu zijn het twee besluiten, want het zijn twee dingen. Zien dat er een filmpje
is, is niet hetzelfde als het scherm afstaan aan de motor van een derde partij,
compleet met eindscherm en volgende-video. shaer:externalEmbeds houdt de kaart,
shaer:externalPlayback de speler, allebei standaard uit voor een ward, en
afspelen vereist de kaart: je kunt niet spelen wat je niet mag zien.

En het antwoord op Robins vraag over de links: die vallen er ook onder. De gate
verborg tot nu toe alleen het plaatje terwijl de kale link eronder gewoon
aantikbaar bleef, dus de deur stond open met een doek eroverheen. Staat de gate
dicht, dan toont de kaart zich nog wel maar is hij geen deur meer.

De server bepaalt wat gespeeld mag worden, niet de client: hij levert
shaer:playerUrl mee, alleen bij een open gate en alleen in de privacy-variant
(youtube-nocookie met rel=0, of de eigen speler van de PeerTube-instance). De
app houdt zo geen lijst van hosts bij; hij speelt wat hij krijgt aangereikt.

Changed files:
src/config/database.js

  • kolom sites.external_playback

src/services/guardianship/notes.js

  • externalPlaybackAllowed naast externalEmbedsAllowed

src/services/guardianship/gated.js

  • shaer:externalPlayback in de feature-tabel

src/services/ActivityPubService.js

  • timelineEmbed voegt shaer:playerUrl toe als afspelen mag; playerUrlFor kent alleen privacy-varianten en weigert de rest

src/routes/activitypub.js

  • shaer:capabilities op de owner-only inbox-read: wat mag dit account
  • de embed draagt de speler-URL alleen bij een open playback-gate

src/routes/posts.js

  • het gat gedicht: de speler-iframe op de web-Krant valt nu onder de gate

src/routes/guardian.js

  • de voorstel-route is feature-bewust; het lokale pad stuurt nu ook door

src/assets/js/guardian.js

  • tweede knop in het paneel, alleen zichtbaar als de kaart al aan staat

src/services/i18n.js

  • de labels in nl, en, de

test/gated-settings.test.js

  • drie tests: de speler-URL rijdt alleen mee bij een open gate, een pagina die we niet framen blijft een thumbnail, en afspelen vereist de kaart

remarks: 280 tests groen. Niets geforceerd: beide gates staan standaard uit
voor een ward en twee van de drie guardians moeten nog steeds akkoord gaan.

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

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