source: Klonkt/src/routes/ward.js@ 5412cbb

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

Ward-PWA: de (+) voor een nieuw gesprek

De hemel toont alleen met wie je al sprak; de (+) is de deur naar de
rest. Hij zit op de OORSPRONG van het veld en pant en zoomt met de
gezichten mee, zodat hij altijd het midden van de vliegende avatars is,
waar de hemel ook heen drijft. Precies waar de plaatsing bewust ruimte
vrijhoudt: elk gezicht hangt minstens 1.4 cel naar buiten.

De tik kiest eerst een gezicht en pas daarna de (+), met een raakzone
die meeschaalt met de zoom. De kiezer erachter toont iedereen met een
volgrelatie, welke kant op dan ook: dezelfde bron als de Connect-pagina
(listConnections), want de vraag is dezelfde. Kies iemand en het lege
gesprek opent gewoon; het eerste bericht maakt het echt.

Changed files:
src/routes/ward.js

  • /api/friends: following + followers samengevoegd, met naam en icoon

src/assets/js/ward-sky.js

  • de (+) getekend op de veldoorsprong; onNew naast onPick

src/assets/js/ward.js

  • de vriendenkiezer, die doorvalt in openThread

src/views/pages/ward.ejs + src/assets/css/ward.css

  • de kiezer als dialoog

src/services/i18n.js

  • twee sleutels in nl, en, de

test/ward-pwa.test.js

  • beide richtingen zijn een deur, en elke deur heeft een naam

remarks: 899 tests groen.

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

  • Property mode set to 100644
