source: Klonkt/src/assets/js/guardian.js@ 6c152a5

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

De Undo bij het loslaten van een ward reist nu echt mee

Loslaten was een lokale delete. De guardian vergat het kind, terwijl de server
van het kind hem gewoon in shaer:guardians bleef noemen. De vorige commit zette
dat als waarschuwing in beeld; Robin noemt het terecht een bug, want FEP-633c
3.2 zegt gewoon dat een Undo van de Relationship de guardian eruit haalt.

Nu gaat er een Undo naar het kind en naar de andere guardians, met dezelfde
adressering als de Offer waarmee het begon (3.1.1), zodat geen kopie achterblijft
die denkt dat de band er nog is. De ontvangende kant haalt de guardian eruit,
maar alleen als de guardian zelf tekent: de variant waarbij het kind opzegt met
een mede-ondertekenende guardian heeft een tweede handtekening nodig en is niet
gebouwd, dus die wordt geweigerd in plaats van half uitgevoerd.

De laatste guardian kan niet meer alleen weglopen. 3.3 geldt zolang er meer dan
een over is; de set leegmaken is emancipatie (3.4) en daar gaat geen enkele
partij alleen over. Dat wordt geweigerd aan beide kanten, en de knop biedt in
dat geval alleen nog een nee aan in plaats van een ja die toch een fout geeft.

Een kind op DEZELFDE instance kreeg de Undo niet: een inbox op deze machine is
van deze machine niet over HTTP bereikbaar, en dat hoort ook niet. De commit-kant
lost dat al zo op dat elke instance schrijft wat hij host; het loslaten doet dat
nu ook. In de browser gevonden nadat de guardian-kant leeg was en de kant van het
kind nog niet.

Een guardian-app kan hetzelfde over C2S: een Undo naar de eigen outbox loopt
langs precies dezelfde functie als de knop in de PWA, zodat die twee niet uit
elkaar kunnen groeien.

Changed files:
src/services/guardianship/handshake.js

  • endGuardianship: bouwt en verstuurt de Undo, weigert emancipatie, en schrijft de kant van een lokaal gehost kind zelf
  • applyInboundUndo + dropGuardianFromWard: de ontvangende kant
  • handleOutbox accepteert Undo; handleInbox routeert hem

src/services/guardianship/index.js

  • endGuardianship en parseUndoRelationship geexporteerd

src/services/ActivityPubService.js

  • de guardianship-dispatch ziet Undo nu voordat de generieke Undo-tak hem opslokt met een 202
  • push voor een vertrokken guardian en een vertrokken mede-guardian

src/routes/guardian.js

  • /wards/remove loopt langs endGuardianship in plaats van een lokale delete
  • release-check meldt niet langer dat de Undo blijft liggen

src/assets/js/guardian.js

  • geen ja-knop meer als jij de laatste bent; een 409 wordt getoond in plaats van stil hertekend

src/services/i18n.js

  • de waarschuwing klopt weer, plus push-teksten in nl, en, de

test/guardianship.test.js

  • zes tests: de Undo werkt aan beide kanten, de laatste guardian wordt geweigerd, hij is idempotent, C2S loopt hetzelfde pad, een vreemde Undo verandert niets, en een kind op dezelfde instance wordt ook bijgewerkt terwijl er niets bezorgd is

remarks: end-to-end nagekeken in de browser: na het loslaten staat guard niet
meer in shaer:guardians van het actor-document van het kind, en een POST die de
knop omzeilt krijgt 409 would_emancipate.

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

  • Property mode set to 100644
