/** * De Ward PWA (FEP-633c), clientkant. Tegenhanger van guardian.js, maar dan * vanuit het kind: wie waakt over mij, is dat vangnet heel, en wat ligt er bij * hen te wachten. * * Geen framework, net als guardian.js: de toestand komt als JSON mee met de * pagina en dit bestand tekent hem. Alles wat nieuw is komt van /ward/api/*. */ import { SkyRenderer } from '/ward/sky.js'; const state = JSON.parse(document.getElementById('ward-state').textContent); const $ = (id) => document.getElementById(id); /** * De §4-uitkomst in woorden. * * Drie gevallen, en het derde is het punt: "onbereikbaar" is GEEN goedkeuring. * Een ward wiens guardian-server plat ligt heeft een ONGECONTROLEERD vangnet, * en dat is iets anders dan een gecontroleerd vangnet. Niets tonen zou het * eerste als het tweede laten lezen. Zelfde drieslag als in Shaer. */ const CHECK_NOTE = { malformed: { text: 'Deze guardian heeft zelf guardians. Een noodoproep komt hier niet aan.', cls: 'alarm' }, unreachable: { text: 'Deze guardian was niet te bereiken, dus niet gecontroleerd.', cls: 'unknown' }, sound: null, }; /** Beschikbaarheid in de taal die iedereen zonder handleiding leest. */ const AVAILABILITY = { active: { label: 'Beschikbaar', cls: 'ok' }, away: { label: 'Even weg', cls: 'away' }, dormant: { label: 'Offline', cls: 'off' }, }; function el(tag, cls, text) { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; } function renderGuardians(checks) { const list = $('guardian-list'); list.replaceChildren(); $('guardians-empty').hidden = state.guardians.length > 0; state.guardians.forEach((g) => { const li = el('li', 'card'); const head = el('div', 'row'); head.append(el('span', 'name', g.handle)); const av = AVAILABILITY[g.availability] || AVAILABILITY.active; head.append(el('span', `chip ${av.cls}`, av.label)); li.append(head); // De controle staat WAAR de guardian genoemd wordt, niet in een logboek: // daar heeft het gat betekenis. const note = CHECK_NOTE[checks?.[g.uri]]; if (note) li.append(el('p', `note ${note.cls}`, note.text)); list.append(li); }); } function renderOffers() { const sec = $('offers'); const list = $('offer-list'); list.replaceChildren(); sec.hidden = !state.offers.length; state.offers.forEach((o) => { const li = el('li', 'card'); const who = o['shaer:candidate'] || o.actor || ''; li.append(el('p', 'name', `${who} wil jouw guardian worden`)); if (o['shaer:acceptedBy']) { li.append(el('p', 'hint', `${o['shaer:acceptedBy'].length} van de partijen zei al ja`)); } list.append(li); }); } function renderWaiting() { const sec = $('waiting'); const list = $('waiting-list'); list.replaceChildren(); sec.hidden = !state.waitingFollows.length; state.waitingFollows.forEach((w) => { const li = el('li', 'card'); li.append(el('p', 'name', w.handle)); // Niet "pending": de bal ligt bij iemand die je kent, en geweigerd is // helemaal geen wachten meer. Zelfde drie zinnen als in Shaer. const denied = w.status === 'denied'; li.append(el('p', denied ? 'note alarm' : 'hint', denied ? 'Je guardians hebben hier nee tegen gezegd.' : 'Je guardians moeten hier nog ja tegen zeggen.')); list.append(li); }); } /** * De kaarten. De body komt AF van de server (dezelfde partial als de Krant), * dus hier alleen invoegen; zelf HTML samenstellen zou opmaak, media en * quote-kaarten opnieuw moeten uitvinden en er anders uit laten zien. */ function renderFeed(items) { const box = document.getElementById('card-list'); box.replaceChildren(); document.getElementById('feed-empty').hidden = items.length > 0; items.forEach((it) => { const card = el('article', 'post'); card.dataset.note = it.id; // waar de boei op mikt const head = el('div', 'row'); if (it.authorIcon) { const img = el('img', 'avatar'); img.src = it.authorIcon; img.alt = ''; img.loading = 'lazy'; head.append(img); } head.append(el('span', 'name', it.authorName || it.author)); head.append(el('span', 'when', it.whenText || '')); card.append(head); if (it.cw) card.append(el('p', 'cw', it.cw)); const body = el('div', 'body'); body.innerHTML = it.bodyHtml || ''; // server-gerenderd en gesaniteerd card.append(body); box.append(card); }); } async function loadFeed() { try { const r = await fetch('/ward/api/feed', { headers: { Accept: 'application/json' } }); if (!r.ok) return; renderFeed((await r.json()).items || []); } catch { /* de pagina blijft staan met wat er al is */ } } /** * De reddingsboei: sleep hem op een kaart en je guardians krijgen die post. * * Pointer-events in plaats van muis- of touch-events apart, want dat is het * enige dat op een telefoon en met een muis hetzelfde doet. `setPointerCapture` * zorgt dat de sleep van jou blijft, en `touch-action: none` op de boei houdt * de pagina stil terwijl je hem verplaatst: zonder dat vecht het slepen met * scrollen en verlies je hem halverwege. */ function wireBuoy() { const buoy = document.getElementById('buoy'); const dlg = document.getElementById('help-dialog'); let dragging = false, dx = 0, dy = 0, target = null; const cardUnder = (x, y) => document.elementFromPoint(x, y)?.closest('.post'); buoy.addEventListener('pointerdown', (e) => { dragging = true; buoy.setPointerCapture(e.pointerId); const r = buoy.getBoundingClientRect(); dx = e.clientX - r.left; dy = e.clientY - r.top; buoy.classList.add('dragging'); }); buoy.addEventListener('pointermove', (e) => { if (!dragging) return; buoy.style.left = `${e.clientX - dx}px`; buoy.style.top = `${e.clientY - dy}px`; buoy.style.right = 'auto'; buoy.style.bottom = 'auto'; // Onderweg oplichten waar hij zou landen, zodat je ziet wat je kiest. document.querySelectorAll('.post.hover').forEach((n) => n.classList.remove('hover')); cardUnder(e.clientX, e.clientY)?.classList.add('hover'); }); const drop = (e) => { if (!dragging) return; dragging = false; buoy.classList.remove('dragging'); const card = cardUnder(e.clientX, e.clientY); document.querySelectorAll('.post.hover').forEach((n) => n.classList.remove('hover')); // Terug naar zijn hoek: de boei hoort altijd op dezelfde plek te liggen. buoy.style.left = buoy.style.top = 'auto'; buoy.style.right = ''; buoy.style.bottom = ''; if (card) askHelp(card.dataset.note, card.querySelector('.name')?.textContent || ''); }; buoy.addEventListener('pointerup', drop); buoy.addEventListener('pointercancel', drop); // Een tik (zonder slepen) vraagt hulp zonder dat er een post bij hoort. buoy.addEventListener('click', (e) => { if (!e.detail || !dragging) askHelp(null, null); }); function askHelp(noteId, who) { document.getElementById('help-title').textContent = who ? `Hulp vragen bij het bericht van ${who}?` : 'Hulp vragen aan je guardians?'; dlg.dataset.note = noteId || ''; document.getElementById('help-words').value = ''; dlg.showModal(); } dlg.addEventListener('close', async () => { if (dlg.returnValue !== 'send') return; const body = { note: dlg.dataset.note || undefined, words: document.getElementById('help-words').value }; const hint = document.getElementById('help-hint'); try { const r = await fetch('/ward/api/help', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const out = await r.json().catch(() => ({})); hint.textContent = r.ok ? state.strings.helpSent : (out.error === 'no_guardians' ? 'Je hebt nog geen guardians om te vragen.' : 'Dat lukte niet; probeer het nog eens.'); } catch { hint.textContent = 'Dat lukte niet; probeer het nog eens.'; } }); } /** * De hemel en de kaarten zijn één scherm met twee standen. * * De hemel draait ALTIJD en ligt achter de kaarten; in kaartenstand is hij * decor en vangt hij geen tikken, in berichtenstand vallen de kaarten weg en * wordt hij de interface. Dat is precies de beweging uit Shaer: de kaarten * vallen weg en wat eronder lag was er al. */ function wireSky() { const canvas = document.getElementById('sky'); const btn = document.getElementById('mode'); const main = document.getElementById('app'); let convs = []; const sky = new SkyRenderer(canvas, (i) => { const c = convs[i]; if (c) openThread(c); }, () => openFriendsPicker()); /* ── De (+): de deur naar iemand nieuw ───────────────────────────────── De hemel toont alleen met wie je al sprak; de kiezer toont iedereen met een volgrelatie. Kies iemand en het lege gesprek opent gewoon: het eerste bericht maakt het echt. */ const friendsDlg = document.getElementById('friends-dialog'); async function openFriendsPicker() { const list = document.getElementById('friends-list'); list.replaceChildren(); friendsDlg.showModal(); try { const r = await fetch('/ward/api/friends', { headers: { Accept: 'application/json' } }); const items = r.ok ? (await r.json()).items || [] : []; document.getElementById('friends-empty').hidden = items.length > 0; items.forEach((f) => { const li = el('li', 'card friend'); const row = el('button', 'row'); row.type = 'button'; if (f.icon) { const img = el('img', 'avatar'); img.src = f.icon; img.alt = ''; img.loading = 'lazy'; row.append(img); } const kolom = el('span', 'wie'); kolom.append(el('span', 'name', f.name), el('span', 'hint', f.handle)); row.append(kolom); row.addEventListener('click', () => { friendsDlg.close(); openThread({ uri: f.uri, name: f.name, icon: f.icon }); }); li.append(row); list.append(li); }); } catch { /* de kiezer blijft leeg staan; annuleren kan altijd */ } } document.getElementById('friends-cancel').addEventListener('click', () => friendsDlg.close()); /* ── Het gesprek: een overlay OVER de levende hemel ──────────────────── Geen aparte pagina: de hemel eronder blijft dezelfde draaiende instantie, precies zoals de thread in Shaer over de hemel schuift. */ const thread = document.getElementById('thread'); const list = document.getElementById('thread-list'); let openWith = null; let pollTimer = null; async function loadThread() { if (!openWith) return; try { const r = await fetch(`/ward/api/thread?with=${encodeURIComponent(openWith.uri)}`, { headers: { Accept: 'application/json' } }); if (!r.ok) return; const { items } = await r.json(); list.replaceChildren(); // De server geeft nieuwste eerst; een gesprek lees je oudste eerst. [...items].reverse().forEach((m) => { const b = el('div', `bubble ${m.dir}${m.wave ? ' wave' : ''}`); const body = el('div', 'body'); body.innerHTML = m.bodyHtml || ''; b.append(body, el('span', 'when', m.whenText || '')); list.append(b); }); list.scrollTop = list.scrollHeight; } catch { /* volgende poll probeert opnieuw */ } } function openThread(c) { openWith = c; document.getElementById('thread-name').textContent = c.name; thread.hidden = false; loadThread(); // Pollen zolang het gesprek open staat: eenvoudig en zuinig genoeg. // Een socket kan later; het ritme van een gesprek is geen milliseconde. clearInterval(pollTimer); pollTimer = setInterval(loadThread, 8000); } function closeThread() { openWith = null; thread.hidden = true; clearInterval(pollTimer); } document.getElementById('thread-close').addEventListener('click', closeThread); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && openWith) closeThread(); }); document.getElementById('thread-form').addEventListener('submit', async (e) => { e.preventDefault(); const input = document.getElementById('thread-text'); const text = input.value.trim(); if (!text || !openWith) return; input.value = ''; await sendMessage(openWith.uri, text, false); loadThread(); }); async function sendMessage(to, text, wave) { try { const r = await fetch('/ward/api/message', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ to, text, wave }), }); return r.ok; } catch { return false; } } /* ── De zwaai-hand (§5) ──────────────────────────────────────────────── 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; de tekst die je kiest IS het bericht. */ const hand = document.getElementById('hand'); const waveDlg = document.getElementById('wave-dialog'); let waveTarget = null; (() => { let dragging = false, dx = 0, dy = 0; hand.addEventListener('pointerdown', (e) => { dragging = true; hand.setPointerCapture(e.pointerId); const r = hand.getBoundingClientRect(); dx = e.clientX - r.left; dy = e.clientY - r.top; hand.classList.add('dragging'); }); hand.addEventListener('pointermove', (e) => { if (!dragging) return; hand.style.left = `${e.clientX - dx}px`; hand.style.top = `${e.clientY - dy}px`; hand.style.right = 'auto'; hand.style.bottom = 'auto'; }); const drop = (e) => { if (!dragging) return; dragging = false; hand.classList.remove('dragging'); hand.style.left = hand.style.top = 'auto'; hand.style.right = ''; hand.style.bottom = ''; // Zelfde rekensom als een tik op de hemel: het dichtstbijzijnde gezicht // binnen bereik wint, en ernaast is bewust niets. const r = canvas.getBoundingClientRect(); const hit = sky.pick(e.clientX - r.left, e.clientY - r.top); if (hit != null && convs[hit]) { waveTarget = convs[hit]; document.getElementById('wave-title').textContent = state.strings.waveTo.replace('{who}', waveTarget.name); waveDlg.showModal(); } }; hand.addEventListener('pointerup', drop); hand.addEventListener('pointercancel', drop); })(); waveDlg.addEventListener('click', async (e) => { const btn = e.target.closest('button'); if (!btn) return; waveDlg.close(); if (btn.id === 'wave-cancel' || !waveTarget) return; const isWave = btn.dataset.wave === '1'; const text = isWave ? '👋' : btn.dataset.text; if (await sendMessage(waveTarget.uri, text, isWave)) openThread(waveTarget); }); let messagesMode = false; const setMode = (on) => { messagesMode = on; sky.interactive = on; canvas.setAttribute('aria-hidden', String(!on)); // touch-action pas uitzetten als de hemel echt bedienbaar is: anders kun // je in kaartenstand niet meer scrollen. canvas.style.touchAction = on ? 'none' : ''; canvas.style.pointerEvents = on ? 'auto' : 'none'; document.body.classList.toggle('messages-mode', on); document.getElementById('buoy').hidden = on; hand.hidden = !on; if (!on) closeThread(); btn.textContent = on ? state.strings.toCards : state.strings.toSky; btn.setAttribute('aria-pressed', String(on)); main.querySelectorAll('section').forEach((sec) => { sec.hidden = on; }); }; btn.addEventListener('click', () => setMode(!messagesMode)); setMode(false); (async () => { try { const r = await fetch('/ward/api/conversations', { headers: { Accept: 'application/json' } }); if (!r.ok) return; convs = (await r.json()).items || []; await sky.setFaces(convs.map((c) => ({ name: c.name, icon: c.icon, ageMs: c.ageMs }))); sky.start(); } catch { /* zonder hemel blijft het scherm gewoon werken */ } })(); } /** 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. */ async function loadChecks() { if (!state.guardians.length) return; try { const r = await fetch('/ward/api/guardian-checks', { headers: { Accept: 'application/json' } }); if (!r.ok) return; const { checks } = await r.json(); renderGuardians(checks); } catch { /* stilte is hier juist: dan blijft de lijst zonder oordeel staan */ } } function wireHelp() { const btn = $('help-btn'); const hint = $('help-hint'); if (!state.guardians.length) { btn.disabled = true; hint.textContent = 'Je hebt nog geen guardians om te vragen.'; return; } hint.textContent = `Gaat naar ${state.guardians.length} guardian(s).`; btn.addEventListener('click', () => document.getElementById('buoy').click()); } renderGuardians(null); wireBuoy(); wireSky(); loadFeed(); renderOffers(); renderWaiting(); wireHelp(); loadChecks();