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  // Visible diagnostic overlay for mobile debugging (no console access).
 277  var _diagEl = null;
 278  function _diag(msg) {
 279    console.log(msg);
 280    if (!_diagEl) {
 281      _diagEl = document.createElement('div');
 282      _diagEl.style.cssText = 'position:fixed;bottom:0;left:0;right:0;max-height:40vh;overflow-y:auto;background:#111;color:#0f0;font:11px/1.4 monospace;padding:6px;z-index:99999;pointer-events:auto;';
 283      document.body.appendChild(_diagEl);
 284    }
 285    var line = document.createElement('div');
 286    line.textContent = msg;
 287    _diagEl.appendChild(line);
 288    _diagEl.scrollTop = _diagEl.scrollHeight;
 289  }
 290  let _wsPendingPub = {};  // url -> [json strings] queued while CONNECTING
 291  
 292  function _wsSend(handler, msg) {
 293    if (handler) handler(msg);
 294  }
 295  
 296  function _wsEnsure(url) {
 297    if (_wsConns[url]) return _wsConns[url];
 298    var ws;
 299    try { ws = new WebSocket(url); } catch(e) { return null; }
 300    _wsConns[url] = ws;
 301    ws.onopen = function() {
 302      _diag('WS OPEN ' + url);
 303      // Replay active subs for this relay.
 304      for (var sid in _wsSubs) {
 305        var sub = _wsSubs[sid];
 306        if (sub.relays.has(url)) {
 307          ws.send(JSON.stringify(["REQ", sid, JSON.parse(sub.filter)]));
 308        }
 309      }
 310      // Flush queued publishes.
 311      var pending = _wsPendingPub[url];
 312      if (pending) {
 313        _diag('FLUSH ' + pending.length + ' queued to ' + url);
 314        delete _wsPendingPub[url];
 315        for (var p = 0; p < pending.length; p++) ws.send(pending[p]);
 316      }
 317    };
 318    ws.onmessage = function(e) {
 319      var msg;
 320      try { msg = JSON.parse(e.data); } catch(ex) { return; }
 321      if (!Array.isArray(msg)) return;
 322      if (msg[0] === "EVENT" && msg.length >= 3) {
 323        var sid = msg[1], ev = msg[2];
 324        if (!_wsSeen[sid]) _wsSeen[sid] = {};
 325        if (ev.id && _wsSeen[sid][ev.id]) return;
 326        if (ev.id) _wsSeen[sid][ev.id] = true;
 327        _wsSend(_swMessageHandler, '["EVENT",' + JSON.stringify(sid) + ',' + JSON.stringify(ev) + ']');
 328        if (ev.id) _wsSend(_swMessageHandler, '["SEEN_ON",' + JSON.stringify(ev.id) + ',' + JSON.stringify(url) + ']');
 329      } else if (msg[0] === "EOSE" && msg.length >= 2) {
 330        var sid2 = msg[1];
 331        if (!_wsEoseCount[sid2]) _wsEoseCount[sid2] = 0;
 332        _wsEoseCount[sid2]++;
 333        var sub2 = _wsSubs[sid2];
 334        if (sub2 && _wsEoseCount[sid2] >= sub2.relays.size) {
 335          _wsSend(_swMessageHandler, '["EOSE",' + JSON.stringify(sid2) + ']');
 336        }
 337      } else if (msg[0] === "OK" && msg.length >= 3) {
 338        var okId = msg[1], okSuccess = msg[2], okMsg = msg[3] || '';
 339        if (okSuccess) { _diag('OK ' + url + ' id=' + (okId && okId.substring(0,8))); }
 340        else { _diag('REJECTED ' + url + ' ' + (okId && okId.substring(0,8)) + ': ' + okMsg); }
 341      } else if (msg[0] === "NOTICE") {
 342        _diag('NOTICE ' + url + ': ' + msg[1]);
 343      }
 344    };
 345    ws.onclose = function() {
 346      _diag('WS CLOSE ' + url);
 347      delete _wsConns[url];
 348      // Trigger EOSE for subs that included this relay (connection died before EOSE).
 349      for (var sid in _wsSubs) {
 350        var sub = _wsSubs[sid];
 351        if (sub.relays.has(url)) {
 352          if (!_wsEoseCount[sid]) _wsEoseCount[sid] = 0;
 353          _wsEoseCount[sid]++;
 354          if (_wsEoseCount[sid] >= sub.relays.size) {
 355            _wsSend(_swMessageHandler, '["EOSE",' + JSON.stringify(sid) + ']');
 356          }
 357        }
 358      }
 359    };
 360    ws.onerror = function() { ws.close(); };
 361    return ws;
 362  }
 363  
 364  function _wsCloseSub(sid) {
 365    var sub = _wsSubs[sid];
 366    if (!sub) return;
 367    sub.relays.forEach(function(url) {
 368      var ws = _wsConns[url];
 369      if (ws && ws.readyState === WebSocket.OPEN) {
 370        try { ws.send(JSON.stringify(["CLOSE", sid])); } catch(e) {}
 371      }
 372    });
 373    delete _wsSubs[sid];
 374    delete _wsSeen[sid];
 375    delete _wsEoseCount[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      _wsCloseSub(sid);
 391      _wsSubs[sid] = { filter: JSON.stringify(filter), relays: new Set(relays) };
 392      _wsSeen[sid] = {};
 393      _wsEoseCount[sid] = 0;
 394      for (var j = 0; j < relays.length; j++) {
 395        var ws = _wsEnsure(relays[j]);
 396        if (ws && ws.readyState === WebSocket.OPEN) {
 397          ws.send(JSON.stringify(["REQ", sid, filter]));
 398        }
 399      }
 400    } else if (cmd === "REQ") {
 401      var sid2 = arr[1], filter2 = arr[2];
 402      var url = _wsWriteRelays[0];
 403      if (url) {
 404        _wsCloseSub(sid2);
 405        _wsSubs[sid2] = { filter: JSON.stringify(filter2), relays: new Set([url]) };
 406        _wsSeen[sid2] = {};
 407        _wsEoseCount[sid2] = 0;
 408        var ws2 = _wsEnsure(url);
 409        if (ws2 && ws2.readyState === WebSocket.OPEN) {
 410          ws2.send(JSON.stringify(["REQ", sid2, filter2]));
 411        }
 412      }
 413    } else if (cmd === "CLOSE") {
 414      _wsCloseSub(arr[1]);
 415    } else if (cmd === "PUBLISH_TO") {
 416      var ev2 = arr[1], relays2 = arr[2] || [];
 417      _diag('PUBLISH_TO kind=' + (ev2 && ev2.kind) + ' id=' + (ev2 && ev2.id && ev2.id.substring(0,8)) + ' relays=' + relays2.length);
 418      var data2 = JSON.stringify(["EVENT", ev2]);
 419      for (var m = 0; m < relays2.length; m++) {
 420        var ws4 = _wsEnsure(relays2[m]);
 421        if (ws4 && ws4.readyState === WebSocket.OPEN) { _diag('SEND ' + relays2[m]); ws4.send(data2); }
 422        else if (ws4) { _diag('QUEUE ' + relays2[m] + ' state=' + ws4.readyState); if (!_wsPendingPub[relays2[m]]) _wsPendingPub[relays2[m]] = []; _wsPendingPub[relays2[m]].push(data2); }
 423        else { _diag('FAIL no WS ' + relays2[m]); }
 424      }
 425    } else if (cmd === "EVENT") {
 426      _diag('EVENT kind=' + (arr[1] && arr[1].kind) + ' writeRelays=' + _wsWriteRelays.length);
 427      var ev = arr[1], data = JSON.stringify(["EVENT", ev]);
 428      for (var k = 0; k < _wsWriteRelays.length; k++) {
 429        var ws3 = _wsEnsure(_wsWriteRelays[k]);
 430        if (ws3 && ws3.readyState === WebSocket.OPEN) { _diag('SEND ' + _wsWriteRelays[k]); ws3.send(data); }
 431        else if (ws3) { var u3 = _wsWriteRelays[k]; _diag('QUEUE ' + u3 + ' state=' + ws3.readyState); if (!_wsPendingPub[u3]) _wsPendingPub[u3] = []; _wsPendingPub[u3].push(data); }
 432        else { _diag('FAIL no WS ' + _wsWriteRelays[k]); }
 433      }
 434    } else if (cmd === "CLEAR_KEY") {
 435      for (var sid3 in _wsSubs) _wsCloseSub(sid3);
 436      for (var u in _wsConns) { try { _wsConns[u].close(); } catch(e) {} }
 437      _wsConns = {};
 438      _wsWriteRelays = [];
 439      _wsPendingPub = {};
 440    }
 441  }
 442  
 443  // ========================================================================
 444  
 445  // All PostToSW messages, in order. Used to replay to a new SW on controllerchange.
 446  var _swSent = [];
 447  
 448  export function PostToSW(msg) {
 449    if (!_hasSW) {
 450      _wsHandleMsg('' + msg);
 451      return;
 452    }
 453    var s = '' + msg;
 454    _swSent.push(s);
 455    if (navigator.serviceWorker.controller) {
 456      navigator.serviceWorker.controller.postMessage(s);
 457    } else {
 458      _swQueue.push(s);
 459    }
 460  }
 461  
 462  function _sendToSW() {
 463    if (!navigator.serviceWorker.controller) return;
 464    // Prefer queued messages (never sent). If queue is empty, replay all sent
 465    // messages (they went to an old SW that was replaced).
 466    var msgs;
 467    if (_swQueue.length > 0) {
 468      msgs = _swQueue;
 469      _swQueue = [];
 470    } else if (_swSent.length > 0) {
 471      msgs = _swSent;
 472    } else {
 473      return;
 474    }
 475    for (var i = 0; i < msgs.length; i++) {
 476      navigator.serviceWorker.controller.postMessage(msgs[i]);
 477    }
 478  }
 479  
 480  if (_hasSW) {
 481    navigator.serviceWorker.addEventListener('controllerchange', _sendToSW);
 482    navigator.serviceWorker.ready.then(function(reg) {
 483      if (!navigator.serviceWorker.controller && reg.active) {
 484        reg.active.postMessage('CLAIM');
 485      }
 486      setTimeout(_sendToSW, 100);
 487    });
 488  }
 489  
 490  export function OnSWMessage(fn) {
 491    _swMessageHandler = fn;
 492    if (_hasSW) {
 493      navigator.serviceWorker.addEventListener('message', function(e) {
 494        var d = e.data;
 495        if (typeof d !== 'string') {
 496          // Backward compat: old SW may send Slice objects via structured clone.
 497          if (d && d.$array && typeof d.$length === 'number') {
 498            var buf = new Uint8Array(d.$length);
 499            var off = d.$offset || 0;
 500            for (var i = 0; i < d.$length; i++) buf[i] = d.$array[off + i];
 501            d = new TextDecoder().decode(buf);
 502          } else {
 503            return; // Not a string and not a Slice — skip.
 504          }
 505        }
 506        if (_swMessageHandler) _swMessageHandler(d);
 507      });
 508    }
 509  }
 510  
 511  export function ReadClipboard(fn) {
 512    if (navigator.clipboard && navigator.clipboard.readText) {
 513      navigator.clipboard.readText().then(function(t) { fn(t || ''); }).catch(function() {
 514        var r = prompt('Paste nsec:');
 515        fn(r || '');
 516      });
 517    } else {
 518      var r = prompt('Paste nsec:');
 519      fn(r || '');
 520    }
 521  }
 522  
 523  // IndexedDB (simple key-value).
 524  let _idb = null;
 525  const _idbName = 'smesh-kv';
 526  const _idbStores = ['profiles', 'events', 'cache'];
 527  
 528  function _openIDB() {
 529    return new Promise(function(resolve, reject) {
 530      if (_idb) { resolve(_idb); return; }
 531      const req = indexedDB.open(_idbName, 1);
 532      req.onupgradeneeded = function(e) {
 533        const db = e.target.result;
 534        for (const s of _idbStores) {
 535          if (!db.objectStoreNames.contains(s)) db.createObjectStore(s);
 536        }
 537      };
 538      req.onsuccess = function(e) { _idb = e.target.result; resolve(_idb); };
 539      req.onerror = function() { reject(req.error); };
 540    });
 541  }
 542  
 543  export function IDBGet(store, key, fn) {
 544    _openIDB().then(function(db) {
 545      const tx = db.transaction(store, 'readonly');
 546      const req = tx.objectStore(store).get(key);
 547      req.onsuccess = function() { fn(req.result ?? ''); };
 548      req.onerror = function() { fn(''); };
 549    }).catch(function() { fn(''); });
 550  }
 551  
 552  export function IDBPut(store, key, value) {
 553    _openIDB().then(function(db) {
 554      const tx = db.transaction(store, 'readwrite');
 555      tx.objectStore(store).put(value, key);
 556    });
 557  }
 558  
 559  export function IDBGetAll(store, fn, done) {
 560    _openIDB().then(function(db) {
 561      const tx = db.transaction(store, 'readonly');
 562      const req = tx.objectStore(store).openCursor();
 563      req.onsuccess = function(e) {
 564        const cursor = e.target.result;
 565        if (cursor) {
 566          fn(String(cursor.key), String(cursor.value));
 567          cursor.continue();
 568        } else {
 569          if (done) done();
 570        }
 571      };
 572      req.onerror = function() { if (done) done(); };
 573    }).catch(function() { if (done) done(); });
 574  }
 575  
 576  // Fetch relay NIP-11 info.
 577  export function FetchRelayInfo(url, fn) {
 578    fetch(url, { headers: { 'Accept': 'application/nostr+json' } })
 579      .then(function(r) { return r.text(); })
 580      .then(function(t) { if (fn) fn(t); })
 581      .catch(function() { if (fn) fn(''); });
 582  }
 583  
 584  // History / location.
 585  export function PushState(path) {
 586    if (typeof history !== 'undefined') history.pushState(null, '', path);
 587  }
 588  
 589  export function ReplaceState(path) {
 590    if (typeof history !== 'undefined') history.replaceState(null, '', path);
 591  }
 592  
 593  export function Back() {
 594    if (typeof history !== 'undefined') history.back();
 595  }
 596  
 597  export function LocationReload() {
 598    if (typeof location !== 'undefined') location.reload();
 599  }
 600  
 601  export function HardRefresh() {
 602    var p = [];
 603    if (typeof caches !== 'undefined') {
 604      p.push(caches.keys().then(function(keys) {
 605        return Promise.all(keys.map(function(k) { return caches.delete(k); }));
 606      }));
 607    }
 608    if (typeof navigator !== 'undefined' && navigator.serviceWorker) {
 609      p.push(navigator.serviceWorker.getRegistrations().then(function(regs) {
 610        regs.forEach(function(r) { r.unregister(); });
 611      }));
 612    }
 613    Promise.all(p).then(function() { location.reload(); });
 614  }
 615  
 616  export function ClearStoragePrefix(prefix) {
 617    if (typeof localStorage === 'undefined') return;
 618    var keys = [];
 619    for (var i = 0; i < localStorage.length; i++) {
 620      var k = localStorage.key(i);
 621      if (k && k.indexOf(prefix) === 0) keys.push(k);
 622    }
 623    for (var j = 0; j < keys.length; j++) localStorage.removeItem(keys[j]);
 624  }
 625  
 626  export function TimezoneOffsetSeconds() {
 627    // getTimezoneOffset() returns minutes, positive west of UTC.
 628    // We return seconds to ADD to UTC to get local time.
 629    return -(new Date().getTimezoneOffset()) * 60;
 630  }
 631  
 632  export function GetPath() {
 633    if (typeof location !== 'undefined') return location.pathname;
 634    return '/';
 635  }
 636  
 637  export function Hostname() {
 638    if (typeof location !== 'undefined') return location.hostname;
 639    return '';
 640  }
 641  
 642  export function Port() {
 643    if (typeof location !== 'undefined') return location.port;
 644    return '';
 645  }
 646  
 647  export function UserAgent() {
 648    if (typeof navigator !== 'undefined') return navigator.userAgent;
 649    return '';
 650  }
 651  
 652  export function OnPopState(fn) {
 653    if (typeof window !== 'undefined') {
 654      window.addEventListener('popstate', function() {
 655        if (fn) fn(location.pathname);
 656      });
 657    }
 658  }
 659  
 660  // DownloadText triggers a file download with the given text content.
 661  export function DownloadText(filename, content, mimeType) {
 662    const blob = new Blob([content], { type: mimeType || 'text/plain' });
 663    const url = URL.createObjectURL(blob);
 664    const a = document.createElement('a');
 665    a.href = url;
 666    a.download = filename;
 667    document.body.appendChild(a);
 668    a.click();
 669    setTimeout(function() { a.remove(); URL.revokeObjectURL(url); }, 1000);
 670  }
 671  
 672  // PickFileText opens a file picker and reads the selected file as text.
 673  export function PickFileText(accept, fn) {
 674    const input = document.createElement('input');
 675    input.type = 'file';
 676    if (accept) input.accept = accept;
 677    input.style.display = 'none';
 678    input.addEventListener('change', function() {
 679      if (!input.files || !input.files[0]) { fn(''); return; }
 680      const reader = new FileReader();
 681      reader.onload = function() { fn(reader.result || ''); };
 682      reader.onerror = function() { fn(''); };
 683      reader.readAsText(input.files[0]);
 684    });
 685    document.body.appendChild(input);
 686    input.click();
 687    // Clean up after a delay (even if user cancels).
 688    setTimeout(function() { input.remove(); }, 60000);
 689  }
 690  
 691  // NowSeconds returns current Unix time in seconds.
 692  export function NowSeconds() {
 693    return BigInt(Math.floor(Date.now() / 1000));
 694  }
 695  
 696  // OnPullRefresh registers a handler that fires when the user pulls to refresh.
 697  // Handles both mouse wheel (accumulated up-scroll at top) and touch gestures.
 698  export function OnPullRefresh(elId, indicatorId, fn) {
 699    const el = _elements.get(elId);
 700    const ind = _elements.get(indicatorId);
 701    if (!el) return;
 702  
 703    var pullAccum = 0;
 704    var pullTimer = 0;
 705    var threshold = 200;
 706    var resetMs = 400;
 707  
 708    el.addEventListener('wheel', function(e) {
 709      if (el.scrollTop > 0 || e.deltaY >= 0) {
 710        pullAccum = 0;
 711        if (ind) ind.style.display = 'none';
 712        return;
 713      }
 714      pullAccum += Math.abs(e.deltaY);
 715      clearTimeout(pullTimer);
 716      pullTimer = setTimeout(function() {
 717        pullAccum = 0;
 718        if (ind) ind.style.display = 'none';
 719      }, resetMs);
 720      if (ind && pullAccum > 40) ind.style.display = 'flex';
 721      if (pullAccum >= threshold) {
 722        pullAccum = 0;
 723        fn();
 724      }
 725    }, { passive: true });
 726  
 727    var touchStartY = 0;
 728    el.addEventListener('touchstart', function(e) {
 729      if (el.scrollTop === 0 && e.touches.length === 1) {
 730        touchStartY = e.touches[0].clientY;
 731      }
 732    }, { passive: true });
 733    el.addEventListener('touchmove', function(e) {
 734      if (el.scrollTop > 0 || touchStartY === 0) return;
 735      var dy = e.touches[0].clientY - touchStartY;
 736      if (dy > 20 && ind) ind.style.display = 'flex';
 737      if (dy > 80) {
 738        touchStartY = 0;
 739        fn();
 740      }
 741    }, { passive: true });
 742    el.addEventListener('touchend', function() {
 743      touchStartY = 0;
 744      if (pullAccum < threshold && ind) ind.style.display = 'none';
 745    }, { passive: true });
 746  }
 747  
 748  // Get raw element (for advanced use within JS runtime only).
 749  export function getRawElement(id) {
 750    return _elements.get(id);
 751  }
 752