source: Klonkt/src/assets/js/guardian.js@ 99a7b40

main
Last change on this file since 99a7b40 was 99a7b40, checked in by Claude (agent) <aiclaude@…>, 5 weeks ago

Guardian-PWA: ward-posts renderen als in de Krant

De PWA had het al half: een hulpvraag ging door renderNoteBody, de gedeelde
note-body-partial, en kwam dus compleet binnen. De feed met posts van je wards
niet -- die kreeg alleen kale content en een losse media-array, dus een
guardian zag een lege regel waar een foto stond, geen quote-kaart, geen embed en
geen custom emoji. Precies de post die je als guardian wilt kunnen beoordelen.

/api/feed geeft nu body_html mee, dezelfde partial als de Krant en Berichten.
content blijft ernaast staan zodat een client die nog uit de cache draait niet
op een leeg blok uitkomt.

De content warning blijft nadrukkelijk van de PWA zelf. note-body versluiert
alleen bij nsfw, terwijl de PWA elke cw dichtklapt -- strenger, en dat hoort hier
ook: dit is de app waarmee iemand meekijkt met een kind. body_html gaat dus
BINNEN de bestaande details, niet in de plaats ervan.

Overige oppervlakken nagelopen: de publieke post-thread en het C2S-pad naar de
Shaer-apps vertaalden attachments al, dus daar was niets te doen.

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

  • Property mode set to 100644
