dom.mjs raw
1 // TinyJS Runtime — DOM Bridge
2 // Provides Go-callable browser DOM operations.
3 // Elements are tracked by integer handles.
4 // Function names match Go signatures (PascalCase).
5
6 const _elements = new Map();
7 let _nextId = 1; // Safe up to 2^53 in JS. No wrap needed.
8 const _callbacks = new Map();
9 let _nextCb = 1;
10
11 // Bootstrap: map handle 0 to document.body (available after DOMContentLoaded).
12 function ensureBody() {
13 if (!_elements.has(0) && typeof document !== 'undefined' && document.body) {
14 _elements.set(0, document.body);
15 }
16 }
17
18 // Element creation and lookup.
19 export function CreateElement(tag) {
20 const el = document.createElement(tag);
21 const id = _nextId++;
22 _elements.set(id, el);
23 return id;
24 }
25
26 export function CreateTextNode(text) {
27 const node = document.createTextNode(text);
28 const id = _nextId++;
29 _elements.set(id, node);
30 return id;
31 }
32
33 export function GetElementById(id) {
34 const el = document.getElementById(id);
35 if (!el) return 0;
36 const handle = _nextId++;
37 _elements.set(handle, el);
38 return handle;
39 }
40
41 export function QuerySelector(sel) {
42 const el = document.querySelector(sel);
43 if (!el) return 0;
44 const handle = _nextId++;
45 _elements.set(handle, el);
46 return handle;
47 }
48
49 export function Body() {
50 ensureBody();
51 return 0;
52 }
53
54 // Tree manipulation.
55 export function AppendChild(parentId, childId) {
56 const parent = _elements.get(parentId);
57 const child = _elements.get(childId);
58 if (parent && child) parent.appendChild(child);
59 }
60
61 export function RemoveChild(parentId, childId) {
62 const parent = _elements.get(parentId);
63 const child = _elements.get(childId);
64 if (parent && child) parent.removeChild(child);
65 }
66
67 export function FirstChild(parentId) {
68 const parent = _elements.get(parentId);
69 if (parent && parent.firstChild) {
70 const id = _nextId++;
71 _elements.set(id, parent.firstChild);
72 return id;
73 }
74 return 0;
75 }
76
77 export function FirstElementChild(parentId) {
78 const parent = _elements.get(parentId);
79 if (parent && parent.firstElementChild) {
80 const id = _nextId++;
81 _elements.set(id, parent.firstElementChild);
82 return id;
83 }
84 return 0;
85 }
86
87 export function InsertBefore(parentId, newId, refId) {
88 const parent = _elements.get(parentId);
89 const newEl = _elements.get(newId);
90 const ref = refId >= 0 ? _elements.get(refId) : null;
91 if (parent && newEl) parent.insertBefore(newEl, ref);
92 }
93
94 export function ReplaceChild(parentId, newId, oldId) {
95 const parent = _elements.get(parentId);
96 const newEl = _elements.get(newId);
97 const oldEl = _elements.get(oldId);
98 if (parent && newEl && oldEl) parent.replaceChild(newEl, oldEl);
99 }
100
101 // Properties and attributes.
102 export function SetAttribute(elId, name, value) {
103 const el = _elements.get(elId);
104 if (el && el.setAttribute) el.setAttribute(name, value);
105 }
106
107 export function RemoveAttribute(elId, name) {
108 const el = _elements.get(elId);
109 if (el && el.removeAttribute) el.removeAttribute(name);
110 }
111
112 export function SetTextContent(elId, text) {
113 const el = _elements.get(elId);
114 if (el) el.textContent = text;
115 }
116
117 export function SetInnerHTML(elId, html) {
118 const el = _elements.get(elId);
119 if (el && 'innerHTML' in el) el.innerHTML = html;
120 }
121
122 export function SetStyle(elId, prop, value) {
123 const el = _elements.get(elId);
124 if (el && el.style) el.style[prop] = value;
125 }
126
127 export function Focus(elId) {
128 const el = _elements.get(elId);
129 if (el && el.focus) el.focus();
130 }
131
132 export function SetProperty(elId, prop, value) {
133 const el = _elements.get(elId);
134 if (el) el[prop] = value;
135 }
136
137 export function GetProperty(elId, prop) {
138 const el = _elements.get(elId);
139 if (el) return String(el[prop] ?? '');
140 return '';
141 }
142
143 export function AddClass(elId, cls) {
144 const el = _elements.get(elId);
145 if (el && el.classList) el.classList.add(cls);
146 }
147
148 export function RemoveClass(elId, cls) {
149 const el = _elements.get(elId);
150 if (el && el.classList) el.classList.remove(cls);
151 }
152
153 // Events.
154 export function AddEventListener(elId, event, callbackId) {
155 const el = _elements.get(elId);
156 const cb = _callbacks.get(callbackId);
157 if (el && cb) el.addEventListener(event, cb);
158 }
159
160 // AddEnterKeyListener fires callback when Enter is pressed on the element.
161 export function AddEnterKeyListener(elId, callbackId) {
162 const el = _elements.get(elId);
163 const cb = _callbacks.get(callbackId);
164 if (el && cb) el.addEventListener('keydown', function(e) {
165 if (e.key === 'Enter') { e.preventDefault(); cb(); }
166 });
167 }
168
169 // Like AddEventListener but only fires when e.target === e.currentTarget.
170 // Useful for backdrop click-to-close without catching child clicks.
171 export function AddSelfEventListener(elId, event, callbackId) {
172 const el = _elements.get(elId);
173 const cb = _callbacks.get(callbackId);
174 if (el && cb) el.addEventListener(event, function(e) {
175 if (e.target === e.currentTarget) cb();
176 });
177 }
178
179 export function RemoveEventListener(elId, event, callbackId) {
180 const el = _elements.get(elId);
181 const cb = _callbacks.get(callbackId);
182 if (el && cb) el.removeEventListener(event, cb);
183 }
184
185 // Register a Go function as a JS callback. Returns callback ID.
186 export function RegisterCallback(fn) {
187 const id = _nextCb++;
188 _callbacks.set(id, fn);
189 return id;
190 }
191
192 export function ReleaseCallback(id) {
193 _callbacks.delete(id);
194 }
195
196 // Scheduling.
197 export function RequestAnimationFrame(fn) {
198 if (typeof window !== 'undefined') {
199 window.requestAnimationFrame(fn);
200 } else {
201 setTimeout(fn, 16);
202 }
203 }
204
205 export function SetTimeout(fn, ms) {
206 return setTimeout(fn, ms);
207 }
208
209 export function SetInterval(fn, ms) {
210 return setInterval(fn, ms);
211 }
212
213 export function ClearInterval(id) {
214 clearInterval(id);
215 }
216
217 export function ClearTimeout(id) {
218 clearTimeout(id);
219 }
220
221 // Cleanup: release element handle.
222 export function ReleaseElement(id) {
223 _elements.delete(id);
224 }
225
226 // Fetch a URL as text, call fn with result.
227 export function FetchText(url, fn) {
228 fetch(url).then(r => r.text()).then(t => { if (fn) fn(t); });
229 }
230
231 // Navigation.
232 export function NextSibling(elId) {
233 const el = _elements.get(elId);
234 if (el && el.nextSibling) {
235 const id = _nextId++;
236 _elements.set(id, el.nextSibling);
237 return id;
238 }
239 return 0;
240 }
241
242 // Console / browser.
243 export function ConsoleLog(msg) {
244 console.log(msg);
245 }
246
247 export function PrefersDark() {
248 if (typeof window !== 'undefined' && window.matchMedia) {
249 return window.matchMedia('(prefers-color-scheme: dark)').matches;
250 }
251 return false;
252 }
253
254 export function Confirm(msg) {
255 if (typeof window !== 'undefined') return window.confirm(msg);
256 return false;
257 }
258
259 // Service Worker communication — with direct WebSocket fallback for insecure contexts.
260 let _swMessageHandler = null;
261 let _swQueue = [];
262 const _hasSW = typeof navigator !== 'undefined' && 'serviceWorker' in navigator &&
263 (typeof self !== 'undefined' && self.isSecureContext);
264
265 // ========================================================================
266 // Direct WebSocket relay fallback (active when SW unavailable)
267 // Handles PROXY, REQ, CLOSE, EVENT messages using NIP-01 WebSocket protocol.
268 // ========================================================================
269
270 let _wsConns = {}; // url -> WebSocket
271 let _wsSubs = {}; // subID -> { filter: string, relays: Set<url> }
272 let _wsSeen = {}; // subID -> { eventID: true }
273 let _wsEoseCount = {}; // subID -> count of relays that sent EOSE
274 let _wsWriteRelays = []; // relay URLs for EVENT publishing
275
276 function _wsSend(handler, msg) {
277 if (handler) handler(msg);
278 }
279
280 function _wsEnsure(url) {
281 if (_wsConns[url]) return _wsConns[url];
282 var ws;
283 try { ws = new WebSocket(url); } catch(e) { return null; }
284 _wsConns[url] = ws;
285 ws.onopen = function() {
286 // Replay active subs for this relay.
287 for (var sid in _wsSubs) {
288 var sub = _wsSubs[sid];
289 if (sub.relays.has(url)) {
290 ws.send(JSON.stringify(["REQ", sid, JSON.parse(sub.filter)]));
291 }
292 }
293 };
294 ws.onmessage = function(e) {
295 var msg;
296 try { msg = JSON.parse(e.data); } catch(ex) { return; }
297 if (!Array.isArray(msg)) return;
298 if (msg[0] === "EVENT" && msg.length >= 3) {
299 var sid = msg[1], ev = msg[2];
300 if (!_wsSeen[sid]) _wsSeen[sid] = {};
301 if (ev.id && _wsSeen[sid][ev.id]) return;
302 if (ev.id) _wsSeen[sid][ev.id] = true;
303 _wsSend(_swMessageHandler, '["EVENT",' + JSON.stringify(sid) + ',' + JSON.stringify(ev) + ']');
304 if (ev.id) _wsSend(_swMessageHandler, '["SEEN_ON",' + JSON.stringify(ev.id) + ',' + JSON.stringify(url) + ']');
305 } else if (msg[0] === "EOSE" && msg.length >= 2) {
306 var sid2 = msg[1];
307 if (!_wsEoseCount[sid2]) _wsEoseCount[sid2] = 0;
308 _wsEoseCount[sid2]++;
309 var sub2 = _wsSubs[sid2];
310 if (sub2 && _wsEoseCount[sid2] >= sub2.relays.size) {
311 _wsSend(_swMessageHandler, '["EOSE",' + JSON.stringify(sid2) + ']');
312 }
313 }
314 };
315 ws.onclose = function() {
316 delete _wsConns[url];
317 // Trigger EOSE for subs that included this relay (connection died before EOSE).
318 for (var sid in _wsSubs) {
319 var sub = _wsSubs[sid];
320 if (sub.relays.has(url)) {
321 if (!_wsEoseCount[sid]) _wsEoseCount[sid] = 0;
322 _wsEoseCount[sid]++;
323 if (_wsEoseCount[sid] >= sub.relays.size) {
324 _wsSend(_swMessageHandler, '["EOSE",' + JSON.stringify(sid) + ']');
325 }
326 }
327 }
328 };
329 ws.onerror = function() { ws.close(); };
330 return ws;
331 }
332
333 function _wsCloseSub(sid) {
334 var sub = _wsSubs[sid];
335 if (!sub) return;
336 sub.relays.forEach(function(url) {
337 var ws = _wsConns[url];
338 if (ws && ws.readyState === WebSocket.OPEN) {
339 try { ws.send(JSON.stringify(["CLOSE", sid])); } catch(e) {}
340 }
341 });
342 delete _wsSubs[sid];
343 delete _wsSeen[sid];
344 delete _wsEoseCount[sid];
345 }
346
347 function _wsHandleMsg(raw) {
348 var arr;
349 try { arr = JSON.parse(raw); } catch(e) { return; }
350 if (!Array.isArray(arr)) return;
351 var cmd = arr[0];
352 if (cmd === "SET_PUBKEY") {
353 // no-op for direct WS
354 } else if (cmd === "SET_WRITE_RELAYS") {
355 _wsWriteRelays = arr[1] || [];
356 for (var i = 0; i < _wsWriteRelays.length; i++) _wsEnsure(_wsWriteRelays[i]);
357 } else if (cmd === "PROXY") {
358 var sid = arr[1], filter = arr[2], relays = arr[3] || [];
359 _wsCloseSub(sid);
360 _wsSubs[sid] = { filter: JSON.stringify(filter), relays: new Set(relays) };
361 _wsSeen[sid] = {};
362 _wsEoseCount[sid] = 0;
363 for (var j = 0; j < relays.length; j++) {
364 var ws = _wsEnsure(relays[j]);
365 if (ws && ws.readyState === WebSocket.OPEN) {
366 ws.send(JSON.stringify(["REQ", sid, filter]));
367 }
368 }
369 } else if (cmd === "REQ") {
370 var sid2 = arr[1], filter2 = arr[2];
371 var url = _wsWriteRelays[0];
372 if (url) {
373 _wsCloseSub(sid2);
374 _wsSubs[sid2] = { filter: JSON.stringify(filter2), relays: new Set([url]) };
375 _wsSeen[sid2] = {};
376 _wsEoseCount[sid2] = 0;
377 var ws2 = _wsEnsure(url);
378 if (ws2 && ws2.readyState === WebSocket.OPEN) {
379 ws2.send(JSON.stringify(["REQ", sid2, filter2]));
380 }
381 }
382 } else if (cmd === "CLOSE") {
383 _wsCloseSub(arr[1]);
384 } else if (cmd === "EVENT") {
385 var ev = arr[1], data = JSON.stringify(["EVENT", ev]);
386 for (var k = 0; k < _wsWriteRelays.length; k++) {
387 var ws3 = _wsEnsure(_wsWriteRelays[k]);
388 if (ws3 && ws3.readyState === WebSocket.OPEN) ws3.send(data);
389 }
390 } else if (cmd === "CLEAR_KEY") {
391 for (var sid3 in _wsSubs) _wsCloseSub(sid3);
392 for (var u in _wsConns) { try { _wsConns[u].close(); } catch(e) {} }
393 _wsConns = {};
394 _wsWriteRelays = [];
395 }
396 }
397
398 // ========================================================================
399
400 // All PostToSW messages, in order. Used to replay to a new SW on controllerchange.
401 var _swSent = [];
402
403 export function PostToSW(msg) {
404 if (!_hasSW) {
405 _wsHandleMsg(msg);
406 return;
407 }
408 var s = '' + msg;
409 _swSent.push(s);
410 if (navigator.serviceWorker.controller) {
411 navigator.serviceWorker.controller.postMessage(s);
412 } else {
413 _swQueue.push(s);
414 }
415 }
416
417 function _sendToSW() {
418 if (!navigator.serviceWorker.controller) return;
419 // Prefer queued messages (never sent). If queue is empty, replay all sent
420 // messages (they went to an old SW that was replaced).
421 var msgs;
422 if (_swQueue.length > 0) {
423 msgs = _swQueue;
424 _swQueue = [];
425 } else if (_swSent.length > 0) {
426 msgs = _swSent;
427 } else {
428 return;
429 }
430 for (var i = 0; i < msgs.length; i++) {
431 navigator.serviceWorker.controller.postMessage(msgs[i]);
432 }
433 }
434
435 if (_hasSW) {
436 navigator.serviceWorker.addEventListener('controllerchange', _sendToSW);
437 navigator.serviceWorker.ready.then(function(reg) {
438 if (!navigator.serviceWorker.controller && reg.active) {
439 reg.active.postMessage('CLAIM');
440 }
441 setTimeout(_sendToSW, 100);
442 });
443 }
444
445 export function OnSWMessage(fn) {
446 _swMessageHandler = fn;
447 if (_hasSW) {
448 navigator.serviceWorker.addEventListener('message', function(e) {
449 var d = e.data;
450 if (typeof d !== 'string') {
451 // Backward compat: old SW may send Slice objects via structured clone.
452 if (d && d.$array && typeof d.$length === 'number') {
453 var buf = new Uint8Array(d.$length);
454 var off = d.$offset || 0;
455 for (var i = 0; i < d.$length; i++) buf[i] = d.$array[off + i];
456 d = new TextDecoder().decode(buf);
457 } else {
458 return; // Not a string and not a Slice — skip.
459 }
460 }
461 if (_swMessageHandler) _swMessageHandler(d);
462 });
463 }
464 }
465
466 export function ReadClipboard(fn) {
467 if (navigator.clipboard && navigator.clipboard.readText) {
468 navigator.clipboard.readText().then(function(t) { fn(t || ''); }).catch(function() {
469 var r = prompt('Paste nsec:');
470 fn(r || '');
471 });
472 } else {
473 var r = prompt('Paste nsec:');
474 fn(r || '');
475 }
476 }
477
478 // IndexedDB (simple key-value).
479 let _idb = null;
480 const _idbName = 'smesh-kv';
481 const _idbStores = ['profiles', 'events', 'cache'];
482
483 function _openIDB() {
484 return new Promise(function(resolve, reject) {
485 if (_idb) { resolve(_idb); return; }
486 const req = indexedDB.open(_idbName, 1);
487 req.onupgradeneeded = function(e) {
488 const db = e.target.result;
489 for (const s of _idbStores) {
490 if (!db.objectStoreNames.contains(s)) db.createObjectStore(s);
491 }
492 };
493 req.onsuccess = function(e) { _idb = e.target.result; resolve(_idb); };
494 req.onerror = function() { reject(req.error); };
495 });
496 }
497
498 export function IDBGet(store, key, fn) {
499 _openIDB().then(function(db) {
500 const tx = db.transaction(store, 'readonly');
501 const req = tx.objectStore(store).get(key);
502 req.onsuccess = function() { fn(req.result ?? ''); };
503 req.onerror = function() { fn(''); };
504 }).catch(function() { fn(''); });
505 }
506
507 export function IDBPut(store, key, value) {
508 _openIDB().then(function(db) {
509 const tx = db.transaction(store, 'readwrite');
510 tx.objectStore(store).put(value, key);
511 });
512 }
513
514 export function IDBGetAll(store, fn, done) {
515 _openIDB().then(function(db) {
516 const tx = db.transaction(store, 'readonly');
517 const req = tx.objectStore(store).openCursor();
518 req.onsuccess = function(e) {
519 const cursor = e.target.result;
520 if (cursor) {
521 fn(String(cursor.key), String(cursor.value));
522 cursor.continue();
523 } else {
524 if (done) done();
525 }
526 };
527 req.onerror = function() { if (done) done(); };
528 }).catch(function() { if (done) done(); });
529 }
530
531 // Fetch relay NIP-11 info.
532 export function FetchRelayInfo(url, fn) {
533 fetch(url, { headers: { 'Accept': 'application/nostr+json' } })
534 .then(function(r) { return r.text(); })
535 .then(function(t) { if (fn) fn(t); })
536 .catch(function() { if (fn) fn(''); });
537 }
538
539 // History / location.
540 export function PushState(path) {
541 if (typeof history !== 'undefined') history.pushState(null, '', path);
542 }
543
544 export function ReplaceState(path) {
545 if (typeof history !== 'undefined') history.replaceState(null, '', path);
546 }
547
548 export function Back() {
549 if (typeof history !== 'undefined') history.back();
550 }
551
552 export function LocationReload() {
553 if (typeof location !== 'undefined') location.reload();
554 }
555
556 export function HardRefresh() {
557 var p = [];
558 if (typeof caches !== 'undefined') {
559 p.push(caches.keys().then(function(keys) {
560 return Promise.all(keys.map(function(k) { return caches.delete(k); }));
561 }));
562 }
563 if (typeof navigator !== 'undefined' && navigator.serviceWorker) {
564 p.push(navigator.serviceWorker.getRegistrations().then(function(regs) {
565 regs.forEach(function(r) { r.unregister(); });
566 }));
567 }
568 Promise.all(p).then(function() { location.reload(); });
569 }
570
571 export function ClearStoragePrefix(prefix) {
572 if (typeof localStorage === 'undefined') return;
573 var keys = [];
574 for (var i = 0; i < localStorage.length; i++) {
575 var k = localStorage.key(i);
576 if (k && k.indexOf(prefix) === 0) keys.push(k);
577 }
578 for (var j = 0; j < keys.length; j++) localStorage.removeItem(keys[j]);
579 }
580
581 export function TimezoneOffsetSeconds() {
582 // getTimezoneOffset() returns minutes, positive west of UTC.
583 // We return seconds to ADD to UTC to get local time.
584 return -(new Date().getTimezoneOffset()) * 60;
585 }
586
587 export function GetPath() {
588 if (typeof location !== 'undefined') return location.pathname;
589 return '/';
590 }
591
592 export function Hostname() {
593 if (typeof location !== 'undefined') return location.hostname;
594 return '';
595 }
596
597 export function Port() {
598 if (typeof location !== 'undefined') return location.port;
599 return '';
600 }
601
602 export function UserAgent() {
603 if (typeof navigator !== 'undefined') return navigator.userAgent;
604 return '';
605 }
606
607 export function OnPopState(fn) {
608 if (typeof window !== 'undefined') {
609 window.addEventListener('popstate', function() {
610 if (fn) fn(location.pathname);
611 });
612 }
613 }
614
615 // DownloadText triggers a file download with the given text content.
616 export function DownloadText(filename, content, mimeType) {
617 const blob = new Blob([content], { type: mimeType || 'text/plain' });
618 const url = URL.createObjectURL(blob);
619 const a = document.createElement('a');
620 a.href = url;
621 a.download = filename;
622 document.body.appendChild(a);
623 a.click();
624 setTimeout(function() { a.remove(); URL.revokeObjectURL(url); }, 1000);
625 }
626
627 // PickFileText opens a file picker and reads the selected file as text.
628 export function PickFileText(accept, fn) {
629 const input = document.createElement('input');
630 input.type = 'file';
631 if (accept) input.accept = accept;
632 input.style.display = 'none';
633 input.addEventListener('change', function() {
634 if (!input.files || !input.files[0]) { fn(''); return; }
635 const reader = new FileReader();
636 reader.onload = function() { fn(reader.result || ''); };
637 reader.onerror = function() { fn(''); };
638 reader.readAsText(input.files[0]);
639 });
640 document.body.appendChild(input);
641 input.click();
642 // Clean up after a delay (even if user cancels).
643 setTimeout(function() { input.remove(); }, 60000);
644 }
645
646 // NowSeconds returns current Unix time in seconds.
647 export function NowSeconds() {
648 return BigInt(Math.floor(Date.now() / 1000));
649 }
650
651 // OnPullRefresh registers a handler that fires when the user pulls to refresh.
652 // Handles both mouse wheel (accumulated up-scroll at top) and touch gestures.
653 export function OnPullRefresh(elId, indicatorId, fn) {
654 const el = _elements.get(elId);
655 const ind = _elements.get(indicatorId);
656 if (!el) return;
657
658 var pullAccum = 0;
659 var pullTimer = 0;
660 var threshold = 200;
661 var resetMs = 400;
662
663 el.addEventListener('wheel', function(e) {
664 if (el.scrollTop > 0 || e.deltaY >= 0) {
665 pullAccum = 0;
666 if (ind) ind.style.display = 'none';
667 return;
668 }
669 pullAccum += Math.abs(e.deltaY);
670 clearTimeout(pullTimer);
671 pullTimer = setTimeout(function() {
672 pullAccum = 0;
673 if (ind) ind.style.display = 'none';
674 }, resetMs);
675 if (ind && pullAccum > 40) ind.style.display = 'flex';
676 if (pullAccum >= threshold) {
677 pullAccum = 0;
678 fn();
679 }
680 }, { passive: true });
681
682 var touchStartY = 0;
683 el.addEventListener('touchstart', function(e) {
684 if (el.scrollTop === 0 && e.touches.length === 1) {
685 touchStartY = e.touches[0].clientY;
686 }
687 }, { passive: true });
688 el.addEventListener('touchmove', function(e) {
689 if (el.scrollTop > 0 || touchStartY === 0) return;
690 var dy = e.touches[0].clientY - touchStartY;
691 if (dy > 20 && ind) ind.style.display = 'flex';
692 if (dy > 80) {
693 touchStartY = 0;
694 fn();
695 }
696 }, { passive: true });
697 el.addEventListener('touchend', function() {
698 touchStartY = 0;
699 if (pullAccum < threshold && ind) ind.style.display = 'none';
700 }, { passive: true });
701 }
702
703 // Get raw element (for advanced use within JS runtime only).
704 export function getRawElement(id) {
705 return _elements.get(id);
706 }
707