source: Klonkt/src/routes/ward.js@ 084b046

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

De Ward PWA: de avatarhemel achter de kaarten

Plak 3. Robins correctie: niet de hemel IN PLAATS VAN de kaarten, maar
allebei, met de hemel er altijd achter. Zelfde beweging als in Shaer: de
kaarten vallen weg en wat eronder lag was er al.

MODEL EN RENDERER APART, en die naad is het punt. orbitPlacement en
nearestFace zijn pure wiskunde en bestaan nu drie keer (Swift, Kotlin,
hier). Drie keer kans op drift, en drift zie je niet aan een screenshot:
je ziet het als gezichten ergens anders hangen dan in de app. Daarom in
een eigen module, met dezelfde eigenschappen bewaakt als AvatarSkyTest.kt.

CANVAS, GEEN ENGINE, en dat is een afweging en geen luiheid. De rem is
niet het aantal sprites maar het aantal texturen: alle avatars gaan een
keer in een atlas, daarna is elk gezicht een drawImage uit datzelfde
plaatje. Dat haalt duizenden. Wil het ooit meer, dan vervangt een
WebGL-variant alleen SkyRenderer en merkt de rest er niets van. De echte
schaaloplossing is trouwens niet sneller tekenen maar clusteren; dat
staat als bead voor later.

New file:
src/assets/js/ward-sky.js

  • orbitPlacement + nearestFace, letterlijke port
  • SkyRenderer: atlas, ademhaling, pan, zoom, tik

test/ward-sky.test.js

  • vers dichterbij dan oud, deterministisch, geen overlap, mikken kiest de dichtstbijzijnde en schaalt mee met de zoom

Changed files:
src/routes/ward.js

  • /api/conversations: per tegenpartij het jongste bericht
  • /sky.js als eigen asset

src/views/pages/ward.ejs

  • canvas achter alles, wisselknop, app.js als module

src/assets/js/ward.js

  • de wissel: kaarten of hemel, hemel draait altijd

src/assets/css/ward.css

  • hemel op z-index 0, kaarten erboven

src/services/i18n.js

  • drie sleutels in nl, en, de

remarks: 895 tests groen. Een tik op een gezicht wijst naar /ward/thread,
en die route bestaat nog niet; dat is de volgende plak.

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

  • Property mode set to 100644
