source: Klonkt/src/assets/js/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.9 KB
RevLine 
[303bfc4]1/**
2 * De Ward PWA (FEP-633c), clientkant. Tegenhanger van guardian.js, maar dan
3 * vanuit het kind: wie waakt over mij, is dat vangnet heel, en wat ligt er bij
4 * hen te wachten.
5 *
6 * Geen framework, net als guardian.js: de toestand komt als JSON mee met de
7 * pagina en dit bestand tekent hem. Alles wat nieuw is komt van /ward/api/*.
8 */
[084b046]9import { SkyRenderer } from '/ward/sky.js';
[303bfc4]10
11const state = JSON.parse(document.getElementById('ward-state').textContent);
12const $ = (id) => document.getElementById(id);
13
14/**
15 * De §4-uitkomst in woorden.
16 *
17 * Drie gevallen, en het derde is het punt: "onbereikbaar" is GEEN goedkeuring.
18 * Een ward wiens guardian-server plat ligt heeft een ONGECONTROLEERD vangnet,
19 * en dat is iets anders dan een gecontroleerd vangnet. Niets tonen zou het
20 * eerste als het tweede laten lezen. Zelfde drieslag als in Shaer.
21 */
22const CHECK_NOTE = {
23 malformed: { text: 'Deze guardian heeft zelf guardians. Een noodoproep komt hier niet aan.', cls: 'alarm' },
24 unreachable: { text: 'Deze guardian was niet te bereiken, dus niet gecontroleerd.', cls: 'unknown' },
25 sound: null,
26};
27
28/** Beschikbaarheid in de taal die iedereen zonder handleiding leest. */
29const AVAILABILITY = {
30 active: { label: 'Beschikbaar', cls: 'ok' },
31 away: { label: 'Even weg', cls: 'away' },
32 dormant: { label: 'Offline', cls: 'off' },
33};
34
35function el(tag, cls, text) {
36 const n = document.createElement(tag);
37 if (cls) n.className = cls;
38 if (text != null) n.textContent = text;
39 return n;
40}
41
42function renderGuardians(checks) {
43 const list = $('guardian-list');
44 list.replaceChildren();
45 $('guardians-empty').hidden = state.guardians.length > 0;
46
47 state.guardians.forEach((g) => {
48 const li = el('li', 'card');
49 const head = el('div', 'row');
50 head.append(el('span', 'name', g.handle));
51
52 const av = AVAILABILITY[g.availability] || AVAILABILITY.active;
53 head.append(el('span', `chip ${av.cls}`, av.label));
54 li.append(head);
55
56 // De controle staat WAAR de guardian genoemd wordt, niet in een logboek:
57 // daar heeft het gat betekenis.
58 const note = CHECK_NOTE[checks?.[g.uri]];
59 if (note) li.append(el('p', `note ${note.cls}`, note.text));
60 list.append(li);
61 });
62}
63
64function renderOffers() {
65 const sec = $('offers');
66 const list = $('offer-list');
67 list.replaceChildren();
68 sec.hidden = !state.offers.length;
69
70 state.offers.forEach((o) => {
71 const li = el('li', 'card');
72 const who = o['shaer:candidate'] || o.actor || '';
73 li.append(el('p', 'name', `${who} wil jouw guardian worden`));
74 if (o['shaer:acceptedBy']) {
75 li.append(el('p', 'hint', `${o['shaer:acceptedBy'].length} van de partijen zei al ja`));
76 }
77 list.append(li);
78 });
79}
80
81function renderWaiting() {
82 const sec = $('waiting');
83 const list = $('waiting-list');
84 list.replaceChildren();
85 sec.hidden = !state.waitingFollows.length;
86
87 state.waitingFollows.forEach((w) => {
88 const li = el('li', 'card');
89 li.append(el('p', 'name', w.handle));
90 // Niet "pending": de bal ligt bij iemand die je kent, en geweigerd is
91 // helemaal geen wachten meer. Zelfde drie zinnen als in Shaer.
92 const denied = w.status === 'denied';
93 li.append(el('p', denied ? 'note alarm' : 'hint',
94 denied ? 'Je guardians hebben hier nee tegen gezegd.'
95 : 'Je guardians moeten hier nog ja tegen zeggen.'));
96 list.append(li);
97 });
98}
99
[a5a6249]100/**
101 * De kaarten. De body komt AF van de server (dezelfde partial als de Krant),
102 * dus hier alleen invoegen; zelf HTML samenstellen zou opmaak, media en
103 * quote-kaarten opnieuw moeten uitvinden en er anders uit laten zien.
104 */
105function renderFeed(items) {
106 const box = document.getElementById('card-list');
107 box.replaceChildren();
108 document.getElementById('feed-empty').hidden = items.length > 0;
109
110 items.forEach((it) => {
111 const card = el('article', 'post');
112 card.dataset.note = it.id; // waar de boei op mikt
113 const head = el('div', 'row');
114 if (it.authorIcon) {
115 const img = el('img', 'avatar');
116 img.src = it.authorIcon; img.alt = ''; img.loading = 'lazy';
117 head.append(img);
118 }
119 head.append(el('span', 'name', it.authorName || it.author));
120 head.append(el('span', 'when', it.whenText || ''));
121 card.append(head);
122
123 if (it.cw) card.append(el('p', 'cw', it.cw));
124 const body = el('div', 'body');
125 body.innerHTML = it.bodyHtml || ''; // server-gerenderd en gesaniteerd
126 card.append(body);
127 box.append(card);
128 });
129}
130
131async function loadFeed() {
132 try {
133 const r = await fetch('/ward/api/feed', { headers: { Accept: 'application/json' } });
134 if (!r.ok) return;
135 renderFeed((await r.json()).items || []);
136 } catch { /* de pagina blijft staan met wat er al is */ }
137}
138
139/**
140 * De reddingsboei: sleep hem op een kaart en je guardians krijgen die post.
141 *
142 * Pointer-events in plaats van muis- of touch-events apart, want dat is het
143 * enige dat op een telefoon en met een muis hetzelfde doet. `setPointerCapture`
144 * zorgt dat de sleep van jou blijft, en `touch-action: none` op de boei houdt
145 * de pagina stil terwijl je hem verplaatst: zonder dat vecht het slepen met
146 * scrollen en verlies je hem halverwege.
147 */
148function wireBuoy() {
149 const buoy = document.getElementById('buoy');
150 const dlg = document.getElementById('help-dialog');
151 let dragging = false, dx = 0, dy = 0, target = null;
152
153 const cardUnder = (x, y) => document.elementFromPoint(x, y)?.closest('.post');
154
155 buoy.addEventListener('pointerdown', (e) => {
156 dragging = true;
157 buoy.setPointerCapture(e.pointerId);
158 const r = buoy.getBoundingClientRect();
159 dx = e.clientX - r.left; dy = e.clientY - r.top;
160 buoy.classList.add('dragging');
161 });
162
163 buoy.addEventListener('pointermove', (e) => {
164 if (!dragging) return;
165 buoy.style.left = `${e.clientX - dx}px`;
166 buoy.style.top = `${e.clientY - dy}px`;
167 buoy.style.right = 'auto'; buoy.style.bottom = 'auto';
168 // Onderweg oplichten waar hij zou landen, zodat je ziet wat je kiest.
169 document.querySelectorAll('.post.hover').forEach((n) => n.classList.remove('hover'));
170 cardUnder(e.clientX, e.clientY)?.classList.add('hover');
171 });
172
173 const drop = (e) => {
174 if (!dragging) return;
175 dragging = false;
176 buoy.classList.remove('dragging');
177 const card = cardUnder(e.clientX, e.clientY);
178 document.querySelectorAll('.post.hover').forEach((n) => n.classList.remove('hover'));
179 // Terug naar zijn hoek: de boei hoort altijd op dezelfde plek te liggen.
180 buoy.style.left = buoy.style.top = 'auto';
181 buoy.style.right = ''; buoy.style.bottom = '';
182 if (card) askHelp(card.dataset.note, card.querySelector('.name')?.textContent || '');
183 };
184 buoy.addEventListener('pointerup', drop);
185 buoy.addEventListener('pointercancel', drop);
186
187 // Een tik (zonder slepen) vraagt hulp zonder dat er een post bij hoort.
188 buoy.addEventListener('click', (e) => { if (!e.detail || !dragging) askHelp(null, null); });
189
190 function askHelp(noteId, who) {
191 document.getElementById('help-title').textContent = who
192 ? `Hulp vragen bij het bericht van ${who}?` : 'Hulp vragen aan je guardians?';
193 dlg.dataset.note = noteId || '';
194 document.getElementById('help-words').value = '';
195 dlg.showModal();
196 }
197
198 dlg.addEventListener('close', async () => {
199 if (dlg.returnValue !== 'send') return;
200 const body = { note: dlg.dataset.note || undefined, words: document.getElementById('help-words').value };
201 const hint = document.getElementById('help-hint');
202 try {
203 const r = await fetch('/ward/api/help', {
204 method: 'POST',
205 headers: { 'Content-Type': 'application/json' },
206 body: JSON.stringify(body),
207 });
208 const out = await r.json().catch(() => ({}));
209 hint.textContent = r.ok ? state.strings.helpSent
210 : (out.error === 'no_guardians' ? 'Je hebt nog geen guardians om te vragen.' : 'Dat lukte niet; probeer het nog eens.');
211 } catch {
212 hint.textContent = 'Dat lukte niet; probeer het nog eens.';
213 }
214 });
215}
216
[084b046]217/**
218 * De hemel en de kaarten zijn één scherm met twee standen.
219 *
220 * De hemel draait ALTIJD en ligt achter de kaarten; in kaartenstand is hij
221 * decor en vangt hij geen tikken, in berichtenstand vallen de kaarten weg en
222 * wordt hij de interface. Dat is precies de beweging uit Shaer: de kaarten
223 * vallen weg en wat eronder lag was er al.
224 */
225function wireSky() {
226 const canvas = document.getElementById('sky');
227 const btn = document.getElementById('mode');
228 const main = document.getElementById('app');
229 let convs = [];
230
231 const sky = new SkyRenderer(canvas, (i) => {
232 const c = convs[i];
[5218863]233 if (c) openThread(c);
[5412cbb]234 }, () => openFriendsPicker());
235
236 /* ── De (+): de deur naar iemand nieuw ─────────────────────────────────
237 De hemel toont alleen met wie je al sprak; de kiezer toont iedereen met
238 een volgrelatie. Kies iemand en het lege gesprek opent gewoon: het
239 eerste bericht maakt het echt. */
240 const friendsDlg = document.getElementById('friends-dialog');
241 async function openFriendsPicker() {
242 const list = document.getElementById('friends-list');
243 list.replaceChildren();
244 friendsDlg.showModal();
245 try {
246 const r = await fetch('/ward/api/friends', { headers: { Accept: 'application/json' } });
247 const items = r.ok ? (await r.json()).items || [] : [];
248 document.getElementById('friends-empty').hidden = items.length > 0;
249 items.forEach((f) => {
250 const li = el('li', 'card friend');
251 const row = el('button', 'row');
252 row.type = 'button';
253 if (f.icon) {
254 const img = el('img', 'avatar');
255 img.src = f.icon; img.alt = ''; img.loading = 'lazy';
256 row.append(img);
257 }
258 const kolom = el('span', 'wie');
259 kolom.append(el('span', 'name', f.name), el('span', 'hint', f.handle));
260 row.append(kolom);
261 row.addEventListener('click', () => {
262 friendsDlg.close();
263 openThread({ uri: f.uri, name: f.name, icon: f.icon });
264 });
265 li.append(row);
266 list.append(li);
267 });
268 } catch { /* de kiezer blijft leeg staan; annuleren kan altijd */ }
269 }
270 document.getElementById('friends-cancel').addEventListener('click', () => friendsDlg.close());
[5218863]271
272 /* ── Het gesprek: een overlay OVER de levende hemel ────────────────────
273 Geen aparte pagina: de hemel eronder blijft dezelfde draaiende
274 instantie, precies zoals de thread in Shaer over de hemel schuift. */
275 const thread = document.getElementById('thread');
276 const list = document.getElementById('thread-list');
277 let openWith = null;
278 let pollTimer = null;
279
280 async function loadThread() {
281 if (!openWith) return;
282 try {
283 const r = await fetch(`/ward/api/thread?with=${encodeURIComponent(openWith.uri)}`, { headers: { Accept: 'application/json' } });
284 if (!r.ok) return;
285 const { items } = await r.json();
286 list.replaceChildren();
287 // De server geeft nieuwste eerst; een gesprek lees je oudste eerst.
288 [...items].reverse().forEach((m) => {
289 const b = el('div', `bubble ${m.dir}${m.wave ? ' wave' : ''}`);
290 const body = el('div', 'body');
291 body.innerHTML = m.bodyHtml || '';
292 b.append(body, el('span', 'when', m.whenText || ''));
293 list.append(b);
294 });
295 list.scrollTop = list.scrollHeight;
296 } catch { /* volgende poll probeert opnieuw */ }
297 }
298
299 function openThread(c) {
300 openWith = c;
301 document.getElementById('thread-name').textContent = c.name;
302 thread.hidden = false;
303 loadThread();
304 // Pollen zolang het gesprek open staat: eenvoudig en zuinig genoeg.
305 // Een socket kan later; het ritme van een gesprek is geen milliseconde.
306 clearInterval(pollTimer);
307 pollTimer = setInterval(loadThread, 8000);
308 }
309
310 function closeThread() {
311 openWith = null;
312 thread.hidden = true;
313 clearInterval(pollTimer);
314 }
315 document.getElementById('thread-close').addEventListener('click', closeThread);
316 document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && openWith) closeThread(); });
317
318 document.getElementById('thread-form').addEventListener('submit', async (e) => {
319 e.preventDefault();
320 const input = document.getElementById('thread-text');
321 const text = input.value.trim();
322 if (!text || !openWith) return;
323 input.value = '';
324 await sendMessage(openWith.uri, text, false);
325 loadThread();
326 });
327
328 async function sendMessage(to, text, wave) {
329 try {
330 const r = await fetch('/ward/api/message', {
331 method: 'POST',
332 headers: { 'Content-Type': 'application/json' },
333 body: JSON.stringify({ to, text, wave }),
334 });
335 return r.ok;
336 } catch { return false; }
337 }
338
339 /* ── De zwaai-hand (§5) ────────────────────────────────────────────────
340 Dezelfde hoek als de boei, en ze wisselen elkaar af met de stand: met de
341 kaarten verdwijnt de boei, op de hemel hangt de hand. Slepen op een
342 gezicht opent het paneel; de tekst die je kiest IS het bericht. */
343 const hand = document.getElementById('hand');
344 const waveDlg = document.getElementById('wave-dialog');
345 let waveTarget = null;
346
347 (() => {
348 let dragging = false, dx = 0, dy = 0;
349 hand.addEventListener('pointerdown', (e) => {
350 dragging = true;
351 hand.setPointerCapture(e.pointerId);
352 const r = hand.getBoundingClientRect();
353 dx = e.clientX - r.left; dy = e.clientY - r.top;
354 hand.classList.add('dragging');
355 });
356 hand.addEventListener('pointermove', (e) => {
357 if (!dragging) return;
358 hand.style.left = `${e.clientX - dx}px`;
359 hand.style.top = `${e.clientY - dy}px`;
360 hand.style.right = 'auto'; hand.style.bottom = 'auto';
361 });
362 const drop = (e) => {
363 if (!dragging) return;
364 dragging = false;
365 hand.classList.remove('dragging');
366 hand.style.left = hand.style.top = 'auto';
367 hand.style.right = ''; hand.style.bottom = '';
368 // Zelfde rekensom als een tik op de hemel: het dichtstbijzijnde gezicht
369 // binnen bereik wint, en ernaast is bewust niets.
370 const r = canvas.getBoundingClientRect();
371 const hit = sky.pick(e.clientX - r.left, e.clientY - r.top);
372 if (hit != null && convs[hit]) {
373 waveTarget = convs[hit];
374 document.getElementById('wave-title').textContent =
375 state.strings.waveTo.replace('{who}', waveTarget.name);
376 waveDlg.showModal();
377 }
378 };
379 hand.addEventListener('pointerup', drop);
380 hand.addEventListener('pointercancel', drop);
381 })();
382
383 waveDlg.addEventListener('click', async (e) => {
384 const btn = e.target.closest('button');
385 if (!btn) return;
386 waveDlg.close();
387 if (btn.id === 'wave-cancel' || !waveTarget) return;
388 const isWave = btn.dataset.wave === '1';
389 const text = isWave ? '👋' : btn.dataset.text;
390 if (await sendMessage(waveTarget.uri, text, isWave)) openThread(waveTarget);
[084b046]391 });
392
393 let messagesMode = false;
394 const setMode = (on) => {
395 messagesMode = on;
396 sky.interactive = on;
397 canvas.setAttribute('aria-hidden', String(!on));
398 // touch-action pas uitzetten als de hemel echt bedienbaar is: anders kun
399 // je in kaartenstand niet meer scrollen.
400 canvas.style.touchAction = on ? 'none' : '';
401 canvas.style.pointerEvents = on ? 'auto' : 'none';
402 document.body.classList.toggle('messages-mode', on);
[5218863]403 document.getElementById('buoy').hidden = on;
404 hand.hidden = !on;
405 if (!on) closeThread();
[084b046]406 btn.textContent = on ? state.strings.toCards : state.strings.toSky;
407 btn.setAttribute('aria-pressed', String(on));
408 main.querySelectorAll('section').forEach((sec) => { sec.hidden = on; });
409 };
410
411 btn.addEventListener('click', () => setMode(!messagesMode));
412 setMode(false);
413
414 (async () => {
415 try {
416 const r = await fetch('/ward/api/conversations', { headers: { Accept: 'application/json' } });
417 if (!r.ok) return;
418 convs = (await r.json()).items || [];
419 await sky.setFaces(convs.map((c) => ({ name: c.name, icon: c.icon, ageMs: c.ageMs })));
420 sky.start();
421 } catch { /* zonder hemel blijft het scherm gewoon werken */ }
422 })();
423}
424
425/** De controle kost per guardian een remote fetch, dus hij komt ná het tekenen. *//** De controle kost per guardian een remote fetch, dus hij komt ná het tekenen. */
[303bfc4]426async function loadChecks() {
427 if (!state.guardians.length) return;
428 try {
429 const r = await fetch('/ward/api/guardian-checks', { headers: { Accept: 'application/json' } });
430 if (!r.ok) return;
431 const { checks } = await r.json();
432 renderGuardians(checks);
433 } catch { /* stilte is hier juist: dan blijft de lijst zonder oordeel staan */ }
434}
435
436function wireHelp() {
437 const btn = $('help-btn');
438 const hint = $('help-hint');
439 if (!state.guardians.length) {
440 btn.disabled = true;
441 hint.textContent = 'Je hebt nog geen guardians om te vragen.';
442 return;
443 }
444 hint.textContent = `Gaat naar ${state.guardians.length} guardian(s).`;
[a5a6249]445 btn.addEventListener('click', () => document.getElementById('buoy').click());
[303bfc4]446}
447
448renderGuardians(null);
[a5a6249]449wireBuoy();
[084b046]450wireSky();
[a5a6249]451loadFeed();
[303bfc4]452renderOffers();
453renderWaiting();
454wireHelp();
455loadChecks();
Note: See TracBrowser for help on using the repository browser.