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;
   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 -1;
  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 -1;
  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 NextSibling(elId) {
  78    const el = _elements.get(elId);
  79    if (el && el.nextSibling) {
  80      const id = _nextId++;
  81      _elements.set(id, el.nextSibling);
  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  // Properties and attributes.
  95  export function SetAttribute(elId, name, value) {
  96    const el = _elements.get(elId);
  97    if (el) el.setAttribute(name, value);
  98  }
  99  
 100  export function SetTextContent(elId, text) {
 101    const el = _elements.get(elId);
 102    if (el) el.textContent = text;
 103  }
 104  
 105  export function SetInnerHTML(elId, html) {
 106    const el = _elements.get(elId);
 107    if (el) el.innerHTML = html;
 108  }
 109  
 110  export function SetStyle(elId, prop, value) {
 111    const el = _elements.get(elId);
 112    if (el) el.style[prop] = value;
 113  }
 114  
 115  export function SetProperty(elId, prop, value) {
 116    const el = _elements.get(elId);
 117    if (el) el[prop] = value;
 118  }
 119  
 120  export function GetProperty(elId, prop) {
 121    const el = _elements.get(elId);
 122    if (el) return String(el[prop] ?? '');
 123    return '';
 124  }
 125  
 126  export function AddClass(elId, cls) {
 127    const el = _elements.get(elId);
 128    if (el && el.classList) el.classList.add(cls);
 129  }
 130  
 131  export function RemoveClass(elId, cls) {
 132    const el = _elements.get(elId);
 133    if (el && el.classList) el.classList.remove(cls);
 134  }
 135  
 136  // Events.
 137  export function AddEventListener(elId, event, callbackId) {
 138    const el = _elements.get(elId);
 139    const cb = _callbacks.get(callbackId);
 140    if (el && cb) el.addEventListener(event, cb);
 141  }
 142  
 143  export function RemoveEventListener(elId, event, callbackId) {
 144    const el = _elements.get(elId);
 145    const cb = _callbacks.get(callbackId);
 146    if (el && cb) el.removeEventListener(event, cb);
 147  }
 148  
 149  // Register a Go function as a JS callback. Returns callback ID.
 150  export function RegisterCallback(fn) {
 151    const id = _nextCb++;
 152    _callbacks.set(id, fn);
 153    return id;
 154  }
 155  
 156  export function ReleaseCallback(id) {
 157    _callbacks.delete(id);
 158  }
 159  
 160  // Scheduling.
 161  export function RequestAnimationFrame(fn) {
 162    if (typeof window !== 'undefined') {
 163      window.requestAnimationFrame(fn);
 164    } else {
 165      setTimeout(fn, 16);
 166    }
 167  }
 168  
 169  export function SetTimeout(fn, ms) {
 170    return setTimeout(fn, ms);
 171  }
 172  
 173  
 174  
 175  export function ClearTimeout(id) {
 176    clearTimeout(id);
 177  }
 178  
 179  // Cleanup: release element handle.
 180  export function ReleaseElement(id) {
 181    _elements.delete(id);
 182  }
 183  
 184  // Fetch a URL as text, call fn with result.
 185  export function FetchText(url, fn) {
 186    fetch(url).then(r => r.text()).then(t => { if (fn) fn(t); });
 187  }
 188  
 189  // Fetch NIP-11 relay info document with Accept header.
 190  export function FetchRelayInfo(url, fn) {
 191    fetch(url, { headers: { 'Accept': 'application/nostr+json' } })
 192      .then(r => r.text())
 193      .then(t => { if (fn) fn(t); })
 194      .catch(() => { if (fn) fn(''); });
 195  }
 196  
 197  // --- IndexedDB ---
 198  
 199  let _db = null;
 200  let _dbReady = [];
 201  
 202  function ensureDB(cb) {
 203    if (_db) { cb(_db); return; }
 204    _dbReady.push(cb);
 205    if (_dbReady.length > 1) return; // already opening
 206    const req = indexedDB.open('sm3sh', 3);
 207    req.onupgradeneeded = (e) => {
 208      const db = e.target.result;
 209      if (!db.objectStoreNames.contains('profiles')) db.createObjectStore('profiles');
 210      if (!db.objectStoreNames.contains('relays')) db.createObjectStore('relays');
 211      if (!db.objectStoreNames.contains('events')) {
 212        const store = db.createObjectStore('events', { keyPath: 'id' });
 213        store.createIndex('pubkey', 'pubkey', { unique: false });
 214        store.createIndex('kind', 'kind', { unique: false });
 215        store.createIndex('pubkey_kind', ['pubkey', 'kind'], { unique: false });
 216        store.createIndex('created_at', 'created_at', { unique: false });
 217      }
 218      if (!db.objectStoreNames.contains('dms')) {
 219        const dms = db.createObjectStore('dms', { keyPath: 'id' });
 220        dms.createIndex('peer', 'peer', { unique: false });
 221        dms.createIndex('peer_ts', ['peer', 'created_at'], { unique: false });
 222      }
 223    };
 224    req.onblocked = () => { console.warn('[sm3sh] IDB upgrade blocked — close other tabs'); };
 225    req.onsuccess = (e) => {
 226      _db = e.target.result;
 227      _db.onversionchange = () => { _db.close(); _db = null; };
 228      for (const fn of _dbReady) fn(_db);
 229      _dbReady = [];
 230    };
 231    req.onerror = () => { _dbReady = []; };
 232  }
 233  
 234  export function IDBGet(store, key, fn) {
 235    ensureDB((db) => {
 236      try {
 237        const tx = db.transaction(store, 'readonly');
 238        const req = tx.objectStore(store).get(key);
 239        req.onsuccess = () => { fn(req.result ?? ''); };
 240        req.onerror = () => { fn(''); };
 241      } catch(e) { fn(''); }
 242    });
 243  }
 244  
 245  export function IDBPut(store, key, value) {
 246    ensureDB((db) => {
 247      try {
 248        const tx = db.transaction(store, 'readwrite');
 249        tx.objectStore(store).put(value, key);
 250      } catch(e) {}
 251    });
 252  }
 253  
 254  export function IDBGetAll(store, fn, done) {
 255    ensureDB((db) => {
 256      try {
 257        const tx = db.transaction(store, 'readonly');
 258        const req = tx.objectStore(store).openCursor();
 259        req.onsuccess = (e) => {
 260          const cursor = e.target.result;
 261          if (cursor) {
 262            fn(String(cursor.key), String(cursor.value ?? ''));
 263            cursor.continue();
 264          } else {
 265            done();
 266          }
 267        };
 268        req.onerror = () => { done(); };
 269      } catch(e) { done(); }
 270    });
 271  }
 272  
 273  // Check if browser prefers dark color scheme.
 274  export function PrefersDark() {
 275    if (typeof window !== 'undefined' && window.matchMedia) {
 276      return window.matchMedia('(prefers-color-scheme: dark)').matches;
 277    }
 278    return false;
 279  }
 280  
 281  // Log a message to the browser console.
 282  export function ConsoleLog(msg) {
 283    console.log('[sm3sh]', msg);
 284  }
 285  
 286  export function Confirm(msg) {
 287    return confirm(msg);
 288  }
 289  
 290  // Send a raw JSON string to the service worker controller.
 291  // Messages sent before the SW is active are queued and flushed on controllerchange.
 292  let _swQueue = null;
 293  export function PostToSW(msg) {
 294    const sw = navigator.serviceWorker;
 295    if (!sw) return;
 296    if (sw.controller) {
 297      sw.controller.postMessage(msg);
 298    } else {
 299      if (!_swQueue) {
 300        _swQueue = [];
 301        sw.addEventListener('controllerchange', () => {
 302          if (sw.controller) {
 303            for (const m of _swQueue) sw.controller.postMessage(m);
 304          }
 305          _swQueue = null;
 306        }, { once: true });
 307      }
 308      _swQueue.push(msg);
 309    }
 310  }
 311  
 312  // Register a handler for non-bus messages from the service worker.
 313  // Bus relay (shell SW → satellite SWs) is handled in index.html inline script
 314  // so it's active before WASM loads.
 315  export function OnSWMessage(fn) {
 316    if (!navigator.serviceWorker) return;
 317    navigator.serviceWorker.addEventListener('message', (event) => {
 318      const d = event.data;
 319      // Bus messages handled by index.html — skip here.
 320      if (typeof d === 'string' && d.length > 0 && d[0] === '{') return;
 321      if (typeof d === 'string') {
 322        fn(d);
 323      } else if (Array.isArray(d) && d.length > 0) {
 324        fn(JSON.stringify(d));
 325      }
 326    });
 327  }
 328  
 329  // --- History API ---
 330  
 331  export function PushState(path) {
 332    history.pushState(null, '', path);
 333  }
 334  
 335  export function ReplaceState(path) {
 336    history.replaceState(null, '', path);
 337  }
 338  
 339  export function LocationReload() {
 340    location.reload();
 341  }
 342  
 343  export function GetPath() {
 344    return location.pathname + location.hash;
 345  }
 346  
 347  export function Hostname() {
 348    return location.hostname;
 349  }
 350  
 351  export function Port() {
 352    return location.port;
 353  }
 354  
 355  export function OnPopState(fn) {
 356    window.addEventListener('popstate', () => {
 357      fn(location.pathname + location.hash);
 358    });
 359  }
 360  
 361