File size: 12.5 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 },
104 };
105}
106
107// ── De PWA-pagina ────────────────────────────────────────────────────────
108router.get('/', requireAuth, (req, res) => {
109 const site = siteForUser(req);
110 const L = resolveLang(req);
111 if (!site) return res.status(404).send('No site for this account.');
112 // Losse PWA-pagina, dus NIET via renderPage: de CSP-nonce moet hier zelf
113 // geïnjecteerd worden, anders blokkeert strict-dynamic ward.js en doet geen
114 // enkele knop iets (zelfde valkuil als bij /guardian).
115 res.render('pages/ward', {
116 state: wardState(site, L),
117 lang: L,
118 t: (k, v) => i18nT(L, k, v),
119 cspNonce: res.locals.cspNonce,
120 }, (err, html) => {
121 if (err) { console.error('[ward] render error', err); return res.status(500).send('Internal Server Error'); }
122 res.send(injectCspNonce(html, res.locals.cspNonce));
123 });
124});
125
126// ── JSON voor verversen ──────────────────────────────────────────────────
127router.get('/api/state', requireAuth, (req, res) => {
128 const site = siteForUser(req);
129 if (!site) return res.status(404).json({ error: 'no_site' });
130 res.json(wardState(site, resolveLang(req)));
131});
132
133/**
134 * De §4-controle: is mijn vangnet wel heel?
135 *
136 * Een "guardian" met eigen guardians is er geen (§1), en een escalatie
137 * daarheen komt nergens aan. Dat is precies wat een ward moet kunnen zien.
138 * Apart endpoint omdat het per guardian een remote fetch kost; de pagina
139 * tekent eerst en vult dit daarna aan.
140 *
141 * Drie uitkomsten, net als in de apps: in orde, misvormd, of onbereikbaar.
142 * Onbereikbaar is géén goedkeuring, dus het krijgt een eigen waarde in plaats
143 * van te ontbreken.
144 */
145router.get('/api/guardian-checks', requireAuth, async (req, res) => {
146 const site = siteForUser(req);
147 if (!site) return res.status(404).json({ error: 'no_site' });
148 const uris = Guardianship.listGuardians(site.slug).map((g) => g.other_uri).filter(Boolean);
149 const out = {};
150 await Promise.all(uris.map(async (uri) => {
151 const doc = await AP.fetchActor(uri).catch(() => null);
152 if (!doc) { out[uri] = 'unreachable'; return; }
153 out[uri] = carriesGuardians(doc) ? 'malformed' : 'sound';
154 }));
155 res.json({ checks: out });
156});
157
158/**
159 * De feed: wat er binnenkomt van de mensen die je volgt.
160 *
161 * Zelfde bron en dezelfde partial als de Krant, Berichten en het
162 * guardian-dashboard (renderNoteBody), zodat een post er overal hetzelfde
163 * uitziet: opmaak, media, quote-kaart en embed. Kale content sturen zou hier
164 * een lege regel opleveren waar een foto hoort.
165 */
166router.get('/api/feed', requireAuth, (req, res) => {
167 const site = siteForUser(req);
168 if (!site) return res.status(404).json({ error: 'no_site' });
169 const L = resolveLang(req);
170 const items = AP.getTimeline(site.slug, 60, 0).map((p) => ({
171 id: p.id,
172 uri: p.object_uri || p.url,
173 author: p.author_handle || p.author_name || p.author_uri,
174 authorName: p.author_name,
175 authorIcon: p.author_icon,
176 url: p.url,
177 published: p.published || p.created_at,
178 whenText: formatDateTime(p.published || p.created_at),
179 cw: p.cw || null,
180 bodyHtml: renderNoteBody(p, L),
181 }));
182 res.json({ items });
183});
184
185/**
186 * De reddingsboei (§5.2.1): sleep hem op een post en je guardians krijgen hem.
187 *
188 * Shaer maakt hier een SCHERMAFDRUK van de kaart, met al het gedoe van buiten
189 * beeld renderen. Op de server is dat onnodig: wij weten wélke note het is, dus
190 * de hulpvraag draagt de note zelf mee. Dat is niet alleen minder werk maar ook
191 * eerlijker: een guardian ziet de echte post en niet een plaatje dat er ooit op
192 * leek.
193 *
194 * Gaat naar ALLE guardians tegelijk, als direct bericht. Geen post, nooit
195 * boostbaar. Zonder guardians is er niets om te vragen en zegt dat het ook.
196 */
197router.post('/api/help', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
198 const site = siteForUser(req);
199 if (!site) return res.status(404).json({ error: 'no_site' });
200 const guardians = Guardianship.listGuardians(site.slug).map((g) => g.other_uri).filter(Boolean);
201 if (!guardians.length) return res.status(409).json({ error: 'no_guardians' });
202
203 const noteId = String(req.body?.note || '').trim();
204 const words = String(req.body?.words || '').trim().slice(0, 500);
205 // De post waar het om gaat, als die van deze tijdlijn komt. Niet verplicht:
206 // een kind mag ook om hulp vragen zonder dat er een post bij hoort.
207 const row = noteId ? AP.getTimeline(site.slug, 200, 0).find((p) => String(p.id) === noteId) : null;
208 const link = row && (row.url || row.object_uri);
209
210 const text = [words || 'Ik heb hulp nodig.', link ? `\n\n${link}` : ''].join('');
211 const sent = await Guardianship.deliverDirectNote(site, {
212 recipients: guardians,
213 text,
214 language: site.language || 'nl',
215 helpRequest: true,
216 }).catch(() => null);
217
218 if (!sent) return res.status(502).json({ error: 'not_delivered' });
219 res.json({ ok: true, guardians: guardians.length, note: link || null });
220});
221
222/**
223 * De gesprekken voor de hemel: iedereen met wie dit kind persoonlijk berichten
224 * heeft gewisseld, met hoe lang geleden dat voor het laatst was.
225 *
226 * De hemel toont GESPREKKEN, geen posts. Directe berichten leven in
227 * ap_mentions en niet in de tijdlijn, precies omdat een bericht aan genoemde
228 * mensen geen post is. Per tegenpartij houden we het jongste bericht: dat is
229 * wat de afstand tot het midden bepaalt.
230 */
231router.get('/api/conversations', requireAuth, (req, res) => {
232 const site = siteForUser(req);
233 if (!site) return res.status(404).json({ error: 'no_site' });
234 const now = Date.now();
235 const byUri = new Map();
236 for (const m of AP.getDirectMessages(site.slug, 200)) {
237 if (!m.actor_uri) continue;
238 const when = Date.parse(m.published || m.created_at || '') || now;
239 const seen = byUri.get(m.actor_uri);
240 if (!seen || when > seen.when) {
241 byUri.set(m.actor_uri, {
242 uri: m.actor_uri,
243 name: m.actor_name || m.actor_handle || m.actor_uri,
244 handle: m.actor_handle || m.actor_uri,
245 icon: m.actor_icon || null,
246 when,
247 });
248 }
249 }
250 const items = [...byUri.values()]
251 .sort((a, b) => b.when - a.when)
252 .map((c) => ({ ...c, ageMs: Math.max(0, now - c.when) }));
253 res.json({ items });
254});
255
256function pwaAsset(rel, type) {
257 return (req, res) => {
258 res.set('Cache-Control', 'no-cache');
259 res.type(type);
260 res.sendFile(path.join(__dir, '..', 'assets', rel));
261 };
262}
263router.get('/app.js', pwaAsset('js/ward.js', 'application/javascript'));
264// De hemel is een eigen module, zodat model en renderer los staan van de rest.
265router.get('/sky.js', pwaAsset('js/ward-sky.js', 'application/javascript'));
266router.get('/app.css', pwaAsset('css/ward.css', 'text/css'));
267
268router.get('/manifest.webmanifest', (req, res) => {
269 const site = res.locals.site;
270 res.set('Cache-Control', 'no-cache');
271 res.json({
272 id: `klonkt-ward-${site?.slug || 'ward'}`,
273 name: 'Klonkt Ward',
274 short_name: 'Ward',
275 description: 'Wie over je waakt, en wat er op hen wacht.',
276 scope: '/ward/',
277 start_url: '/ward?source=pwa',
278 display: 'standalone',
279 display_override: ['standalone', 'minimal-ui'],
280 orientation: 'any',
281 background_color: '#141a24',
282 theme_color: '#5a32e6',
283 lang: site?.language || 'nl',
284 icons: [
285 { src: '/ward/icon.svg', sizes: 'any', type: 'image/svg+xml' },
286 ],
287 });
288});
289
290// Het schild uit de guardianship-taal, in het ward-accent.
291router.get('/icon.svg', (req, res) => {
292 const svg = `<?xml version="1.0" encoding="UTF-8"?>
293<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
294 <rect width="64" height="64" rx="14" fill="#141a24"/>
295 <path d="M32 12l16 6v14c0 10-6.6 16.6-16 20-9.4-3.4-16-10-16-20V18z" fill="#5a32e6"/>
296 <path d="M24 32l6 6 12-12" stroke="#fff" stroke-width="4" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
297</svg>`;
298 res.set('Cache-Control', 'public, max-age=86400');
299 res.type('image/svg+xml').send(svg);
300});
301
302export default router;
Note: See TracBrowser for help on using the repository browser.