source: Klonkt/src/assets/js/guardian.js@ a7bcf66

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

De Guardian PWA liep twee uur achter

Op sound-fabrics staat de tijdzone op Europe/Amsterdam, maar een hulpvraag van
20:20 stond in de PWA als 18:20. Het dashboard bouwt zijn kaarten in de browser
en sneed de rauwe UTC-string af (slice(0,16)) in plaats van hem om te rekenen.
De server geeft de tijd nu geformatteerd mee, met dezelfde formatDateTime die de
Krant en Berichten gebruiken; het afsnijden blijft alleen als terugval staan.

In formatDateTime zat een tweede probleem, dat nog niet zichtbaar was. SQLite
schrijft CURRENT_TIMESTAMP als UTC zonder dat erbij te zeggen ("2026-07-28
18:20:33"), en new Date() leest een string in die vorm als LOKALE tijd. Dat gaat
goed zolang de machine op UTC staat, wat nu toevallig zo is. Zet de VPS ooit op
Amsterdam en elke opgeslagen datum in de hele app schuift twee uur op. De parser
zegt nu expliciet UTC.

Onderweg bleek Berichten en de PWA ook niet dezelfde tijd te tonen voor dezelfde
post: 20:12 tegenover 20:20. Berichten liet zien wanneer wij de post ontvingen,
de PWA en de Krant wanneer hij geschreven is. Berichten toont nu ook de
publicatietijd. Sorteren en de "nieuw sinds je laatste bezoek"-stip blijven op
de ontvangsttijd: een post die laat federeert is nog steeds nieuw voor jou.

Changed files:
src/middleware/render.js

  • parseStamp leest een tijdstempel zonder zone als UTC
  • formatDateTime geexporteerd voor oppervlakken buiten de EJS-pagina's

src/routes/guardian.js

  • when_text bij hulpvragen en bij de tijdlijn van de wards

src/assets/js/guardian.js

  • when() gebruikt dat veld, afsnijden alleen nog als terugval

src/services/ActivityPubService.js

  • getNotifications geeft published mee naast created_at

src/views/partials/msg-item.ejs

  • toont de publicatietijd, met created_at als terugval

New file:
test/timestamps.test.js

  • de tijdzone-instelling wordt toegepast, en een SQLite-tijdstempel hangt niet af van de tijdzone van de machine

remarks: geverifieerd op de wegwerp-database met Europe/Amsterdam: Berichten en
de PWA tonen allebei 28 jul 2026, 20:20 voor dezelfde post.

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

  • Property mode set to 100644
File size: 16.8 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 ───────────────────────────────────────────────────
[318d0c2]45 function renderHelp() {
46 var list = document.getElementById('help-list');
47 list.textContent = '';
[c26cc18]48 var help = S.help || [];
49 help.forEach(function (h) {
[318d0c2]50 var card = el('div', 'g-card help');
51 var row = el('div', 'row');
[d9ad6c5]52 var who = el('span', 'who grow');
53 // name_html carries the custom emojis (FEP-9098) of the display name, the
54 // same way de Krant renders a byline. Falls back to the plain name.
55 if (h.name_html) who.innerHTML = h.name_html;
56 else who.textContent = h.actor_name || handleOf(h.actor_uri, h.actor_handle);
57 row.appendChild(who);
[a7bcf66]58 row.appendChild(el('span', 'when', when(h, h.published || h.created_at)));
[318d0c2]59 card.appendChild(row);
[d9ad6c5]60 var body = el('div', 'body g-note');
61 // body_html is the shared note-body partial, rendered server-side: the
62 // content with its emojis, the quote / link-preview card and the media.
63 // Falls back to the bare content for rows stored before that existed.
64 body.innerHTML = h.body_html || h.content || ''; // sanitized server-side on ingest
[318d0c2]65 card.appendChild(body);
66 if (h.note_url) {
[c26cc18]67 var a = el('a', 'g-link', T.open || 'open');
68 a.href = h.note_url; a.target = '_blank'; a.rel = 'noopener';
69 card.appendChild(a);
[318d0c2]70 }
71 list.appendChild(card);
72 });
[c26cc18]73 var badge = document.getElementById('help-count');
74 badge.textContent = help.length; badge.hidden = help.length === 0;
75 show('help-empty', help.length === 0);
[318d0c2]76 }
77
[780a7c6]78 // ── 3. Offers I am a party to (sent, or a co-guardianship to co-approve) ─
79 function answer(offerId, decision, btn) {
80 if (btn) btn.disabled = true;
81 fetch('/guardian/offer', {
82 method: 'POST', headers: { 'Content-Type': 'application/json' },
83 body: JSON.stringify({ offer: offerId, answer: decision, site: S.site }),
84 }).then(refresh);
85 }
86 function offerCard(o) {
87 var card = el('div', 'g-card');
88 var row = el('div', 'row');
89 var subject = o['shaer:iAmCandidate']
90 ? handleOf(o['shaer:ward'], o['shaer:wardHandle']) // my sent offer: about the ward
91 : handleOf(o['shaer:candidate'], o['shaer:candidateHandle']); // co-guard: who wants in
92 row.appendChild(el('span', 'who grow', subject));
93 if (o['shaer:iAmCandidate']) {
94 // My own offer, waiting for the others to accept.
95 row.appendChild(el('span', 'tag wait', T.pending));
96 var rt = el('button', 'quiet small', T.retract);
97 rt.addEventListener('click', function () { answer(o.id, 'reject', rt); });
98 row.appendChild(rt);
99 } else if (o['shaer:needsMyAccept']) {
100 // A co-guardianship offer for a ward I already guard: my call.
101 row.appendChild(el('span', 'tag co', T.coguard));
102 var ac = el('button', 'small', T.accept);
103 ac.addEventListener('click', function () { answer(o.id, 'accept', ac); });
104 var rj = el('button', 'quiet small', T.reject);
105 rj.addEventListener('click', function () { answer(o.id, 'reject', rj); });
106 row.appendChild(ac); row.appendChild(rj);
107 } else {
108 row.appendChild(el('span', 'tag wait', T.awaiting_others));
109 }
110 card.appendChild(row);
111 return card;
112 }
[c26cc18]113 function renderPending() {
114 var list = document.getElementById('pending-list');
115 list.textContent = '';
[780a7c6]116 var offers = S.offers || [];
117 offers.forEach(function (o) { list.appendChild(offerCard(o)); });
118 show('pending-section', offers.length > 0);
[318d0c2]119 }
[c26cc18]120
121 // ── 4. Accepted wards ──────────────────────────────────────────────────
[318d0c2]122 function renderWards() {
[c26cc18]123 var list = document.getElementById('wards-list');
124 list.textContent = '';
125 var wards = S.wards || [];
126 wards.forEach(function (w) {
127 var card = el('div', 'g-card');
128 var row = el('div', 'row');
129 row.appendChild(el('span', 'who grow', handleOf(w.other_uri, w.other_handle)));
130 row.appendChild(el('span', 'tag ok', T.active));
[f1c50f9]131 var wave = el('button', 'small', T.wave || '👋 Wave');
132 wave.addEventListener('click', function () { sendWave(w.other_uri, wave); });
133 row.appendChild(wave);
[2a76184]134 // Gated feature: external (non-fediverse) embeds. Off by default for a
135 // ward; only a guardian can open it, and the gate is enforced server-side
136 // when the feed is built, so this button is the only thing that moves it.
[65abc85]137 // Shown for EVERY ward, including one on another server. There the value
138 // is unknown (it lives on the ward's server), but proposing is exactly as
139 // possible: the proposal travels, the ward's server tallies the guardians
140 // and enforces. A guardian next door must not have more say than one far
141 // away.
142 var known = w.embeds === true || w.embeds === false;
143 var emb = el('button', 'quiet small',
144 (known ? (w.embeds ? T.embeds_on : T.embeds_off) : T.embeds_propose) || 'Link previews');
145 emb.addEventListener('click', function () {
146 emb.disabled = true;
147 fetch('/guardian/wards/embeds', {
148 method: 'POST', headers: { 'Content-Type': 'application/json' },
149 body: JSON.stringify({ uri: w.other_uri, allow: known ? !w.embeds : true }),
150 }).then(function (r) { return r.json(); })
151 .then(function (j) {
152 // Not settled yet: the other guardians still have to answer.
153 if (j && j.state === 'open') {
154 emb.textContent = (T.embeds_waiting || 'waiting for the other guardians');
155 emb.disabled = true;
156 return;
157 }
158 refresh();
159 })
160 .catch(function () { emb.disabled = false; });
161 });
162 row.appendChild(emb);
[c26cc18]163 var btn = el('button', 'quiet small', T.release);
[c628dcd4]164 // Releasing a ward is heavy and hard to undo (coming back needs a fresh
165 // offer the ward accepts), so it asks first and spells out what changes.
166 btn.addEventListener('click', function () {
167 var who = handleOf(w.other_uri, w.other_handle);
168 var msg = (T.release_confirm || 'Release {who}?').replace('{who}', who);
169 if (window.confirm(msg)) remove(w.other_uri, btn);
170 });
[c26cc18]171 row.appendChild(btn);
172 card.appendChild(row);
173 list.appendChild(card);
174 });
175 show('wards-empty', wards.length === 0);
176 }
177
[f1c50f9]178 function sendWave(uri, btn) {
179 btn.disabled = true;
180 fetch('/guardian/api/wave', {
181 method: 'POST', headers: { 'Content-Type': 'application/json' },
182 body: JSON.stringify({ ward: uri, site: S.site }),
183 }).then(function (r) { return r.json(); })
184 .then(function (j) { btn.disabled = false; btn.textContent = (j && j.ok) ? (T.waved || '👋 sent') : (T.wave || '👋 Wave'); })
185 .catch(function () { btn.disabled = false; });
186 }
187
[c26cc18]188 function remove(uri, btn) {
189 btn.disabled = true;
190 fetch('/guardian/wards/remove', {
191 method: 'POST', headers: { 'Content-Type': 'application/json' },
192 body: JSON.stringify({ uri: uri, site: S.site }),
193 }).then(refresh);
[318d0c2]194 }
195
[c26cc18]196 function renderAll() { renderHelp(); renderPending(); renderWards(); }
197
[f1c50f9]198 // ── 0. Wards' corner: read-only feed of your wards' posts ───────────────
199 function renderFeed(items) {
200 var list = document.getElementById('feed-list');
201 list.textContent = '';
202 (items || []).forEach(function (p) {
203 var card = el('div', 'g-card feed');
204 var head = el('div', 'row');
205 head.appendChild(el('span', 'who grow', p.author));
[a7bcf66]206 if (p.published) head.appendChild(el('span', 'g-when', when(p, p.published)));
[f1c50f9]207 card.appendChild(head);
208 var body = el('div', 'feed-body');
209 if (p.cw) {
210 var d = document.createElement('details');
211 var sum = document.createElement('summary'); sum.textContent = p.cw; d.appendChild(sum);
212 var inner = el('div'); inner.innerHTML = p.content || ''; d.appendChild(inner);
213 body.appendChild(d);
214 } else {
215 body.innerHTML = p.content || ''; // server-sanitized HTML (same as Berichten)
216 }
217 card.appendChild(body);
218 list.appendChild(card);
219 });
220 show('feed-section', (items || []).length > 0);
221 }
222
223 function loadFeed() {
224 return fetch('/guardian/api/feed?site=' + encodeURIComponent(S.site))
225 .then(function (r) { return r.json(); })
226 .then(function (f) { if (f && !f.error) renderFeed(f.items); })
227 .catch(function () { /* corner just stays hidden */ });
228 }
229
230 // ── 0b. Follow requests on your wards (§5.3) ────────────────────────────
231 function answerFollow(id, decision, btn) {
232 if (btn) btn.disabled = true;
233 fetch('/guardian/api/follow/' + encodeURIComponent(id), {
234 method: 'POST', headers: { 'Content-Type': 'application/json' },
235 body: JSON.stringify({ decision: decision, site: S.site }),
236 }).then(loadFollowReqs);
237 }
238 function renderFollowReqs(items) {
239 var list = document.getElementById('follow-list');
240 list.textContent = '';
241 (items || []).forEach(function (f) {
242 var card = el('div', 'g-card');
243 var row = el('div', 'row');
244 row.appendChild(el('span', 'who grow', f.follower + ' → ' + f.ward));
245 var ok = el('button', 'small', T.accept || 'Accept');
246 ok.addEventListener('click', function () { answerFollow(f.id, 'approve', ok); });
247 var no = el('button', 'quiet small', T.reject || 'Deny');
248 no.addEventListener('click', function () { answerFollow(f.id, 'reject', no); });
249 row.appendChild(ok); row.appendChild(no);
250 card.appendChild(row);
251 list.appendChild(card);
252 });
253 show('follow-section', (items || []).length > 0);
254 }
255 function loadFollowReqs() {
256 return fetch('/guardian/api/follow-requests?site=' + encodeURIComponent(S.site))
257 .then(function (r) { return r.json(); })
258 .then(function (f) { if (f && !f.error) renderFollowReqs(f.items); })
259 .catch(function () { /* stays hidden */ });
260 }
261
[318d0c2]262 function refresh() {
[c26cc18]263 return fetch('/guardian/api/state?site=' + encodeURIComponent(S.site))
[318d0c2]264 .then(function (r) { return r.json(); })
[f1c50f9]265 .then(function (s) { if (s && !s.error) { S = s; T = s.strings || T; renderAll(); } })
266 .then(loadFeed).then(loadFollowReqs);
[318d0c2]267 }
268
[c26cc18]269 // ── 2. Adopt ───────────────────────────────────────────────────────────
270 var form = document.getElementById('adopt-form');
271 var input = document.getElementById('adopt-handle');
272 var adoptBtn = document.getElementById('adopt-btn');
273 var msg = document.getElementById('adopt-msg');
274 function setMsg(text, isErr) { msg.hidden = false; msg.className = 'g-msg' + (isErr ? ' err' : ''); msg.textContent = text; }
275
276 form.addEventListener('submit', function (ev) {
[318d0c2]277 ev.preventDefault();
278 var handle = input.value.trim();
279 if (!handle) return;
[c26cc18]280 adoptBtn.disabled = true;
281 setMsg(T.sending || '…', false);
[318d0c2]282 fetch('/guardian/adopt', {
283 method: 'POST', headers: { 'Content-Type': 'application/json' },
[c26cc18]284 body: JSON.stringify({ handle: handle, site: S.site }),
[318d0c2]285 }).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
286 .then(function (res) {
[c26cc18]287 adoptBtn.disabled = false;
288 if (res.ok) {
289 input.value = '';
290 // Always refresh: the offer is recorded even if delivery is still
291 // in flight. Show it under "Verzonden aanvragen".
292 setMsg(res.j.delivered === false ? T.sent_retry : T.sent, false);
293 refresh();
294 } else {
295 setMsg((res.j.error === 'not_found' ? T.not_found : T.failed) , true);
296 }
[318d0c2]297 })
[c26cc18]298 .catch(function () { adoptBtn.disabled = false; setMsg(T.network, true); });
[318d0c2]299 });
300
301 // ── Site picker ────────────────────────────────────────────────────────
302 var picker = document.getElementById('site-picker');
303 if (picker) picker.addEventListener('change', function () {
304 location.href = '/guardian?site=' + encodeURIComponent(picker.value);
305 });
306
[c26cc18]307 // ── 5. Push ────────────────────────────────────────────────────────────
[318d0c2]308 var toggle = document.getElementById('push-toggle');
309 var pmsg = document.getElementById('push-msg');
310 function pushState() {
311 if (!('serviceWorker' in navigator) || !('PushManager' in window)) { toggle.disabled = true; return; }
312 navigator.serviceWorker.register('/sw.js').catch(function () {});
313 navigator.serviceWorker.ready
314 .then(function (reg) { return reg.pushManager.getSubscription(); })
315 .then(function (sub) {
316 toggle.textContent = sub ? toggle.dataset.onLabel : toggle.dataset.offLabel;
317 toggle.dataset.subscribed = sub ? '1' : '';
[c26cc18]318 toggle.classList.toggle('is-on', !!sub);
[318d0c2]319 });
320 }
321 function urlB64(base64) {
322 var pad = '='.repeat((4 - (base64.length % 4)) % 4);
323 var b = (base64 + pad).replace(/-/g, '+').replace(/_/g, '/');
324 var raw = atob(b); var arr = new Uint8Array(raw.length);
325 for (var i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
326 return arr;
327 }
328 toggle.addEventListener('click', function () {
329 pmsg.hidden = true;
330 navigator.serviceWorker.ready.then(function (reg) {
331 if (toggle.dataset.subscribed) {
332 reg.pushManager.getSubscription().then(function (sub) {
333 if (!sub) return;
334 fetch('/push/unsubscribe', {
335 method: 'POST', headers: { 'Content-Type': 'application/json' },
336 body: JSON.stringify({ endpoint: sub.endpoint }),
337 }).then(function () { return sub.unsubscribe(); }).then(pushState);
338 });
339 return;
340 }
341 fetch('/push/vapid').then(function (r) { return r.json(); }).then(function (v) {
342 if (!v.publicKey) throw new Error('no key');
343 return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlB64(v.publicKey) });
344 }).then(function (sub) {
345 return fetch('/push/subscribe', {
346 method: 'POST', headers: { 'Content-Type': 'application/json' },
347 body: JSON.stringify({
348 subscription: sub.toJSON(),
349 alerts: { help: 1, guardian: 1, dm: 1, follow: 0, reply: 0, like: 0, boost: 0 },
350 uaLabel: 'Guardian PWA',
351 }),
352 });
353 }).then(pushState).catch(function (e) {
[c26cc18]354 pmsg.hidden = false; pmsg.className = 'g-msg err';
355 pmsg.textContent = (T.push_unavailable || 'Push unavailable') + ': ' + e.message;
[318d0c2]356 });
357 });
358 });
359
[f1c50f9]360 renderAll(); pushState(); loadFeed(); loadFollowReqs();
[c26cc18]361 setInterval(refresh, 45000); // live-ish while open
[fcd6964]362 } catch (e) {
363 fatal((e && e.message) || String(e));
364 }
[318d0c2]365})();
Note: See TracBrowser for help on using the repository browser.