source: Klonkt/src/assets/js/ward.js@ 5218863

Last change on this file since 5218863 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: 15.3 KB
Line 
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 */
9import { SkyRenderer } from '/ward/sky.js';
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
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
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];
233 if (c) openThread(c);
234 });
235
236 /* ── Het gesprek: een overlay OVER de levende hemel ────────────────────
237 Geen aparte pagina: de hemel eronder blijft dezelfde draaiende
238 instantie, precies zoals de thread in Shaer over de hemel schuift. */
239 const thread = document.getElementById('thread');
240 const list = document.getElementById('thread-list');
241 let openWith = null;
242 let pollTimer = null;
243
244 async function loadThread() {
245 if (!openWith) return;
246 try {
247 const r = await fetch(`/ward/api/thread?with=${encodeURIComponent(openWith.uri)}`, { headers: { Accept: 'application/json' } });
248 if (!r.ok) return;
249 const { items } = await r.json();
250 list.replaceChildren();
251 // De server geeft nieuwste eerst; een gesprek lees je oudste eerst.
252 [...items].reverse().forEach((m) => {
253 const b = el('div', `bubble ${m.dir}${m.wave ? ' wave' : ''}`);
254 const body = el('div', 'body');
255 body.innerHTML = m.bodyHtml || '';
256 b.append(body, el('span', 'when', m.whenText || ''));
257 list.append(b);
258 });
259 list.scrollTop = list.scrollHeight;
260 } catch { /* volgende poll probeert opnieuw */ }
261 }
262
263 function openThread(c) {
264 openWith = c;
265 document.getElementById('thread-name').textContent = c.name;
266 thread.hidden = false;
267 loadThread();
268 // Pollen zolang het gesprek open staat: eenvoudig en zuinig genoeg.
269 // Een socket kan later; het ritme van een gesprek is geen milliseconde.
270 clearInterval(pollTimer);
271 pollTimer = setInterval(loadThread, 8000);
272 }
273
274 function closeThread() {
275 openWith = null;
276 thread.hidden = true;
277 clearInterval(pollTimer);
278 }
279 document.getElementById('thread-close').addEventListener('click', closeThread);
280 document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && openWith) closeThread(); });
281
282 document.getElementById('thread-form').addEventListener('submit', async (e) => {
283 e.preventDefault();
284 const input = document.getElementById('thread-text');
285 const text = input.value.trim();
286 if (!text || !openWith) return;
287 input.value = '';
288 await sendMessage(openWith.uri, text, false);
289 loadThread();
290 });
291
292 async function sendMessage(to, text, wave) {
293 try {
294 const r = await fetch('/ward/api/message', {
295 method: 'POST',
296 headers: { 'Content-Type': 'application/json' },
297 body: JSON.stringify({ to, text, wave }),
298 });
299 return r.ok;
300 } catch { return false; }
301 }
302
303 /* ── De zwaai-hand (§5) ────────────────────────────────────────────────
304 Dezelfde hoek als de boei, en ze wisselen elkaar af met de stand: met de
305 kaarten verdwijnt de boei, op de hemel hangt de hand. Slepen op een
306 gezicht opent het paneel; de tekst die je kiest IS het bericht. */
307 const hand = document.getElementById('hand');
308 const waveDlg = document.getElementById('wave-dialog');
309 let waveTarget = null;
310
311 (() => {
312 let dragging = false, dx = 0, dy = 0;
313 hand.addEventListener('pointerdown', (e) => {
314 dragging = true;
315 hand.setPointerCapture(e.pointerId);
316 const r = hand.getBoundingClientRect();
317 dx = e.clientX - r.left; dy = e.clientY - r.top;
318 hand.classList.add('dragging');
319 });
320 hand.addEventListener('pointermove', (e) => {
321 if (!dragging) return;
322 hand.style.left = `${e.clientX - dx}px`;
323 hand.style.top = `${e.clientY - dy}px`;
324 hand.style.right = 'auto'; hand.style.bottom = 'auto';
325 });
326 const drop = (e) => {
327 if (!dragging) return;
328 dragging = false;
329 hand.classList.remove('dragging');
330 hand.style.left = hand.style.top = 'auto';
331 hand.style.right = ''; hand.style.bottom = '';
332 // Zelfde rekensom als een tik op de hemel: het dichtstbijzijnde gezicht
333 // binnen bereik wint, en ernaast is bewust niets.
334 const r = canvas.getBoundingClientRect();
335 const hit = sky.pick(e.clientX - r.left, e.clientY - r.top);
336 if (hit != null && convs[hit]) {
337 waveTarget = convs[hit];
338 document.getElementById('wave-title').textContent =
339 state.strings.waveTo.replace('{who}', waveTarget.name);
340 waveDlg.showModal();
341 }
342 };
343 hand.addEventListener('pointerup', drop);
344 hand.addEventListener('pointercancel', drop);
345 })();
346
347 waveDlg.addEventListener('click', async (e) => {
348 const btn = e.target.closest('button');
349 if (!btn) return;
350 waveDlg.close();
351 if (btn.id === 'wave-cancel' || !waveTarget) return;
352 const isWave = btn.dataset.wave === '1';
353 const text = isWave ? '👋' : btn.dataset.text;
354 if (await sendMessage(waveTarget.uri, text, isWave)) openThread(waveTarget);
355 });
356
357 let messagesMode = false;
358 const setMode = (on) => {
359 messagesMode = on;
360 sky.interactive = on;
361 canvas.setAttribute('aria-hidden', String(!on));
362 // touch-action pas uitzetten als de hemel echt bedienbaar is: anders kun
363 // je in kaartenstand niet meer scrollen.
364 canvas.style.touchAction = on ? 'none' : '';
365 canvas.style.pointerEvents = on ? 'auto' : 'none';
366 document.body.classList.toggle('messages-mode', on);
367 document.getElementById('buoy').hidden = on;
368 hand.hidden = !on;
369 if (!on) closeThread();
370 btn.textContent = on ? state.strings.toCards : state.strings.toSky;
371 btn.setAttribute('aria-pressed', String(on));
372 main.querySelectorAll('section').forEach((sec) => { sec.hidden = on; });
373 };
374
375 btn.addEventListener('click', () => setMode(!messagesMode));
376 setMode(false);
377
378 (async () => {
379 try {
380 const r = await fetch('/ward/api/conversations', { headers: { Accept: 'application/json' } });
381 if (!r.ok) return;
382 convs = (await r.json()).items || [];
383 await sky.setFaces(convs.map((c) => ({ name: c.name, icon: c.icon, ageMs: c.ageMs })));
384 sky.start();
385 } catch { /* zonder hemel blijft het scherm gewoon werken */ }
386 })();
387}
388
389/** 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. */
390async function loadChecks() {
391 if (!state.guardians.length) return;
392 try {
393 const r = await fetch('/ward/api/guardian-checks', { headers: { Accept: 'application/json' } });
394 if (!r.ok) return;
395 const { checks } = await r.json();
396 renderGuardians(checks);
397 } catch { /* stilte is hier juist: dan blijft de lijst zonder oordeel staan */ }
398}
399
400function wireHelp() {
401 const btn = $('help-btn');
402 const hint = $('help-hint');
403 if (!state.guardians.length) {
404 btn.disabled = true;
405 hint.textContent = 'Je hebt nog geen guardians om te vragen.';
406 return;
407 }
408 hint.textContent = `Gaat naar ${state.guardians.length} guardian(s).`;
409 btn.addEventListener('click', () => document.getElementById('buoy').click());
410}
411
412renderGuardians(null);
413wireBuoy();
414wireSky();
415loadFeed();
416renderOffers();
417renderWaiting();
418wireHelp();
419loadChecks();
Note: See TracBrowser for help on using the repository browser.