source: Klonkt/src/routes/ward.js@ a5a6249

Last change on this file since a5a6249 was a5a6249, checked in by Robin <roboburr@…>, 3 months ago

De Ward PWA: de feed en de reddingsboei

Plak 2. Nu valt er iets te doen op /ward in plaats van alleen te kijken.

DE FEED. Dezelfde bron als de Krant (getTimeline) en dezelfde partial
voor de body (renderNoteBody), zodat een post er overal hetzelfde uitziet:
opmaak, media, quote-kaart, embed. Alleen kale content doorgeven zou een
lege regel opleveren waar een foto hoort, precies de fout die het
guardian-dashboard ooit had.

DE BOEI, en die is hier BETER dan in de app. Shaer maakt een
schermafdruk van de kaart, met al het gedoe van buiten beeld renderen.
Op de server weten we wélke note het is, dus de hulpvraag draagt de note
zelf mee. Een guardian ziet daardoor de echte post en niet een plaatje
dat er ooit op leek.

Slepen met pointer-events, want dat is het enige dat op een telefoon en
met een muis hetzelfde doet. touch-action: none op de boei houdt de
pagina stil tijdens het slepen; zonder dat vecht het met scrollen en
verlies je hem halverwege. De kaart eronder licht op, want mikken met een
vinger zonder terugkoppeling is gokken. Een tik zonder slepen vraagt hulp
zonder dat er een post bij hoort, en de grote knop bovenaan doet
hetzelfde.

Changed files:
src/routes/ward.js

  • /api/feed met afgemaakte body per kaart
  • /api/help: direct bericht aan ALLE guardians, met de note erbij; 409 als er geen guardians zijn, want dan is er niets te vragen

src/views/pages/ward.ejs

  • feed-sectie, zwevende boei, hulpdialoog

src/assets/js/ward.js

  • kaarten tekenen, slepen, mikken, versturen

src/assets/css/ward.css

  • kaarten, boei, dialoog, sr-only

src/services/i18n.js

  • acht nieuwe sleutels in nl, en, de

test/ward-pwa.test.js

  • de feed levert een gerenderde body; de boei weigert zonder guardians; een vreemde kan hem niet afvuren

remarks: 889 tests groen. Slepen is alleen in code getest, niet met een
echte vinger op een echt scherm; dat is aan Robin op dev.

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

  • Property mode set to 100644
