source: Klonkt/src/assets/js/ward.js@ 2086ea2

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

Ward-PWA: de composer met media

Schrijven met foto, geluid of video, in de browser.

DEZELFDE POORT ALS DE APP, en dat is de kern. De composer bouwt precies
de AS2 Create die Shaer over C2S stuurt en geeft die aan
ingestOutboxActivity: die doet de compose-poort (par. 5), de
attachment-validatie en de zichtbaarheid. GEEN eigen posting-pad, want
dan zou de web-ward een sluiproute om de guardians heen zijn. Een test
bewaakt precies dat: een gated ward wordt ook via de browser
tegengehouden.

Media gaan eerst apart naar /ward/api/upload, in DEZELFDE bak als de
app (reply-media), zodat een foto van een app-ward en een browser-ward
niet van twee plekken federeert. De server maakt de rest af zoals altijd:
posterframe voor video, waveform voor audio. De post draagt daarna alleen
de url, dus de payload blijft klein.

Sessie-geauthenticeerd, niet met een bearer: /ward draait op je eigen
ingelogde Klonkt, dus de browser heeft geen token nodig zoals de app.

New file: (geen)
Changed files:
src/routes/ward.js

  • /api/upload: sessie-geauthenticeerd, zelfde filters/map als C2S
  • /api/post: bouwt de AS2 Create, door ingestOutboxActivity

src/views/pages/ward.ejs

  • schrijfknop, composer-dialoog met tekst, media en zichtbaarheid

src/assets/js/ward.js

  • upload-per-bestand, media-chips, plaatsen; opruiming van een dubbel geraakte doc-comment

src/assets/css/ward.css

  • composer, media-chips, tools

src/services/i18n.js

  • twaalf sleutels in nl, en, de

test/ward-pwa.test.js

  • de gate geldt ook in de browser; een vrij account plaatst met media; leeg wordt geweigerd; een vreemde kan niet uploaden

remarks: 903 tests groen. Opnemen van audio/video in de browser zelf
(MediaRecorder) is nog niet gebouwd; nu kies je een bestand. Bead volgt.

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

  • Property mode set to 100644
File size: 20.4 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
[2086ea2]425/**
426 * De composer: schrijven met foto, geluid of video.
427 *
428 * De media gaan eerst apart naar /ward/api/upload (dezelfde bak als de app),
429 * en de post draagt daarna alleen de url mee. Zo blijft de post-payload klein
430 * en kan de server elke upload los valideren en verrijken (poster, waveform).
431 */
432function wireComposer() {
433 const dlg = document.getElementById('compose-dialog');
434 const openBtn = document.getElementById('compose-open');
435 const text = document.getElementById('compose-text');
436 const mediaBox = document.getElementById('compose-media');
437 const status = document.getElementById('compose-status');
438 const sendBtn = document.getElementById('compose-send');
439 let media = []; // { url, mediaType, name }
440 let uploading = false;
441
442 function reset() {
443 text.value = '';
444 media = [];
445 mediaBox.replaceChildren();
446 status.textContent = '';
447 updateSend();
448 }
449 function updateSend() {
450 // Een lege post kan niet: tekst OF media, en nooit tijdens een upload.
451 sendBtn.disabled = (!text.value.trim() && !media.length) || uploading;
452 }
453 function renderMedia() {
454 mediaBox.replaceChildren();
455 media.forEach((m, i) => {
456 const chip = el('div', 'media-chip');
457 if (m.mediaType.startsWith('image/')) {
458 const img = el('img'); img.src = m.url; img.alt = m.name || ''; chip.append(img);
459 } else {
460 chip.append(el('span', 'kind', m.mediaType.startsWith('audio/') ? '🎤' : '🎬'));
461 chip.append(el('span', 'name', m.name || ''));
462 }
463 const x = el('button', 'drop', '✕'); x.type = 'button';
464 x.addEventListener('click', () => { media.splice(i, 1); renderMedia(); updateSend(); });
465 chip.append(x);
466 mediaBox.append(chip);
467 });
468 }
469
470 async function upload(file) {
471 if (media.length >= 4) return;
472 // 32 MB is de servergrens; hier alvast vangen scheelt een mislukte upload.
473 if (file.size > 32 * 1024 * 1024) { status.textContent = state.strings.tooBig; return; }
474 uploading = true; status.textContent = state.strings.uploading; updateSend();
475 try {
476 const fd = new FormData();
477 fd.append('file', file);
478 const r = await fetch('/ward/api/upload', { method: 'POST', body: fd });
479 if (r.ok) { media.push(await r.json()); renderMedia(); status.textContent = ''; }
480 else status.textContent = (await r.json().catch(() => ({}))).error === 'media_type'
481 ? 'Dat bestandstype kan niet.' : 'Uploaden lukte niet.';
482 } catch { status.textContent = 'Uploaden lukte niet.'; }
483 uploading = false; updateSend();
484 }
485
486 openBtn.addEventListener('click', () => { reset(); dlg.showModal(); });
487 document.getElementById('compose-cancel').addEventListener('click', () => dlg.close());
488 text.addEventListener('input', updateSend);
489
490 dlg.querySelectorAll('input[type=file]').forEach((inp) => {
491 inp.addEventListener('change', () => {
492 if (inp.files[0]) upload(inp.files[0]);
493 inp.value = ''; // zelfde bestand opnieuw kunnen kiezen
494 });
495 });
496
497 sendBtn.addEventListener('click', async () => {
498 if (sendBtn.disabled) return;
499 sendBtn.disabled = true;
500 try {
501 const r = await fetch('/ward/api/post', {
502 method: 'POST',
503 headers: { 'Content-Type': 'application/json' },
504 body: JSON.stringify({
505 text: text.value,
506 media,
507 visibility: document.getElementById('compose-vis').value,
508 }),
509 });
510 if (r.ok) { dlg.close(); loadFeed(); }
511 else status.textContent = 'Plaatsen lukte niet.';
512 } catch { status.textContent = 'Plaatsen lukte niet.'; }
513 updateSend();
514 });
515}
516
517/** De controle kost per guardian een remote fetch, dus hij komt ná het tekenen. */
[303bfc4]518async function loadChecks() {
519 if (!state.guardians.length) return;
520 try {
521 const r = await fetch('/ward/api/guardian-checks', { headers: { Accept: 'application/json' } });
522 if (!r.ok) return;
523 const { checks } = await r.json();
524 renderGuardians(checks);
525 } catch { /* stilte is hier juist: dan blijft de lijst zonder oordeel staan */ }
526}
527
528function wireHelp() {
529 const btn = $('help-btn');
530 const hint = $('help-hint');
531 if (!state.guardians.length) {
532 btn.disabled = true;
533 hint.textContent = 'Je hebt nog geen guardians om te vragen.';
534 return;
535 }
536 hint.textContent = `Gaat naar ${state.guardians.length} guardian(s).`;
[a5a6249]537 btn.addEventListener('click', () => document.getElementById('buoy').click());
[303bfc4]538}
539
540renderGuardians(null);
[a5a6249]541wireBuoy();
[084b046]542wireSky();
[2086ea2]543wireComposer();
[a5a6249]544loadFeed();
[303bfc4]545renderOffers();
546renderWaiting();
547wireHelp();
548loadChecks();
Note: See TracBrowser for help on using the repository browser.