File size: 16.7 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'),
[084b046]100 toSky: i18nT(L, 'ward.to_sky'),
101 toCards: i18nT(L, 'ward.to_cards'),
102 skyEmpty: i18nT(L, 'ward.sky_empty'),
[5218863]103 handHint: i18nT(L, 'ward.hand_hint'),
104 close: i18nT(L, 'ward.close'),
105 writeHere: i18nT(L, 'ward.write_here'),
106 send: i18nT(L, 'ward.send'),
107 wave: i18nT(L, 'ward.wave'),
108 waveCall: i18nT(L, 'ward.wave_call'),
109 waveThanks: i18nT(L, 'ward.wave_thanks'),
110 notNow: i18nT(L, 'ward.not_now'),
111 waveTo: i18nT(L, 'ward.wave_to'),
[5412cbb]112 newTitle: i18nT(L, 'ward.new_title'),
113 noFriends: i18nT(L, 'ward.no_friends'),
[303bfc4]114 },
115 };
116}
117
118// ── De PWA-pagina ────────────────────────────────────────────────────────
119router.get('/', requireAuth, (req, res) => {
120 const site = siteForUser(req);
121 const L = resolveLang(req);
122 if (!site) return res.status(404).send('No site for this account.');
123 // Losse PWA-pagina, dus NIET via renderPage: de CSP-nonce moet hier zelf
124 // geïnjecteerd worden, anders blokkeert strict-dynamic ward.js en doet geen
125 // enkele knop iets (zelfde valkuil als bij /guardian).
126 res.render('pages/ward', {
127 state: wardState(site, L),
128 lang: L,
129 t: (k, v) => i18nT(L, k, v),
130 cspNonce: res.locals.cspNonce,
131 }, (err, html) => {
132 if (err) { console.error('[ward] render error', err); return res.status(500).send('Internal Server Error'); }
133 res.send(injectCspNonce(html, res.locals.cspNonce));
134 });
135});
136
137// ── JSON voor verversen ──────────────────────────────────────────────────
138router.get('/api/state', requireAuth, (req, res) => {
139 const site = siteForUser(req);
140 if (!site) return res.status(404).json({ error: 'no_site' });
141 res.json(wardState(site, resolveLang(req)));
142});
143
144/**
145 * De §4-controle: is mijn vangnet wel heel?
146 *
147 * Een "guardian" met eigen guardians is er geen (§1), en een escalatie
148 * daarheen komt nergens aan. Dat is precies wat een ward moet kunnen zien.
149 * Apart endpoint omdat het per guardian een remote fetch kost; de pagina
150 * tekent eerst en vult dit daarna aan.
151 *
152 * Drie uitkomsten, net als in de apps: in orde, misvormd, of onbereikbaar.
153 * Onbereikbaar is géén goedkeuring, dus het krijgt een eigen waarde in plaats
154 * van te ontbreken.
155 */
156router.get('/api/guardian-checks', requireAuth, async (req, res) => {
157 const site = siteForUser(req);
158 if (!site) return res.status(404).json({ error: 'no_site' });
159 const uris = Guardianship.listGuardians(site.slug).map((g) => g.other_uri).filter(Boolean);
160 const out = {};
161 await Promise.all(uris.map(async (uri) => {
162 const doc = await AP.fetchActor(uri).catch(() => null);
163 if (!doc) { out[uri] = 'unreachable'; return; }
164 out[uri] = carriesGuardians(doc) ? 'malformed' : 'sound';
165 }));
166 res.json({ checks: out });
167});
168
[a5a6249]169/**
170 * De feed: wat er binnenkomt van de mensen die je volgt.
171 *
172 * Zelfde bron en dezelfde partial als de Krant, Berichten en het
173 * guardian-dashboard (renderNoteBody), zodat een post er overal hetzelfde
174 * uitziet: opmaak, media, quote-kaart en embed. Kale content sturen zou hier
175 * een lege regel opleveren waar een foto hoort.
176 */
177router.get('/api/feed', requireAuth, (req, res) => {
178 const site = siteForUser(req);
179 if (!site) return res.status(404).json({ error: 'no_site' });
180 const L = resolveLang(req);
181 const items = AP.getTimeline(site.slug, 60, 0).map((p) => ({
182 id: p.id,
183 uri: p.object_uri || p.url,
184 author: p.author_handle || p.author_name || p.author_uri,
185 authorName: p.author_name,
186 authorIcon: p.author_icon,
187 url: p.url,
188 published: p.published || p.created_at,
189 whenText: formatDateTime(p.published || p.created_at),
190 cw: p.cw || null,
191 bodyHtml: renderNoteBody(p, L),
192 }));
193 res.json({ items });
194});
195
196/**
197 * De reddingsboei (§5.2.1): sleep hem op een post en je guardians krijgen hem.
198 *
199 * Shaer maakt hier een SCHERMAFDRUK van de kaart, met al het gedoe van buiten
200 * beeld renderen. Op de server is dat onnodig: wij weten wélke note het is, dus
201 * de hulpvraag draagt de note zelf mee. Dat is niet alleen minder werk maar ook
202 * eerlijker: een guardian ziet de echte post en niet een plaatje dat er ooit op
203 * leek.
204 *
205 * Gaat naar ALLE guardians tegelijk, als direct bericht. Geen post, nooit
206 * boostbaar. Zonder guardians is er niets om te vragen en zegt dat het ook.
207 */
208router.post('/api/help', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
209 const site = siteForUser(req);
210 if (!site) return res.status(404).json({ error: 'no_site' });
211 const guardians = Guardianship.listGuardians(site.slug).map((g) => g.other_uri).filter(Boolean);
212 if (!guardians.length) return res.status(409).json({ error: 'no_guardians' });
213
214 const noteId = String(req.body?.note || '').trim();
215 const words = String(req.body?.words || '').trim().slice(0, 500);
216 // De post waar het om gaat, als die van deze tijdlijn komt. Niet verplicht:
217 // een kind mag ook om hulp vragen zonder dat er een post bij hoort.
218 const row = noteId ? AP.getTimeline(site.slug, 200, 0).find((p) => String(p.id) === noteId) : null;
219 const link = row && (row.url || row.object_uri);
220
221 const text = [words || 'Ik heb hulp nodig.', link ? `\n\n${link}` : ''].join('');
222 const sent = await Guardianship.deliverDirectNote(site, {
223 recipients: guardians,
224 text,
225 language: site.language || 'nl',
226 helpRequest: true,
227 }).catch(() => null);
228
229 if (!sent) return res.status(502).json({ error: 'not_delivered' });
230 res.json({ ok: true, guardians: guardians.length, note: link || null });
231});
232
[084b046]233/**
234 * De gesprekken voor de hemel: iedereen met wie dit kind persoonlijk berichten
235 * heeft gewisseld, met hoe lang geleden dat voor het laatst was.
236 *
237 * De hemel toont GESPREKKEN, geen posts. Directe berichten leven in
238 * ap_mentions en niet in de tijdlijn, precies omdat een bericht aan genoemde
239 * mensen geen post is. Per tegenpartij houden we het jongste bericht: dat is
240 * wat de afstand tot het midden bepaalt.
241 */
242router.get('/api/conversations', requireAuth, (req, res) => {
243 const site = siteForUser(req);
244 if (!site) return res.status(404).json({ error: 'no_site' });
245 const now = Date.now();
246 const byUri = new Map();
247 for (const m of AP.getDirectMessages(site.slug, 200)) {
248 if (!m.actor_uri) continue;
249 const when = Date.parse(m.published || m.created_at || '') || now;
250 const seen = byUri.get(m.actor_uri);
251 if (!seen || when > seen.when) {
252 byUri.set(m.actor_uri, {
253 uri: m.actor_uri,
254 name: m.actor_name || m.actor_handle || m.actor_uri,
255 handle: m.actor_handle || m.actor_uri,
256 icon: m.actor_icon || null,
257 when,
258 });
259 }
260 }
261 const items = [...byUri.values()]
262 .sort((a, b) => b.when - a.when)
263 .map((c) => ({ ...c, ageMs: Math.max(0, now - c.when) }));
264 res.json({ items });
265});
266
[5218863]267/**
268 * Eén gesprek, beide kanten, nieuwste eerst, met een cursor voor "meer".
269 *
270 * Dezelfde bron als de C2S-lezing die Shaer gebruikt (conversationHistory):
271 * inkomend zijn de ap_mentions, uitgaand de eigen directe notes. De body komt
272 * af van de server, net als in de feed; de client hoeft alleen bubbels te
273 * tekenen en te weten welke kant elk bericht op wijst.
274 */
275router.get('/api/thread', requireAuth, (req, res) => {
276 const site = siteForUser(req);
277 if (!site) return res.status(404).json({ error: 'no_site' });
278 const other = String(req.query.with || '');
279 if (!/^https?:\/\//i.test(other)) return res.status(400).json({ error: 'with_must_be_actor_uri' });
280 const L = resolveLang(req);
281 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
282
283 const page = AP.conversationHistory(site.slug, other, {
284 before: req.query.before ? String(req.query.before) : null,
285 limit: req.query.limit,
286 });
287 const inRefs = page.rows.filter((r) => r.direction === 'in').map((r) => r.ref);
288 const inMap = new Map(AP.messageRowsByUri(site.slug, inRefs).map((m) => [m.object_uri, m]));
289
290 const items = page.rows.map((r) => {
291 if (r.direction === 'in') {
292 const m = inMap.get(r.ref);
293 if (!m) return null;
294 return {
295 dir: 'in', id: m.object_uri,
296 whenText: formatDateTime(m.published || m.created_at),
297 bodyHtml: renderNoteBody(m, L),
298 wave: !!m.wave, help: !!m.help_request,
299 };
300 }
301 const n = AP.getOutboxNote(base, r.ref);
302 if (!n) return null;
303 return {
304 dir: 'out', id: n.id,
305 whenText: formatDateTime(n.published),
306 // Eigen notes zijn door onszelf gebouwd en gesaniteerd bij het maken.
307 bodyHtml: n.content || '',
308 wave: n['shaer:wave'] === true,
309 };
310 }).filter(Boolean);
311
312 // Openen is lezen. De markering is optellend (maximum), dus een oud toestel
313 // kan nooit iets weer ongelezen maken.
314 const nieuwsteIn = items.find((i) => i.dir === 'in');
315 if (nieuwsteIn) { try { AP.markRead(site.slug, nieuwsteIn.id); } catch { /* lezen faalt stil */ } }
316
317 res.json({ items, more: page.more, oldest: page.oldest });
318});
319
320/**
321 * Een bericht in een gesprek, of een zwaai (§5, shaer:wave).
322 *
323 * Dezelfde draad als de boei (deliverDirectNote): een direct bericht aan één
324 * persoon, nooit een post, nooit boostbaar. De wave-vlag reist mee zodat de
325 * andere kant hem als zwaai toont en niet als kale tekst.
326 */
327router.post('/api/message', requireAuth, express.json({ limit: '8kb' }), async (req, res) => {
328 const site = siteForUser(req);
329 if (!site) return res.status(404).json({ error: 'no_site' });
330 const to = String(req.body?.to || '').trim();
331 const text = String(req.body?.text || '').trim().slice(0, 2000);
332 if (!/^https?:\/\//i.test(to)) return res.status(400).json({ error: 'to_must_be_actor_uri' });
333 if (!text) return res.status(400).json({ error: 'empty' });
334
335 const sent = await Guardianship.deliverDirectNote(site, {
336 recipients: [to],
337 text,
338 language: site.language || 'nl',
339 wave: req.body?.wave === true,
340 }).catch(() => null);
341 if (!sent) return res.status(502).json({ error: 'not_delivered' });
342 res.json({ ok: true });
343});
344
[5412cbb]345/**
346 * De vrienden, voor de (+): iedereen met een volgrelatie, welke kant op dan
347 * ook. Dezelfde bron als de Connect-pagina (listConnections), want de vraag
348 * is dezelfde: wie ken je. De hemel toont alleen met wie je al SPRAK; dit is
349 * de deur naar de rest.
350 */
351router.get('/api/friends', requireAuth, (req, res) => {
352 const site = siteForUser(req);
353 if (!site) return res.status(404).json({ error: 'no_site' });
354 const items = AP.listConnections(site.slug).map((c) => ({
355 uri: c.actor_uri,
356 name: c.name || c.handle || c.actor_uri,
357 handle: c.handle || c.actor_uri,
358 icon: c.icon || null,
359 }));
360 res.json({ items });
361});
362
[303bfc4]363function pwaAsset(rel, type) {
364 return (req, res) => {
365 res.set('Cache-Control', 'no-cache');
366 res.type(type);
367 res.sendFile(path.join(__dir, '..', 'assets', rel));
368 };
369}
370router.get('/app.js', pwaAsset('js/ward.js', 'application/javascript'));
[084b046]371// De hemel is een eigen module, zodat model en renderer los staan van de rest.
372router.get('/sky.js', pwaAsset('js/ward-sky.js', 'application/javascript'));
[303bfc4]373router.get('/app.css', pwaAsset('css/ward.css', 'text/css'));
374
375router.get('/manifest.webmanifest', (req, res) => {
376 const site = res.locals.site;
377 res.set('Cache-Control', 'no-cache');
378 res.json({
379 id: `klonkt-ward-${site?.slug || 'ward'}`,
380 name: 'Klonkt Ward',
381 short_name: 'Ward',
382 description: 'Wie over je waakt, en wat er op hen wacht.',
383 scope: '/ward/',
384 start_url: '/ward?source=pwa',
385 display: 'standalone',
386 display_override: ['standalone', 'minimal-ui'],
387 orientation: 'any',
388 background_color: '#141a24',
389 theme_color: '#5a32e6',
390 lang: site?.language || 'nl',
391 icons: [
392 { src: '/ward/icon.svg', sizes: 'any', type: 'image/svg+xml' },
393 ],
394 });
395});
396
397// Het schild uit de guardianship-taal, in het ward-accent.
398router.get('/icon.svg', (req, res) => {
399 const svg = `<?xml version="1.0" encoding="UTF-8"?>
400<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
401 <rect width="64" height="64" rx="14" fill="#141a24"/>
402 <path d="M32 12l16 6v14c0 10-6.6 16.6-16 20-9.4-3.4-16-10-16-20V18z" fill="#5a32e6"/>
403 <path d="M24 32l6 6 12-12" stroke="#fff" stroke-width="4" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
404</svg>`;
405 res.set('Cache-Control', 'public, max-age=86400');
406 res.type('image/svg+xml').send(svg);
407});
408
409export default router;
Note: See TracBrowser for help on using the repository browser.