File size: 11.0 KB
RevLine 
[303bfc4]1/**
2 * De Ward PWA (FEP-633c): de tegenhanger van /guardian, maar dan vanuit het
3 * kind gezien. Shaer doet dit als native app; dit is dezelfde ward-kant in de
4 * browser, voor wie geen app kan of wil installeren.
5 *
6 * Plak 1 is bewust HET VANGNET en niet de sociale kant: wie over mij waakt,
7 * of dat vangnet ook echt heel is, wat er bij mijn guardians ligt te wachten,
8 * en de knop om hulp te vragen. De feed en de composer heeft Klonkt al op de
9 * gewone site; die dubbelen we hier niet zomaar.
10 *
11 * Alles is gescoped op een site die de ingelogde gebruiker BEZIT, precies als
12 * bij /guardian (?site=slug kiest er een als je er meerdere hebt). Views
13 * dragen geen inline scripts (CSP): de logica staat in /assets/js/ward.js.
14 */
15import express from 'express';
16import path from 'path';
17import { fileURLToPath } from 'url';
18import db from '../config/database.js';
19import { requireAuth } from '../middleware/auth.js';
20import AP from '../services/ActivityPubService.js';
21import * as Guardianship from '../services/guardianship/index.js';
22// offers en carriesGuardians komen NIET als naamruimte uit index.js (die
23// exporteert er losse namen van), dus rechtstreeks uit hun eigen module.
24import * as Offers from '../services/guardianship/offers.js';
25import { carriesGuardians } from '../services/guardianship/context.js';
26import { t as i18nT, resolveLang } from '../services/i18n.js';
[a5a6249]27import { injectCspNonce, renderNoteBody, formatDateTime } from '../middleware/render.js';
[303bfc4]28
29const router = express.Router();
30const __dir = path.dirname(fileURLToPath(import.meta.url));
31
32/** De handelende site: ?site=slug als je hem bezit, anders je eerste. */
33function siteForUser(req) {
34 const userId = req.session.user.id;
35 const want = String(req.query.site || req.body?.site || '').trim();
36 if (want) {
37 const s = db.prepare('SELECT * FROM sites WHERE slug = ? AND owner_id = ?').get(want, userId);
38 if (s) return s;
39 }
40 return db.prepare('SELECT * FROM sites WHERE owner_id = ? ORDER BY id LIMIT 1').get(userId);
41}
42
43/**
44 * Alles wat het scherm in één keer nodig heeft.
45 *
46 * Bewust ZONDER netwerk: dit draait bij elke paginaweergave, en een trage
47 * guardian-server mag de eigen pagina van een kind niet ophouden. Wat een
48 * remote fetch vraagt (de §4-controle) zit in een eigen endpoint dat de app
49 * ná het tekenen ophaalt.
50 */
51function wardState(site, L) {
52 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
53 const me = AP.actorId(base, site.slug);
54 const guardianRows = Guardianship.listGuardians(site.slug);
55 const uris = guardianRows.map((g) => g.other_uri).filter(Boolean);
56 const statuses = Guardianship.availability.statusesFor(site.slug, uris, Date.now());
57 const byUri = new Map(statuses.map((s) => [s.id || s.uri, s]));
58
59 return {
60 me,
61 slug: site.slug,
62 // Wie over mij waakt. De beschikbaarheid staat erbij omdat een guardian
63 // die weg is de echte maat van je vangnet verandert (§3.6).
64 guardians: guardianRows.map((g) => {
65 const st = byUri.get(g.other_uri) || {};
66 return {
67 uri: g.other_uri,
68 handle: g.other_handle || AP.deriveHandleFor?.(g.other_uri) || g.other_uri,
69 availability: st['shaer:availability'] || st.availability || 'active',
70 awayUntil: st['shaer:awayUntil'] || st.awayUntil || null,
71 };
72 }),
73 // Aanbiedingen waar IK een partij in ben: iemand wil mijn guardian worden,
74 // of er loopt een verzoek om er een te laten gaan.
75 offers: Offers.listForParty(site.slug, me).map((o) => Offers.queueItem(o, me)),
76 // Wat ik wilde volgen en wat bij mijn guardians ligt (§5.3 uitgaand).
77 // Dit is de derde uitkomst die Shaer ook toont: niet verstuurd, niet
78 // mislukt, maar wachtend op iemand die je kent.
79 waitingFollows: Guardianship.outgoing.listForWard(site.slug).map((o) => ({
80 id: o.id,
81 target: o.target_uri,
82 handle: o.target_handle || o.target_uri,
83 status: o.status,
84 createdAt: o.created_at,
85 })),
86 strings: {
87 title: i18nT(L, 'ward.app_name'),
88 guardians: i18nT(L, 'ward.guardians'),
89 noGuardians: i18nT(L, 'ward.no_guardians'),
90 offers: i18nT(L, 'ward.offers'),
91 waiting: i18nT(L, 'ward.waiting'),
92 help: i18nT(L, 'ward.help'),
[a5a6249]93 feed: i18nT(L, 'ward.feed'),
94 feedEmpty: i18nT(L, 'ward.feed_empty'),
95 buoyHint: i18nT(L, 'ward.buoy_hint'),
96 helpWords: i18nT(L, 'ward.help_words'),
97 helpSend: i18nT(L, 'ward.help_send'),
98 helpSent: i18nT(L, 'ward.help_sent'),
99 cancel: i18nT(L, 'ward.cancel'),
[303bfc4]100 },
101 };
102}
103
104// ── De PWA-pagina ────────────────────────────────────────────────────────
105router.get('/', requireAuth, (req, res) => {
106 const site = siteForUser(req);
107 const L = resolveLang(req);
108 if (!site) return res.status(404).send('No site for this account.');
109 // Losse PWA-pagina, dus NIET via renderPage: de CSP-nonce moet hier zelf
110 // geïnjecteerd worden, anders blokkeert strict-dynamic ward.js en doet geen
111 // enkele knop iets (zelfde valkuil als bij /guardian).
112 res.render('pages/ward', {
113 state: wardState(site, L),
114 lang: L,
115 t: (k, v) => i18nT(L, k, v),
116 cspNonce: res.locals.cspNonce,
117 }, (err, html) => {
118 if (err) { console.error('[ward] render error', err); return res.status(500).send('Internal Server Error'); }
119 res.send(injectCspNonce(html, res.locals.cspNonce));
120 });
121});
122
123// ── JSON voor verversen ──────────────────────────────────────────────────
124router.get('/api/state', requireAuth, (req, res) => {
125 const site = siteForUser(req);
126 if (!site) return res.status(404).json({ error: 'no_site' });
127 res.json(wardState(site, resolveLang(req)));
128});
129
130/**
131 * De §4-controle: is mijn vangnet wel heel?
132 *
133 * Een "guardian" met eigen guardians is er geen (§1), en een escalatie
134 * daarheen komt nergens aan. Dat is precies wat een ward moet kunnen zien.
135 * Apart endpoint omdat het per guardian een remote fetch kost; de pagina
136 * tekent eerst en vult dit daarna aan.
137 *
138 * Drie uitkomsten, net als in de apps: in orde, misvormd, of onbereikbaar.
139 * Onbereikbaar is géén goedkeuring, dus het krijgt een eigen waarde in plaats
140 * van te ontbreken.
141 */
142router.get('/api/guardian-checks', requireAuth, async (req, res) => {
143 const site = siteForUser(req);
144 if (!site) return res.status(404).json({ error: 'no_site' });
145 const uris = Guardianship.listGuardians(site.slug).map((g) => g.other_uri).filter(Boolean);
146 const out = {};
147 await Promise.all(uris.map(async (uri) => {
148 const doc = await AP.fetchActor(uri).catch(() => null);
149 if (!doc) { out[uri] = 'unreachable'; return; }
150 out[uri] = carriesGuardians(doc) ? 'malformed' : 'sound';
151 }));
152 res.json({ checks: out });
153});
154
[a5a6249]155/**
156 * De feed: wat er binnenkomt van de mensen die je volgt.
157 *
158 * Zelfde bron en dezelfde partial als de Krant, Berichten en het
159 * guardian-dashboard (renderNoteBody), zodat een post er overal hetzelfde
160 * uitziet: opmaak, media, quote-kaart en embed. Kale content sturen zou hier
161 * een lege regel opleveren waar een foto hoort.
162 */
163router.get('/api/feed', requireAuth, (req, res) => {
164 const site = siteForUser(req);
165 if (!site) return res.status(404).json({ error: 'no_site' });
166 const L = resolveLang(req);
167 const items = AP.getTimeline(site.slug, 60, 0).map((p) => ({
168 id: p.id,
169 uri: p.object_uri || p.url,
170 author: p.author_handle || p.author_name || p.author_uri,
171 authorName: p.author_name,
172 authorIcon: p.author_icon,
173 url: p.url,
174 published: p.published || p.created_at,
175 whenText: formatDateTime(p.published || p.created_at),
176 cw: p.cw || null,
177 bodyHtml: renderNoteBody(p, L),
178 }));
179 res.json({ items });
180});
181
182/**
183 * De reddingsboei (§5.2.1): sleep hem op een post en je guardians krijgen hem.
184 *
185 * Shaer maakt hier een SCHERMAFDRUK van de kaart, met al het gedoe van buiten
186 * beeld renderen. Op de server is dat onnodig: wij weten wélke note het is, dus
187 * de hulpvraag draagt de note zelf mee. Dat is niet alleen minder werk maar ook
188 * eerlijker: een guardian ziet de echte post en niet een plaatje dat er ooit op
189 * leek.
190 *
191 * Gaat naar ALLE guardians tegelijk, als direct bericht. Geen post, nooit
192 * boostbaar. Zonder guardians is er niets om te vragen en zegt dat het ook.
193 */
194router.post('/api/help', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
195 const site = siteForUser(req);
196 if (!site) return res.status(404).json({ error: 'no_site' });
197 const guardians = Guardianship.listGuardians(site.slug).map((g) => g.other_uri).filter(Boolean);
198 if (!guardians.length) return res.status(409).json({ error: 'no_guardians' });
199
200 const noteId = String(req.body?.note || '').trim();
201 const words = String(req.body?.words || '').trim().slice(0, 500);
202 // De post waar het om gaat, als die van deze tijdlijn komt. Niet verplicht:
203 // een kind mag ook om hulp vragen zonder dat er een post bij hoort.
204 const row = noteId ? AP.getTimeline(site.slug, 200, 0).find((p) => String(p.id) === noteId) : null;
205 const link = row && (row.url || row.object_uri);
206
207 const text = [words || 'Ik heb hulp nodig.', link ? `\n\n${link}` : ''].join('');
208 const sent = await Guardianship.deliverDirectNote(site, {
209 recipients: guardians,
210 text,
211 language: site.language || 'nl',
212 helpRequest: true,
213 }).catch(() => null);
214
215 if (!sent) return res.status(502).json({ error: 'not_delivered' });
216 res.json({ ok: true, guardians: guardians.length, note: link || null });
217});
218
[303bfc4]219function pwaAsset(rel, type) {
220 return (req, res) => {
221 res.set('Cache-Control', 'no-cache');
222 res.type(type);
223 res.sendFile(path.join(__dir, '..', 'assets', rel));
224 };
225}
226router.get('/app.js', pwaAsset('js/ward.js', 'application/javascript'));
227router.get('/app.css', pwaAsset('css/ward.css', 'text/css'));
228
229router.get('/manifest.webmanifest', (req, res) => {
230 const site = res.locals.site;
231 res.set('Cache-Control', 'no-cache');
232 res.json({
233 id: `klonkt-ward-${site?.slug || 'ward'}`,
234 name: 'Klonkt Ward',
235 short_name: 'Ward',
236 description: 'Wie over je waakt, en wat er op hen wacht.',
237 scope: '/ward/',
238 start_url: '/ward?source=pwa',
239 display: 'standalone',
240 display_override: ['standalone', 'minimal-ui'],
241 orientation: 'any',
242 background_color: '#141a24',
243 theme_color: '#5a32e6',
244 lang: site?.language || 'nl',
245 icons: [
246 { src: '/ward/icon.svg', sizes: 'any', type: 'image/svg+xml' },
247 ],
248 });
249});
250
251// Het schild uit de guardianship-taal, in het ward-accent.
252router.get('/icon.svg', (req, res) => {
253 const svg = `<?xml version="1.0" encoding="UTF-8"?>
254<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
255 <rect width="64" height="64" rx="14" fill="#141a24"/>
256 <path d="M32 12l16 6v14c0 10-6.6 16.6-16 20-9.4-3.4-16-10-16-20V18z" fill="#5a32e6"/>
257 <path d="M24 32l6 6 12-12" stroke="#fff" stroke-width="4" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
258</svg>`;
259 res.set('Cache-Control', 'public, max-age=86400');
260 res.type('image/svg+xml').send(svg);
261});
262
263export default router;
Note: See TracBrowser for help on using the repository browser.