source: Klonkt/src/routes/ward.js@ 4bb93aa

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

De Ward PWA: het gesprek over de hemel, en de zwaai-hand

Plak 4. Een tik op een gezicht opent nu echt iets.

HET GESPREK is een overlay OVER de levende hemel, geen aparte pagina:
de hemel eronder blijft dezelfde draaiende instantie, en het paneel is
doorschijnend met blur, zoals de glazen bubbels in de app. Dezelfde
bron als de C2S-lezing die Shaer gebruikt (conversationHistory):
inkomend de ap_mentions, uitgaand de eigen directe notes. Openen is
lezen: markRead neemt het maximum, dus een oud toestel kan nooit iets
weer ongelezen maken. Verversen is pollen om de acht seconden zolang
het gesprek open staat; het ritme van een gesprek is geen milliseconde,
en een socket kan later zonder iets te slopen.

DE ZWAAI-HAND hangt in dezelfde hoek als de boei en ze wisselen elkaar
af met de stand: met de kaarten verdwijnt de boei, op de hemel hangt de
hand. Slepen op een gezicht opent het paneel met de drie antwoorden, en
de tekst die je kiest IS het bericht. De treffer is sky.pick(): exact
dezelfde rekensom als een tik, dus mikken gedraagt zich overal gelijk.

Changed files:
src/routes/ward.js

  • /api/thread: beide kanten, cursor, markRead bij openen
  • /api/message: direct bericht of zwaai (shaer:wave reist mee)

src/assets/js/ward-sky.js

  • pick() als publieke mikfunctie

src/assets/js/ward.js

  • thread-overlay met poll, verstuurvak, Escape sluit
  • de hand: slepen, mikken, paneel, versturen

src/views/pages/ward.ejs

  • thread, hand en zwaai-paneel in de schil

src/assets/css/ward.css

  • doorschijnend gesprek, bubbels, hand, paneel

src/services/i18n.js

  • tien sleutels in nl, en, de

test/ward-pwa.test.js

  • het gesprek draagt beide kanten en de zwaai-vlag overleeft; versturen eist een echte ontvanger en echte tekst

remarks: 897 tests groen. Nog niet geport: de (+) voor een nieuw
gesprek, Friends/Orbit-panelen, de composer met media, de mini-speler
en de themakleur; beads volgen. Slepen met een echte vinger is aan
Robin op dev.

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

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