File size: 33.8 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 // 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 }
42 function show(id, on) { document.getElementById(id).hidden = !on; }
43
44 // ── 1. Help requests ───────────────────────────────────────────────────
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
77 function renderHelp() {
78 var list = document.getElementById('help-list');
79 list.textContent = '';
80 var help = S.help || [];
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 }
85 var badge = document.getElementById('help-count');
86 badge.textContent = help.length; badge.hidden = help.length === 0;
87 show('help-empty', help.length === 0);
88 }
89
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 }
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
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
179 function renderPending() {
180 var list = document.getElementById('pending-list');
181 list.textContent = '';
182 var offers = S.offers || [];
183 var gated = S.gatedReviews || [];
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)); });
192 gated.forEach(function (g) { list.appendChild(gatedCard(g)); });
193 show('pending-section', offers.length > 0 || lapses.length > 0 || gated.length > 0);
194 }
195
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 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
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
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 || ''));
290 box.appendChild(el('p', null, T.release_local || ''));
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.
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 }
316 box.appendChild(row);
317 return box;
318 }
319
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
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');
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. Hidden
364 // only when previews are known-OFF: for a ward on another server the
365 // value is unknown, and unknown is not off. Hiding it there meant a
366 // guardian elsewhere could never even propose playback, which is how a
367 // whole proposal round went into the wrong gate. The ward's server
368 // enforces play-needs-previews at serve time regardless.
369 if (w.embeds !== false) {
370 setRow.appendChild(gateButton(w, 'shaer:externalPlayback', w.playback, T.play_propose, T.play_on, T.play_off));
371 }
372 set.appendChild(setRow);
373 // What this guardian proposed and how it stands (5.6). This used to be a
374 // button caption that vanished on refresh, so a running decision was
375 // invisible: you could not tell "waiting", "done" and "expired" apart.
376 (w.proposals || []).forEach(function (p) {
377 var what = p.feature === 'shaer:externalPlayback' ? (T.prop_play || 'playback') : (T.prop_embeds || 'link previews');
378 var line = (T.prop_line || 'Proposal {what} {value}: {status}')
379 .replace('{what}', what)
380 .replace('{value}', p.value ? (T.prop_on || 'on') : (T.prop_off || 'off'))
381 .replace('{status}', T['prop_st_' + p.status] || p.status);
382 set.appendChild(el('p', 'small g-prop g-prop-' + p.status, line));
383 });
384 panel.appendChild(set);
385
386 // The fellow guardians of this child, with availability (3.6). For a
387 // ward on another server the states live there, and saying so honestly
388 // beats guessing.
389 var gsec = el('div', 'g-panel-sec');
390 gsec.appendChild(el('h3', null, T.panel_guards || 'Guardians of this child'));
391 if (w.guardians && w.guardians.length) {
392 w.guardians.forEach(function (g) { gsec.appendChild(availRow(g, uri)); });
393 } else {
394 // A ward on another server: WHO guards it is public on its actor
395 // (shaer:guardians, 2.1), so list the seats; availability is the ward
396 // server's private ledger (3.6.1) and is not shown, only named.
397 var placeholder = el('p', 'g-empty small', '…');
398 gsec.appendChild(placeholder);
399 fetch('/guardian/wards/guardians?site=' + encodeURIComponent(S.site) + '&uri=' + encodeURIComponent(uri))
400 .then(function (r) { return r.json(); })
401 .then(function (j) {
402 if (!j || !j.guardians || !j.guardians.length) {
403 placeholder.textContent = T.panel_guards_remote || '';
404 return;
405 }
406 placeholder.remove();
407 j.guardians.forEach(function (g) {
408 var row = el('div', 'row g-guard');
409 row.appendChild(el('span', 'who grow', handleOf(g.uri, g.handle)));
410 gsec.appendChild(row);
411 });
412 gsec.appendChild(el('p', 'g-empty small', T.panel_guards_far || ''));
413 })
414 .catch(function () { placeholder.textContent = T.panel_guards_remote || ''; });
415 }
416 panel.appendChild(gsec);
417
418 sectionInto(panel, T.panel_follow || 'Follow requests',
419 FOLLOWS.filter(function (f) { return f.wardUri === uri; }),
420 T.panel_follow_empty || '', followCard);
421
422 sectionInto(panel, T.panel_help || 'Calls for help',
423 (S.help || []).filter(function (h) { return h.actor_uri === uri; }),
424 T.panel_help_empty || '', helpCard);
425
426 sectionInto(panel, T.panel_posts || 'Recent posts',
427 FEED.filter(function (p) { return p.authorUri === uri; }),
428 T.panel_posts_empty || '', feedCard);
429
430 var act = el('div', 'g-panel-sec');
431 act.appendChild(el('h3', null, T.panel_actions || 'Actions'));
432 var actRow = el('div', 'row');
433 var wave = el('button', 'small', T.wave || '👋 Wave');
434 wave.addEventListener('click', function () { sendWave(uri, wave); });
435 actRow.appendChild(wave);
436 var rel = el('button', 'quiet small', T.release);
437 // Letting a child go is a decision, not a click. It opens a step that first
438 // asks the server what releasing this particular ward actually does, then
439 // says it plainly and asks yes or no. Never window.confirm: that hides a
440 // long explanation behind an OK button people press to make it go away.
441 rel.addEventListener('click', function () {
442 rel.disabled = true;
443 // site matters: with several of your own sites the server would otherwise
444 // check this ward against the wrong one and answer "not my ward".
445 fetch('/guardian/wards/release-check?site=' + encodeURIComponent(S.site) + '&uri=' + encodeURIComponent(uri))
446 .then(function (r) { return r.json(); })
447 .then(function (c) {
448 rel.hidden = true;
449 act.appendChild(releaseStep(w, c || {}, act, rel));
450 })
451 .catch(function () { rel.disabled = false; });
452 });
453 actRow.appendChild(rel);
454 act.appendChild(actRow);
455 panel.appendChild(act);
456 return panel;
457 }
458
459 function renderWards() {
460 var list = document.getElementById('wards-list');
461 list.textContent = '';
462 var wards = S.wards || [];
463 wards.forEach(function (w) {
464 var uri = w.other_uri;
465 var card = el('div', 'g-card ward');
466 var row = el('div', 'row');
467 row.appendChild(el('span', 'who grow', handleOf(uri, w.other_handle)));
468 // Counts on the row: whatever is waiting must be visible with the panel shut.
469 var nHelp = (S.help || []).filter(function (h) { return h.actor_uri === uri; }).length;
470 var nFollow = FOLLOWS.filter(function (f) { return f.wardUri === uri; }).length;
471 if (nHelp) row.appendChild(el('span', 'tag help', '🛟 ' + nHelp));
472 if (nFollow) row.appendChild(el('span', 'tag co', nFollow + ' ' + (nFollow === 1 ? (T.badge_follow_one || '') : (T.badge_follow || ''))));
473 row.appendChild(el('span', 'tag ok', T.active));
474 var toggle = el('button', 'quiet small', openPanels[uri] ? T.panel_close : T.panel_open);
475 row.appendChild(toggle);
476 card.appendChild(row);
477 var panel = wardPanel(w);
478 card.appendChild(panel);
479 toggle.addEventListener('click', function () {
480 openPanels[uri] = !openPanels[uri];
481 panel.hidden = !openPanels[uri];
482 toggle.textContent = openPanels[uri] ? T.panel_close : T.panel_open;
483 });
484 list.appendChild(card);
485 });
486 show('wards-empty', wards.length === 0);
487 // Step away (3.6.1) only means something with wards to tell.
488 show('away-section', wards.length > 0);
489 }
490
491 // ── 4b. Step away (FEP-633c 3.6.1) ─────────────────────────────────────
492 function declareAway(days, btn) {
493 btn.disabled = true;
494 fetch('/guardian/api/away', {
495 method: 'POST', headers: { 'Content-Type': 'application/json' },
496 body: JSON.stringify({ days: days, site: S.site }),
497 }).then(function (r) { return r.json(); })
498 .then(function (j) {
499 btn.disabled = false;
500 var msg = document.getElementById('away-msg');
501 msg.hidden = false;
502 if (j && j.ok) {
503 msg.className = 'g-msg';
504 msg.textContent = (T.away_done || 'Your wards know you are unavailable until {date}.')
505 .replace('{date}', new Date(j.until).toLocaleDateString());
506 } else {
507 msg.className = 'g-msg err';
508 msg.textContent = (j && j.error) || (T.failed || 'failed');
509 }
510 })
511 .catch(function () { btn.disabled = false; });
512 }
513 var awayWeek = document.getElementById('away-week');
514 var awayMonth = document.getElementById('away-month');
515 if (awayWeek) awayWeek.addEventListener('click', function () { declareAway(7, awayWeek); });
516 if (awayMonth) awayMonth.addEventListener('click', function () { declareAway(30, awayMonth); });
517
518 function sendWave(uri, btn) {
519 btn.disabled = true;
520 fetch('/guardian/api/wave', {
521 method: 'POST', headers: { 'Content-Type': 'application/json' },
522 body: JSON.stringify({ ward: uri, site: S.site }),
523 }).then(function (r) { return r.json(); })
524 .then(function (j) { btn.disabled = false; btn.textContent = (j && j.ok) ? (T.waved || '👋 sent') : (T.wave || '👋 Wave'); })
525 .catch(function () { btn.disabled = false; });
526 }
527
528 function remove(uri, btn, onError) {
529 btn.disabled = true;
530 fetch('/guardian/wards/remove', {
531 method: 'POST', headers: { 'Content-Type': 'application/json' },
532 body: JSON.stringify({ uri: uri, site: S.site }),
533 }).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
534 .then(function (res) {
535 // The server can refuse: emptying shaer:guardians is emancipation and
536 // not one guardian's call (§3.4). Say so instead of silently redrawing.
537 if (!res.ok) { if (onError) onError(res.j && res.j.error); return; }
538 refresh();
539 })
540 .catch(function () { if (onError) onError('network'); else btn.disabled = false; });
541 }
542
543 function renderAll() { renderHelp(); renderPending(); renderWards(); }
544
545 // ── 0. Wards' corner: read-only feed of your wards' posts ───────────────
546 // Lives inside each child's panel now, so the fetches only fill a cache and
547 // ask the ward list to redraw. A guardian watches, it does not publish.
548 function feedCard(p) {
549 var card = el('div', 'g-card feed');
550 var head = el('div', 'row');
551 head.appendChild(el('span', 'who grow', p.author));
552 if (p.published) head.appendChild(el('span', 'g-when', when(p, p.published)));
553 card.appendChild(head);
554 var body = el('div', 'feed-body');
555 // body_html is de gedeelde note-body-partial, serverside gerenderd: opmaak,
556 // media, quote-kaart en embed, precies als in de Krant en in Berichten.
557 // Valt terug op de kale content voor een client uit de cache.
558 var html = p.body_html || p.content || '';
559 if (p.cw) {
560 // De content warning blijft van de PWA zelf: note-body versluiert alleen
561 // bij nsfw, en een ward-post met alleen een cw hoort hier dicht te staan.
562 var d = document.createElement('details');
563 var sum = document.createElement('summary'); sum.textContent = p.cw; d.appendChild(sum);
564 var inner = el('div'); inner.innerHTML = html; d.appendChild(inner);
565 body.appendChild(d);
566 } else {
567 body.innerHTML = html; // server-sanitized HTML (same as Berichten)
568 }
569 card.appendChild(body);
570 return card;
571 }
572
573 function loadFeed() {
574 return fetch('/guardian/api/feed?site=' + encodeURIComponent(S.site))
575 .then(function (r) { return r.json(); })
576 .then(function (f) { if (f && !f.error) { FEED = f.items || []; renderWards(); } })
577 .catch(function () { /* panels just show "nothing yet" */ });
578 }
579
580 // ── 0b. Follow requests on your wards (§5.3) ────────────────────────────
581 function answerFollow(id, decision, btn) {
582 if (btn) btn.disabled = true;
583 fetch('/guardian/api/follow/' + encodeURIComponent(id), {
584 method: 'POST', headers: { 'Content-Type': 'application/json' },
585 body: JSON.stringify({ decision: decision, site: S.site }),
586 }).then(loadFollowReqs);
587 }
588 function followCard(f) {
589 var card = el('div', 'g-card');
590 var row = el('div', 'row');
591 // Inside the child's own panel the ward name is a given, so only the
592 // person asking is named here.
593 row.appendChild(el('span', 'who grow', f.follower));
594 var ok = el('button', 'small', T.accept || 'Accept');
595 ok.addEventListener('click', function () { answerFollow(f.id, 'approve', ok); });
596 var no = el('button', 'quiet small', T.reject || 'Deny');
597 no.addEventListener('click', function () { answerFollow(f.id, 'reject', no); });
598 row.appendChild(ok); row.appendChild(no);
599 card.appendChild(row);
600 return card;
601 }
602 function loadFollowReqs() {
603 return fetch('/guardian/api/follow-requests?site=' + encodeURIComponent(S.site))
604 .then(function (r) { return r.json(); })
605 .then(function (f) { if (f && !f.error) { FOLLOWS = f.items || []; renderWards(); } })
606 .catch(function () { /* panels just show "none waiting" */ });
607 }
608
609 function refresh() {
610 return fetch('/guardian/api/state?site=' + encodeURIComponent(S.site))
611 .then(function (r) { return r.json(); })
612 .then(function (s) { if (s && !s.error) { S = s; T = s.strings || T; renderAll(); } })
613 .then(loadFeed).then(loadFollowReqs);
614 }
615
616 // ── 2. Adopt ───────────────────────────────────────────────────────────
617 var form = document.getElementById('adopt-form');
618 var input = document.getElementById('adopt-handle');
619 var adoptBtn = document.getElementById('adopt-btn');
620 var msg = document.getElementById('adopt-msg');
621 function setMsg(text, isErr) { msg.hidden = false; msg.className = 'g-msg' + (isErr ? ' err' : ''); msg.textContent = text; }
622
623 form.addEventListener('submit', function (ev) {
624 ev.preventDefault();
625 var handle = input.value.trim();
626 if (!handle) return;
627 adoptBtn.disabled = true;
628 setMsg(T.sending || '…', false);
629 fetch('/guardian/adopt', {
630 method: 'POST', headers: { 'Content-Type': 'application/json' },
631 body: JSON.stringify({ handle: handle, site: S.site }),
632 }).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
633 .then(function (res) {
634 adoptBtn.disabled = false;
635 if (res.ok) {
636 input.value = '';
637 // Always refresh: the offer is recorded even if delivery is still
638 // in flight. Show it under "Verzonden aanvragen".
639 setMsg(res.j.delivered === false ? T.sent_retry : T.sent, false);
640 refresh();
641 } else {
642 setMsg((res.j.error === 'not_found' ? T.not_found : T.failed) , true);
643 }
644 })
645 .catch(function () { adoptBtn.disabled = false; setMsg(T.network, true); });
646 });
647
648 // ── Site picker ────────────────────────────────────────────────────────
649 var picker = document.getElementById('site-picker');
650 if (picker) picker.addEventListener('change', function () {
651 location.href = '/guardian?site=' + encodeURIComponent(picker.value);
652 });
653
654 // ── 5. Push ────────────────────────────────────────────────────────────
655 var toggle = document.getElementById('push-toggle');
656 var pmsg = document.getElementById('push-msg');
657 function pushState() {
658 if (!('serviceWorker' in navigator) || !('PushManager' in window)) { toggle.disabled = true; return; }
659 navigator.serviceWorker.register('/sw.js').catch(function () {});
660 navigator.serviceWorker.ready
661 .then(function (reg) { return reg.pushManager.getSubscription(); })
662 .then(function (sub) {
663 toggle.textContent = sub ? toggle.dataset.onLabel : toggle.dataset.offLabel;
664 toggle.dataset.subscribed = sub ? '1' : '';
665 toggle.classList.toggle('is-on', !!sub);
666 });
667 }
668 function urlB64(base64) {
669 var pad = '='.repeat((4 - (base64.length % 4)) % 4);
670 var b = (base64 + pad).replace(/-/g, '+').replace(/_/g, '/');
671 var raw = atob(b); var arr = new Uint8Array(raw.length);
672 for (var i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
673 return arr;
674 }
675 toggle.addEventListener('click', function () {
676 pmsg.hidden = true;
677 navigator.serviceWorker.ready.then(function (reg) {
678 if (toggle.dataset.subscribed) {
679 reg.pushManager.getSubscription().then(function (sub) {
680 if (!sub) return;
681 fetch('/push/unsubscribe', {
682 method: 'POST', headers: { 'Content-Type': 'application/json' },
683 body: JSON.stringify({ endpoint: sub.endpoint }),
684 }).then(function () { return sub.unsubscribe(); }).then(pushState);
685 });
686 return;
687 }
688 fetch('/push/vapid').then(function (r) { return r.json(); }).then(function (v) {
689 if (!v.publicKey) throw new Error('no key');
690 return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlB64(v.publicKey) });
691 }).then(function (sub) {
692 return fetch('/push/subscribe', {
693 method: 'POST', headers: { 'Content-Type': 'application/json' },
694 body: JSON.stringify({
695 subscription: sub.toJSON(),
696 alerts: { help: 1, guardian: 1, dm: 1, follow: 0, reply: 0, like: 0, boost: 0 },
697 uaLabel: 'Guardian PWA',
698 }),
699 });
700 }).then(pushState).catch(function (e) {
701 pmsg.hidden = false; pmsg.className = 'g-msg err';
702 pmsg.textContent = (T.push_unavailable || 'Push unavailable') + ': ' + e.message;
703 });
704 });
705 });
706
707 renderAll(); pushState(); loadFeed(); loadFollowReqs();
708 setInterval(refresh, 45000); // live-ish while open
709 } catch (e) {
710 fatal((e && e.message) || String(e));
711 }
712})();
Note: See TracBrowser for help on using the repository browser.