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