File size: 23.2 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 function renderPending() {
126 var list = document.getElementById('pending-list');
127 list.textContent = '';
128 var offers = S.offers || [];
129 offers.forEach(function (o) { list.appendChild(offerCard(o)); });
130 show('pending-section', offers.length > 0);
131 }
132
133 // ── 4. Accepted wards: one panel per child ─────────────────────────────
134 // A guardian thinks per child, not per function, so everything about one
135 // child sits behind that child's row: the gated settings, the follow requests
136 // waiting on them, their calls for help, their recent posts. The row itself
137 // carries counts, so nothing that needs an answer hides inside a closed
138 // panel.
139 var openPanels = {}; // ward uri -> open, so a refresh does not close it
140 // The follow requests and the wards' posts arrive from their own endpoints
141 // and are grouped into the panels by ward, so they are cached here rather
142 // than rendered into a section of their own.
143 var FEED = [], FOLLOWS = [];
144
145 function sectionInto(panel, title, items, empty, build) {
146 var h = el('div', 'g-panel-sec');
147 h.appendChild(el('h3', null, title));
148 if (!items.length) h.appendChild(el('p', 'g-empty small', empty));
149 else items.forEach(function (it) { h.appendChild(build(it)); });
150 panel.appendChild(h);
151 return h;
152 }
153
154 function embedsButton(w) {
155 // Gated feature: external (non-fediverse) embeds. Off by default for a
156 // ward; only a guardian can open it, and the gate is enforced server-side
157 // when the feed is built, so this button is the only thing that moves it.
158 // Shown for EVERY ward, including one on another server. There the value is
159 // unknown (it lives on the ward's server), but proposing is exactly as
160 // possible: the proposal travels, the ward's server tallies the guardians
161 // and enforces. A guardian next door must not have more say than one far
162 // away.
163 var known = w.embeds === true || w.embeds === false;
164 var emb = el('button', 'quiet small',
165 (known ? (w.embeds ? T.embeds_on : T.embeds_off) : T.embeds_propose) || 'Link previews');
166 emb.addEventListener('click', function () {
167 emb.disabled = true;
168 fetch('/guardian/wards/embeds', {
169 method: 'POST', headers: { 'Content-Type': 'application/json' },
170 body: JSON.stringify({ uri: w.other_uri, allow: known ? !w.embeds : true }),
171 }).then(function (r) { return r.json(); })
172 .then(function (j) {
173 // Not settled yet: the other guardians still have to answer.
174 if (j && j.state === 'open') {
175 emb.textContent = (T.embeds_waiting || 'waiting for the other guardians');
176 emb.disabled = true;
177 return;
178 }
179 refresh();
180 })
181 .catch(function () { emb.disabled = false; });
182 });
183 return emb;
184 }
185
186 /**
187 * The second step of releasing a ward: what it does, then yes or no.
188 *
189 * The warning is assembled from what the server found, not from a fixed
190 * sentence, because releasing means two different things (FEP-633c): stepping
191 * down while other guardians remain (§3.3), or being the last one, which is
192 * emancipation and explicitly not one guardian's call (§3.4). And as long as
193 * the Undo does not federate, the ward's server keeps listing you either way
194 * — a guardian has to know that before pressing, not after.
195 */
196 function releaseStep(w, check, host, relBtn) {
197 var uri = w.other_uri;
198 var who = handleOf(uri, w.other_handle);
199 var box = el('div', 'g-warn');
200 box.appendChild(el('strong', null, (T.release_title || 'Release {who}?').replace('{who}', who)));
201 box.appendChild(el('p', null, T.release_effect || ''));
202 if (check.last === true) box.appendChild(el('p', 'grave', T.release_last || ''));
203 else if (check.last === false) box.appendChild(el('p', null, T.release_step_down || ''));
204 else box.appendChild(el('p', 'grave', T.release_unknown || ''));
205 box.appendChild(el('p', null, T.release_local || ''));
206
207 var row = el('div', 'row');
208 var no = el('button', 'small', T.release_no || 'No');
209 no.addEventListener('click', function () {
210 host.removeChild(box);
211 relBtn.hidden = false; relBtn.disabled = false;
212 });
213 // No first: the way out should be the easy one to hit.
214 row.appendChild(no);
215 // Being the last guardian is not a warning but a wall: the server refuses
216 // it (§3.4), so offering a yes here would only produce an error. The text
217 // above already says what has to happen instead.
218 if (check.last !== true) {
219 var yes = el('button', 'danger small', T.release_yes || 'Yes');
220 yes.addEventListener('click', function () {
221 yes.disabled = true;
222 remove(uri, yes, function (err) {
223 // The guardian set can change between the check and the click.
224 yes.disabled = false;
225 box.appendChild(el('p', 'grave', err === 'would_emancipate' ? (T.release_last || '') : (T.failed || '')));
226 if (err === 'would_emancipate') yes.remove();
227 });
228 });
229 row.appendChild(yes);
230 }
231 box.appendChild(row);
232 return box;
233 }
234
235 function wardPanel(w) {
236 var uri = w.other_uri;
237 var panel = el('div', 'g-panel');
238 panel.hidden = !openPanels[uri];
239
240 var set = el('div', 'g-panel-sec');
241 set.appendChild(el('h3', null, T.settings_title || 'Settings'));
242 var setRow = el('div', 'row');
243 setRow.appendChild(embedsButton(w));
244 set.appendChild(setRow);
245 panel.appendChild(set);
246
247 sectionInto(panel, T.panel_follow || 'Follow requests',
248 FOLLOWS.filter(function (f) { return f.wardUri === uri; }),
249 T.panel_follow_empty || '', followCard);
250
251 sectionInto(panel, T.panel_help || 'Calls for help',
252 (S.help || []).filter(function (h) { return h.actor_uri === uri; }),
253 T.panel_help_empty || '', helpCard);
254
255 sectionInto(panel, T.panel_posts || 'Recent posts',
256 FEED.filter(function (p) { return p.authorUri === uri; }),
257 T.panel_posts_empty || '', feedCard);
258
259 var act = el('div', 'g-panel-sec');
260 act.appendChild(el('h3', null, T.panel_actions || 'Actions'));
261 var actRow = el('div', 'row');
262 var wave = el('button', 'small', T.wave || '👋 Wave');
263 wave.addEventListener('click', function () { sendWave(uri, wave); });
264 actRow.appendChild(wave);
265 var rel = el('button', 'quiet small', T.release);
266 // Letting a child go is a decision, not a click. It opens a step that first
267 // asks the server what releasing this particular ward actually does, then
268 // says it plainly and asks yes or no. Never window.confirm: that hides a
269 // long explanation behind an OK button people press to make it go away.
270 rel.addEventListener('click', function () {
271 rel.disabled = true;
272 // site matters: with several of your own sites the server would otherwise
273 // check this ward against the wrong one and answer "not my ward".
274 fetch('/guardian/wards/release-check?site=' + encodeURIComponent(S.site) + '&uri=' + encodeURIComponent(uri))
275 .then(function (r) { return r.json(); })
276 .then(function (c) {
277 rel.hidden = true;
278 act.appendChild(releaseStep(w, c || {}, act, rel));
279 })
280 .catch(function () { rel.disabled = false; });
281 });
282 actRow.appendChild(rel);
283 act.appendChild(actRow);
284 panel.appendChild(act);
285 return panel;
286 }
287
288 function renderWards() {
289 var list = document.getElementById('wards-list');
290 list.textContent = '';
291 var wards = S.wards || [];
292 wards.forEach(function (w) {
293 var uri = w.other_uri;
294 var card = el('div', 'g-card ward');
295 var row = el('div', 'row');
296 row.appendChild(el('span', 'who grow', handleOf(uri, w.other_handle)));
297 // Counts on the row: whatever is waiting must be visible with the panel shut.
298 var nHelp = (S.help || []).filter(function (h) { return h.actor_uri === uri; }).length;
299 var nFollow = FOLLOWS.filter(function (f) { return f.wardUri === uri; }).length;
300 if (nHelp) row.appendChild(el('span', 'tag help', '🛟 ' + nHelp));
301 if (nFollow) row.appendChild(el('span', 'tag co', nFollow + ' ' + (nFollow === 1 ? (T.badge_follow_one || '') : (T.badge_follow || ''))));
302 row.appendChild(el('span', 'tag ok', T.active));
303 var toggle = el('button', 'quiet small', openPanels[uri] ? T.panel_close : T.panel_open);
304 row.appendChild(toggle);
305 card.appendChild(row);
306 var panel = wardPanel(w);
307 card.appendChild(panel);
308 toggle.addEventListener('click', function () {
309 openPanels[uri] = !openPanels[uri];
310 panel.hidden = !openPanels[uri];
311 toggle.textContent = openPanels[uri] ? T.panel_close : T.panel_open;
312 });
313 list.appendChild(card);
314 });
315 show('wards-empty', wards.length === 0);
316 }
317
318 function sendWave(uri, btn) {
319 btn.disabled = true;
320 fetch('/guardian/api/wave', {
321 method: 'POST', headers: { 'Content-Type': 'application/json' },
322 body: JSON.stringify({ ward: uri, site: S.site }),
323 }).then(function (r) { return r.json(); })
324 .then(function (j) { btn.disabled = false; btn.textContent = (j && j.ok) ? (T.waved || '👋 sent') : (T.wave || '👋 Wave'); })
325 .catch(function () { btn.disabled = false; });
326 }
327
328 function remove(uri, btn, onError) {
329 btn.disabled = true;
330 fetch('/guardian/wards/remove', {
331 method: 'POST', headers: { 'Content-Type': 'application/json' },
332 body: JSON.stringify({ uri: uri, site: S.site }),
333 }).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
334 .then(function (res) {
335 // The server can refuse: emptying shaer:guardians is emancipation and
336 // not one guardian's call (§3.4). Say so instead of silently redrawing.
337 if (!res.ok) { if (onError) onError(res.j && res.j.error); return; }
338 refresh();
339 })
340 .catch(function () { if (onError) onError('network'); else btn.disabled = false; });
341 }
342
343 function renderAll() { renderHelp(); renderPending(); renderWards(); }
344
345 // ── 0. Wards' corner: read-only feed of your wards' posts ───────────────
346 // Lives inside each child's panel now, so the fetches only fill a cache and
347 // ask the ward list to redraw. A guardian watches, it does not publish.
348 function feedCard(p) {
349 var card = el('div', 'g-card feed');
350 var head = el('div', 'row');
351 head.appendChild(el('span', 'who grow', p.author));
352 if (p.published) head.appendChild(el('span', 'g-when', when(p, p.published)));
353 card.appendChild(head);
354 var body = el('div', 'feed-body');
355 if (p.cw) {
356 var d = document.createElement('details');
357 var sum = document.createElement('summary'); sum.textContent = p.cw; d.appendChild(sum);
358 var inner = el('div'); inner.innerHTML = p.content || ''; d.appendChild(inner);
359 body.appendChild(d);
360 } else {
361 body.innerHTML = p.content || ''; // server-sanitized HTML (same as Berichten)
362 }
363 card.appendChild(body);
364 return card;
365 }
366
367 function loadFeed() {
368 return fetch('/guardian/api/feed?site=' + encodeURIComponent(S.site))
369 .then(function (r) { return r.json(); })
370 .then(function (f) { if (f && !f.error) { FEED = f.items || []; renderWards(); } })
371 .catch(function () { /* panels just show "nothing yet" */ });
372 }
373
374 // ── 0b. Follow requests on your wards (§5.3) ────────────────────────────
375 function answerFollow(id, decision, btn) {
376 if (btn) btn.disabled = true;
377 fetch('/guardian/api/follow/' + encodeURIComponent(id), {
378 method: 'POST', headers: { 'Content-Type': 'application/json' },
379 body: JSON.stringify({ decision: decision, site: S.site }),
380 }).then(loadFollowReqs);
381 }
382 function followCard(f) {
383 var card = el('div', 'g-card');
384 var row = el('div', 'row');
385 // Inside the child's own panel the ward name is a given, so only the
386 // person asking is named here.
387 row.appendChild(el('span', 'who grow', f.follower));
388 var ok = el('button', 'small', T.accept || 'Accept');
389 ok.addEventListener('click', function () { answerFollow(f.id, 'approve', ok); });
390 var no = el('button', 'quiet small', T.reject || 'Deny');
391 no.addEventListener('click', function () { answerFollow(f.id, 'reject', no); });
392 row.appendChild(ok); row.appendChild(no);
393 card.appendChild(row);
394 return card;
395 }
396 function loadFollowReqs() {
397 return fetch('/guardian/api/follow-requests?site=' + encodeURIComponent(S.site))
398 .then(function (r) { return r.json(); })
399 .then(function (f) { if (f && !f.error) { FOLLOWS = f.items || []; renderWards(); } })
400 .catch(function () { /* panels just show "none waiting" */ });
401 }
402
403 function refresh() {
404 return fetch('/guardian/api/state?site=' + encodeURIComponent(S.site))
405 .then(function (r) { return r.json(); })
406 .then(function (s) { if (s && !s.error) { S = s; T = s.strings || T; renderAll(); } })
407 .then(loadFeed).then(loadFollowReqs);
408 }
409
410 // ── 2. Adopt ───────────────────────────────────────────────────────────
411 var form = document.getElementById('adopt-form');
412 var input = document.getElementById('adopt-handle');
413 var adoptBtn = document.getElementById('adopt-btn');
414 var msg = document.getElementById('adopt-msg');
415 function setMsg(text, isErr) { msg.hidden = false; msg.className = 'g-msg' + (isErr ? ' err' : ''); msg.textContent = text; }
416
417 form.addEventListener('submit', function (ev) {
418 ev.preventDefault();
419 var handle = input.value.trim();
420 if (!handle) return;
421 adoptBtn.disabled = true;
422 setMsg(T.sending || '…', false);
423 fetch('/guardian/adopt', {
424 method: 'POST', headers: { 'Content-Type': 'application/json' },
425 body: JSON.stringify({ handle: handle, site: S.site }),
426 }).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
427 .then(function (res) {
428 adoptBtn.disabled = false;
429 if (res.ok) {
430 input.value = '';
431 // Always refresh: the offer is recorded even if delivery is still
432 // in flight. Show it under "Verzonden aanvragen".
433 setMsg(res.j.delivered === false ? T.sent_retry : T.sent, false);
434 refresh();
435 } else {
436 setMsg((res.j.error === 'not_found' ? T.not_found : T.failed) , true);
437 }
438 })
439 .catch(function () { adoptBtn.disabled = false; setMsg(T.network, true); });
440 });
441
442 // ── Site picker ────────────────────────────────────────────────────────
443 var picker = document.getElementById('site-picker');
444 if (picker) picker.addEventListener('change', function () {
445 location.href = '/guardian?site=' + encodeURIComponent(picker.value);
446 });
447
448 // ── 5. Push ────────────────────────────────────────────────────────────
449 var toggle = document.getElementById('push-toggle');
450 var pmsg = document.getElementById('push-msg');
451 function pushState() {
452 if (!('serviceWorker' in navigator) || !('PushManager' in window)) { toggle.disabled = true; return; }
453 navigator.serviceWorker.register('/sw.js').catch(function () {});
454 navigator.serviceWorker.ready
455 .then(function (reg) { return reg.pushManager.getSubscription(); })
456 .then(function (sub) {
457 toggle.textContent = sub ? toggle.dataset.onLabel : toggle.dataset.offLabel;
458 toggle.dataset.subscribed = sub ? '1' : '';
459 toggle.classList.toggle('is-on', !!sub);
460 });
461 }
462 function urlB64(base64) {
463 var pad = '='.repeat((4 - (base64.length % 4)) % 4);
464 var b = (base64 + pad).replace(/-/g, '+').replace(/_/g, '/');
465 var raw = atob(b); var arr = new Uint8Array(raw.length);
466 for (var i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
467 return arr;
468 }
469 toggle.addEventListener('click', function () {
470 pmsg.hidden = true;
471 navigator.serviceWorker.ready.then(function (reg) {
472 if (toggle.dataset.subscribed) {
473 reg.pushManager.getSubscription().then(function (sub) {
474 if (!sub) return;
475 fetch('/push/unsubscribe', {
476 method: 'POST', headers: { 'Content-Type': 'application/json' },
477 body: JSON.stringify({ endpoint: sub.endpoint }),
478 }).then(function () { return sub.unsubscribe(); }).then(pushState);
479 });
480 return;
481 }
482 fetch('/push/vapid').then(function (r) { return r.json(); }).then(function (v) {
483 if (!v.publicKey) throw new Error('no key');
484 return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlB64(v.publicKey) });
485 }).then(function (sub) {
486 return fetch('/push/subscribe', {
487 method: 'POST', headers: { 'Content-Type': 'application/json' },
488 body: JSON.stringify({
489 subscription: sub.toJSON(),
490 alerts: { help: 1, guardian: 1, dm: 1, follow: 0, reply: 0, like: 0, boost: 0 },
491 uaLabel: 'Guardian PWA',
492 }),
493 });
494 }).then(pushState).catch(function (e) {
495 pmsg.hidden = false; pmsg.className = 'g-msg err';
496 pmsg.textContent = (T.push_unavailable || 'Push unavailable') + ': ' + e.message;
497 });
498 });
499 });
500
501 renderAll(); pushState(); loadFeed(); loadFollowReqs();
502 setInterval(refresh, 45000); // live-ish while open
503 } catch (e) {
504 fatal((e && e.message) || String(e));
505 }
506})();
Note: See TracBrowser for help on using the repository browser.