Skip to content
← Public packages

@kody/mailroom

Offline-first PWA for your Kody mailbox: sent and received threads, copy-prompt for agents, no send.

src/ui/client-script.ts

709 lines · 26.3 KB · TypeScript
import { parseDeepLink } from '../deep-link.ts'
import {
	buildMessagePrompt,
	buildThreadPrompt,
	textFromMessage,
} from '../prompt-text.ts'
import { filterThreads, messageWhen, snippetFrom, threadKey } from '../threads.ts'
import {
	INBOX_POLL_INTERVAL_MS,
	INBOX_REFRESH_MIN_INTERVAL_MS,
	inboxHasNewMail,
	shouldRefreshInbox,
} from './inbox-refresh.ts'
import {
	UPDATE_CHECK_MIN_INTERVAL_MS,
	describeAvailableUpdate,
	shouldRunUpdateCheck,
} from './update-check.ts'

export const LOCAL_THREADS_KEY = 'mailroom-threads'

export const clientScript = `
(function () {
  const base = document.documentElement.dataset.base || '/';
  const dbName = 'mailroom';
  const storeName = 'cache';
  const LOCAL_THREADS_KEY = ${JSON.stringify(LOCAL_THREADS_KEY)};
  const UPDATE_CHECK_MIN_INTERVAL_MS = ${JSON.stringify(UPDATE_CHECK_MIN_INTERVAL_MS)};
  const INBOX_REFRESH_MIN_INTERVAL_MS = ${JSON.stringify(INBOX_REFRESH_MIN_INTERVAL_MS)};
  const INBOX_POLL_INTERVAL_MS = ${JSON.stringify(INBOX_POLL_INTERVAL_MS)};
  const shouldRunUpdateCheck = ${shouldRunUpdateCheck.toString()};
  const shouldRefreshInbox = ${shouldRefreshInbox.toString()};
  const inboxHasNewMail = ${inboxHasNewMail.toString()};
  const describeAvailableUpdate = ${describeAvailableUpdate.toString()};
  const parseDeepLink = ${parseDeepLink.toString()};
  const filterThreads = ${filterThreads.toString()};
  const messageWhen = ${messageWhen.toString()};
  const threadKey = ${threadKey.toString()};
  const snippetFrom = ${snippetFrom.toString()};
  const textFromMessage = ${textFromMessage.toString()};
  const buildMessagePrompt = ${buildMessagePrompt.toString()};
  const buildThreadPrompt = ${buildThreadPrompt.toString()};
  let deferredInstall = null;
  const state = {
    direction: 'inbound',
    threads: [],
    selectedId: null,
    query: '',
    aboutOpen: false,
    total: 0,
    latestAt: null,
    inboxes: [],
  };
  const updateCheck = { inFlight: false, lastStartedAt: null };
  const inboxCheck = { inFlight: false, lastStartedAt: null };
  let inboxPollTimer = null;

  const els = {
    statusText: document.querySelector('[data-status-text]'),
    actions: document.querySelector('[data-actions]'),
    mailbox: document.querySelector('[data-mailbox]'),
    list: document.querySelector('[data-list]'),
    search: document.querySelector('[data-search]'),
    sheet: document.querySelector('[data-sheet]'),
    about: document.querySelector('[data-about]'),
    aboutSheet: document.querySelector('[data-about-sheet]'),
    toast: document.querySelector('[data-toast]'),
    toastTitle: document.querySelector('[data-toast-title]'),
    toastText: document.querySelector('[data-toast-text]'),
    toastAction: document.querySelector('[data-toast-action]'),
    toastDismiss: document.querySelector('[data-toast-dismiss]'),
    installWrap: document.querySelector('[data-install-wrap]'),
    installOpen: document.querySelector('[data-install-open]'),
    install: document.querySelector('[data-install]'),
    installPopover: document.querySelector('[data-install-popover]'),
    installSteps: document.querySelector('[data-install-steps]'),
    installHint: document.querySelector('[data-install-hint]'),
    installHintDismiss: document.querySelector('[data-install-hint-dismiss]'),
  };

  window.addEventListener('beforeinstallprompt', (event) => {
    event.preventDefault();
    deferredInstall = event;
    syncInstallUi();
  });
  window.addEventListener('appinstalled', () => {
    deferredInstall = null;
    syncInstallUi();
  });

  if (shouldCanonicalizeClientPath(location.pathname)) {
    location.replace(location.pathname + '/' + location.search + location.hash);
    return;
  }

  function shouldCanonicalizeClientPath(pathname) {
    return Boolean(pathname) && !pathname.endsWith('/');
  }

  function serviceWorkerScope(basePath, origin) {
    const root = new URL(basePath || '/', origin);
    if (!root.pathname.endsWith('/')) root.pathname += '/';
    return root.href;
  }

  function api(path) {
    const urlRoot = new URL(base || '/', location.origin);
    if (!urlRoot.pathname.endsWith('/')) urlRoot.pathname += '/';
    return new URL(String(path).replace(/^\\//, ''), urlRoot).toString();
  }

  function withTimeout(promise, ms, label) {
    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => reject(new Error(label || 'timeout')), ms);
      promise.then(
        (value) => { clearTimeout(timer); resolve(value); },
        (error) => { clearTimeout(timer); reject(error); },
      );
    });
  }

  function openDb() {
    return withTimeout(new Promise((resolve, reject) => {
      const req = indexedDB.open(dbName, 1);
      req.onupgradeneeded = () => req.result.createObjectStore(storeName);
      req.onsuccess = () => resolve(req.result);
      req.onerror = () => reject(req.error);
    }), 1500, 'cache open timeout');
  }

  async function cacheGet(key) {
    try {
      const db = await openDb();
      return await withTimeout(new Promise((resolve, reject) => {
        const tx = db.transaction(storeName, 'readonly');
        const req = tx.objectStore(storeName).get(key);
        req.onsuccess = () => resolve(req.result || null);
        req.onerror = () => reject(req.error);
      }), 1500, 'cache read timeout');
    } catch {
      return null;
    }
  }

  async function cacheSet(key, value) {
    try {
      if (key.startsWith('threads:')) {
        localStorage.setItem(LOCAL_THREADS_KEY + ':' + key.slice(8), JSON.stringify(value));
      }
    } catch {
      try { localStorage.removeItem(LOCAL_THREADS_KEY + ':' + key.slice(8)); } catch {}
    }
    try {
      const db = await openDb();
      await withTimeout(new Promise((resolve, reject) => {
        const tx = db.transaction(storeName, 'readwrite');
        tx.objectStore(storeName).put(value, key);
        tx.oncomplete = () => resolve();
        tx.onerror = () => reject(tx.error);
      }), 1500, 'cache write timeout');
    } catch {}
  }

  function readLocalThreads(direction) {
    try {
      const raw = localStorage.getItem(LOCAL_THREADS_KEY + ':' + direction);
      if (!raw) return null;
      const parsed = JSON.parse(raw);
      if (!parsed || !Array.isArray(parsed.threads)) return null;
      return parsed;
    } catch {
      return null;
    }
  }

  function setStatus(text) {
    if (els.statusText) els.statusText.textContent = text;
  }

  function setUpdating(updating) {
    els.actions?.classList.toggle('is-updating', updating);
    els.list?.setAttribute('aria-busy', updating ? 'true' : 'false');
  }

  function countLabel(total, direction) {
    const noun = total === 1 ? 'thread' : 'threads';
    const box = direction === 'outbound' ? 'sent' : 'received';
    return total + ' ' + box + ' ' + noun;
  }

  function syncScrollLock() {
    const open = Boolean(
      (els.sheet && !els.sheet.hidden) || (els.aboutSheet && !els.aboutSheet.hidden),
    );
    document.documentElement.classList.toggle('is-modal-open', open);
  }

  function closeAbout() {
    state.aboutOpen = false;
    if (els.aboutSheet) els.aboutSheet.hidden = true;
    syncScrollLock();
  }

  function closeSheet() {
    if (els.sheet) els.sheet.hidden = true;
    state.selectedId = null;
    renderList();
    syncScrollLock();
  }

  function escapeHtml(value) {
    return String(value ?? '')
      .replaceAll('&', '&')
      .replaceAll('<', '&lt;')
      .replaceAll('>', '&gt;')
      .replaceAll('"', '&quot;');
  }

  function escapeAttr(value) {
    return String(value ?? '').replaceAll('&', '&amp;').replaceAll('"', '&quot;');
  }

  function formatWhen(iso) {
    if (!iso) return '';
    const date = new Date(iso);
    if (Number.isNaN(date.getTime())) return iso;
    return date.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
  }

  function visibleThreads() {
    return filterThreads(state.threads, state.query);
  }

  function renderMailbox() {
    els.mailbox?.querySelectorAll('[data-direction]').forEach((button) => {
      button.setAttribute('aria-current', button.getAttribute('data-direction') === state.direction ? 'true' : 'false');
    });
  }

  function renderList() {
    const threads = visibleThreads();
    if (!els.list) return;
    if (threads.length === 0) {
      els.list.innerHTML = '<p class="empty">No ' + (state.direction === 'outbound' ? 'sent' : 'received') + ' threads on this device yet.</p>';
      return;
    }
    els.list.innerHTML = threads.map((thread) => {
      const people = (thread.participants || []).slice(0, 3).join(', ');
      return \`<button class="card" type="button" data-id="\${escapeHtml(thread.id)}" aria-current="\${thread.id === state.selectedId ? 'true' : 'false'}">
        <div class="card-meta"><span>\${escapeHtml(formatWhen(thread.latestAt))}</span><span>\${escapeHtml(String(thread.messageCount || 1))} msg</span></div>
        <h2>\${escapeHtml(thread.subject || '(no subject)')}</h2>
        <p>\${escapeHtml(people)}\${thread.snippet ? ' · ' + escapeHtml(thread.snippet) : ''}</p>
      </button>\`;
    }).join('');
  }

  async function copyText(text, button) {
    try {
      await navigator.clipboard.writeText(text);
    } catch {
      const area = document.createElement('textarea');
      area.value = text;
      document.body.appendChild(area);
      area.select();
      document.execCommand('copy');
      area.remove();
    }
    if (button) {
      const prior = button.textContent;
      button.textContent = 'Copied';
      setTimeout(() => { button.textContent = prior; }, 1200);
    }
  }

  function renderSheet(thread) {
    if (!els.sheet) return;
    els.sheet.hidden = false;
    const messages = thread.messages || [];
    const blocks = messages.map((message) => {
      const body = textFromMessage(message) || '';
      const html = message.html_body
        ? \`<div class="preview"><iframe title="Email preview" sandbox srcdoc="\${escapeAttr(message.html_body)}"></iframe></div>\`
        : '';
      return \`<article class="message" data-message-id="\${escapeHtml(message.id)}">
        <h3>\${escapeHtml(message.direction === 'outbound' ? 'Sent' : 'Received')} · \${escapeHtml(formatWhen(messageWhen(message)))}</h3>
        <p class="meta">\${escapeHtml(message.from_address || '')} → \${escapeHtml((message.to_addresses || []).join(', '))}</p>
        <pre class="message-body">\${escapeHtml(body)}</pre>
        \${html}
        <button class="copy" type="button" data-copy-message="\${escapeHtml(message.id)}">Copy prompt</button>
      </article>\`;
    }).join('');
    els.sheet.innerHTML = \`
      <div class="sheet-card">
        <div class="sheet-toolbar">
          <button class="ghost" type="button" data-close>Close</button>
          <button class="copy" type="button" data-copy-thread>Copy thread prompt</button>
        </div>
        <h2>\${escapeHtml(thread.subject || '(no subject)')}</h2>
        <p class="meta">\${escapeHtml(String(messages.length))} messages · read only</p>
        \${blocks || '<p class="empty">No messages in this thread.</p>'}
      </div>\`;
    syncScrollLock();
  }

  async function openThread(id) {
    if (!id) return;
    state.selectedId = id;
    renderList();
    const cached = await cacheGet('thread:' + id);
    if (cached?.messages) renderSheet(cached);
    try {
      const response = await fetch(api('api/threads/' + encodeURIComponent(id)), { cache: 'no-store' });
      if (!response.ok) throw new Error('thread ' + response.status);
      const thread = await response.json();
      await cacheSet('thread:' + id, thread);
      renderSheet(thread);
    } catch {
      if (!cached) renderSheet({ subject: 'Unavailable offline', messages: [] });
    }
  }

  async function promptFor(kind, id) {
    const cached = kind === 'thread' ? await cacheGet('thread:' + id) : null;
    if (kind === 'thread' && cached?.messages) {
      return buildThreadPrompt({ id, subject: cached.subject, messages: cached.messages });
    }
    if (kind === 'message' && cached?.messages) {
      const message = cached.messages.find((item) => item.id === id);
      if (message) return buildMessagePrompt(message);
    }
    const params = kind === 'thread' ? ('thread=' + encodeURIComponent(id)) : ('message=' + encodeURIComponent(id));
    const response = await fetch(api('api/prompt?' + params), { cache: 'no-store' });
    if (!response.ok) throw new Error('prompt ' + response.status);
    const payload = await response.json();
    return payload.prompt || '';
  }

  async function loadVersion() {
    try {
      const response = await fetch(api('api/version'), { cache: 'no-store' });
      if (!response.ok) throw new Error('version ' + response.status);
      return await response.json();
    } catch {
      const sha = document.documentElement.dataset.runningSha || '';
      return { sha, shortSha: sha.slice(0, 7), message: '', committedAt: '', publishedAt: '' };
    }
  }

  function inboxLine() {
    const first = (state.inboxes || [])[0];
    const address = first?.addresses?.[0]?.address;
    return address ? 'Mail for ' + address : 'Your Kody mailbox';
  }

  function renderAbout(version, checking) {
    if (!els.aboutSheet) return;
    const runningSha = document.documentElement.dataset.runningSha || version.sha || '';
    const latestSha = version.sha || runningSha;
    state.aboutOpen = true;
    els.aboutSheet.hidden = false;
    els.aboutSheet.innerHTML = \`
      <div class="sheet-card">
        <button class="ghost" type="button" data-close>Close</button>
        <h2>About Mailroom</h2>
        <p class="meta">Read-only client for this account's Kody mailbox. Fork the package so the hosted app is yours. Agents send email; this app does not.</p>
        <dl class="about-dl">
          <div><dt>Mailbox</dt><dd>\${escapeHtml(inboxLine())}</dd></div>
          <div><dt>Running version</dt><dd>\${escapeHtml(runningSha ? runningSha.slice(0, 7) : 'Not recorded yet')}</dd></div>
          <div><dt>Latest published</dt><dd>\${escapeHtml(latestSha ? latestSha.slice(0, 7) : 'Not recorded yet')}</dd></div>
          <div><dt>Commit message</dt><dd>\${escapeHtml(version.message || 'Unknown')}</dd></div>
          <div><dt>Committed</dt><dd>\${escapeHtml(version.committedAt ? formatWhen(version.committedAt) : 'Unknown')}</dd></div>
          <div><dt>Published</dt><dd>\${escapeHtml(version.publishedAt ? formatWhen(version.publishedAt) : 'Unknown')}</dd></div>
          <div><dt>Cache</dt><dd>IndexedDB holds the last threads and messages from the server.</dd></div>
        </dl>
        <div class="about-actions">
          <button class="install" type="button" data-check-updates \${checking ? 'disabled' : ''}>\${checking ? 'Checking…' : 'Check for updates'}</button>
        </div>
      </div>\`;
    els.aboutSheet.querySelector('[data-close]')?.addEventListener('click', closeAbout);
    els.aboutSheet.querySelector('[data-check-updates]')?.addEventListener('click', () => checkForUpdates(true));
    syncScrollLock();
  }

  function showToast(title, text, actionLabel, action) {
    if (!els.toast) return;
    els.toast.hidden = false;
    if (els.toastTitle) els.toastTitle.textContent = title;
    if (els.toastText) els.toastText.textContent = text;
    if (els.toastAction) {
      els.toastAction.textContent = actionLabel;
      els.toastAction.onclick = action;
    }
  }

  function applyUpdate() {
    const worker = state.waitingWorker;
    if (worker) worker.postMessage('SKIP_WAITING');
    else location.reload();
  }

  async function checkForUpdates(fromAbout) {
    if (!fromAbout && !shouldRunUpdateCheck({
      visible: document.visibilityState === 'visible',
      inFlight: updateCheck.inFlight,
      lastStartedAt: updateCheck.lastStartedAt,
      now: Date.now(),
      minIntervalMs: UPDATE_CHECK_MIN_INTERVAL_MS,
    })) return;
    updateCheck.inFlight = true;
    updateCheck.lastStartedAt = Date.now();
    try {
      if (fromAbout) renderAbout(await loadVersion(), true);
      const registration = await ensureServiceWorker();
      const [version] = await Promise.all([
        loadVersion(),
        registration?.update?.() || Promise.resolve(),
      ]);
      const waiting = state.waitingWorker || registration?.waiting || null;
      if (waiting) state.waitingWorker = waiting;
      if (fromAbout) renderAbout(version, false);
      const available = describeAvailableUpdate({
        runningSha: document.documentElement.dataset.runningSha || '',
        latestSha: version.sha || '',
        hasWaitingWorker: Boolean(waiting),
      });
      if (available === 'worker') {
        showToast('Update available', 'A newer Mailroom app is ready. Reload to download it.', 'Reload', applyUpdate);
        return;
      }
      if (available === 'published') {
        showToast(
          'Update available',
          'Latest published commit is ' + (version.shortSha || version.sha.slice(0, 7)) + '. Reload to download it.',
          'Reload',
          () => location.reload(),
        );
      }
    } finally {
      updateCheck.inFlight = false;
    }
  }

  function onAppFocused() {
    checkForUpdates(false);
    refreshInbox({ silent: true });
  }

  function startInboxPoll() {
    if (inboxPollTimer) return;
    inboxPollTimer = setInterval(() => {
      if (document.visibilityState !== 'visible') return;
      pollInbox();
    }, INBOX_POLL_INTERVAL_MS);
  }

  async function pollInbox() {
    if (!shouldRefreshInbox({
      visible: document.visibilityState === 'visible',
      inFlight: inboxCheck.inFlight,
      lastStartedAt: inboxCheck.lastStartedAt,
      now: Date.now(),
      minIntervalMs: INBOX_REFRESH_MIN_INTERVAL_MS,
    })) return;
    inboxCheck.inFlight = true;
    inboxCheck.lastStartedAt = Date.now();
    try {
      const response = await fetch(api('api/status'), { cache: 'no-store' });
      if (!response.ok) return;
      const status = await response.json();
      const side = status[state.direction] || {};
      if (inboxHasNewMail({
        previousTotal: state.total,
        nextTotal: Number(side.total ?? 0),
        previousLatestSentAt: state.latestAt,
        nextLatestSentAt: side.latestAt || null,
      })) {
        await refresh({ silent: true, announce: true });
      }
    } catch {
    } finally {
      inboxCheck.inFlight = false;
    }
  }

  async function refreshInbox(options) {
    if (!shouldRefreshInbox({
      visible: document.visibilityState === 'visible',
      inFlight: inboxCheck.inFlight,
      lastStartedAt: inboxCheck.lastStartedAt,
      now: Date.now(),
      minIntervalMs: options?.force ? 0 : INBOX_REFRESH_MIN_INTERVAL_MS,
    }) && !options?.force) return;
    await refresh(options);
  }

  function openDeepLinked() {
    const found = parseDeepLink({ search: location.search, hash: location.hash });
    if (found.threadId) openThread(found.threadId);
    else if (found.messageId) openThread(found.messageId);
  }

  function isStandaloneDisplay() {
    return window.matchMedia('(display-mode: standalone)').matches || navigator.standalone === true;
  }

  function isIosBrowser() {
    if (/iphone|ipad|ipod/i.test(navigator.userAgent)) return true;
    return navigator.platform === 'MacIntel' && (navigator.maxTouchPoints || 0) > 1;
  }

  function installStepsHtml() {
    if (isIosBrowser()) {
      return '<ol><li>Tap <strong>Share</strong></li><li>Tap <strong>Add to Home Screen</strong></li><li>Tap <strong>Add</strong></li></ol>';
    }
    return '<p>Use the install icon in the address bar, or the browser menu, then choose <strong>Install Mailroom</strong>.</p>';
  }

  function syncInstallUi() {
    const ios = isIosBrowser();
    const standalone = isStandaloneDisplay();
    if (els.installWrap) els.installWrap.hidden = standalone;
    if (els.install) els.install.hidden = standalone || !deferredInstall;
    if (els.installSteps) els.installSteps.innerHTML = installStepsHtml();
    if (els.installHint) {
      let dismissed = false;
      try { dismissed = localStorage.getItem('mailroom-install-hint-dismissed') !== null; } catch {}
      els.installHint.hidden = standalone || !ios || dismissed;
    }
  }

  async function ensureServiceWorker() {
    if (!('serviceWorker' in navigator)) return null;
    if (state.registration) return state.registration;
    try {
      state.registration = await navigator.serviceWorker.register(api('sw.js'), {
        scope: serviceWorkerScope(base || '/', location.origin),
        updateViaCache: 'none',
      });
      const track = (worker) => {
        if (!worker) return;
        worker.addEventListener('statechange', () => {
          if (worker.state === 'installed' && navigator.serviceWorker.controller) {
            state.waitingWorker = worker;
            showToast('Update available', 'A newer Mailroom app is ready.', 'Reload', applyUpdate);
          }
        });
      };
      track(state.registration.installing);
      state.registration.addEventListener('updatefound', () => track(state.registration.installing));
      navigator.serviceWorker.addEventListener('controllerchange', () => {
        if (state.waitingWorker) location.reload();
      });
      return state.registration;
    } catch {
      return null;
    }
  }

  async function promptInstall() {
    const promptEvent = deferredInstall;
    if (promptEvent?.prompt) {
      deferredInstall = null;
      try {
        await promptEvent.prompt();
        await promptEvent.userChoice;
        syncInstallUi();
        return true;
      } catch {
        syncInstallUi();
        return false;
      }
    }
    if (typeof navigator.install === 'function') {
      try {
        await navigator.install();
        syncInstallUi();
        return true;
      } catch {
        syncInstallUi();
        return false;
      }
    }
    return false;
  }

  async function refresh(options) {
    const silent = Boolean(options?.silent);
    const announce = Boolean(options?.announce);
    const previousTotal = state.total;
    const previousLatest = state.latestAt;
    const cacheKey = 'threads:' + state.direction;
    const cached = (await cacheGet(cacheKey)) || readLocalThreads(state.direction);
    if (cached?.threads) {
      state.threads = cached.threads;
      state.total = cached.total ?? cached.threads.length;
      state.latestAt = cached.threads[0]?.latestAt || null;
      state.inboxes = cached.inboxes || state.inboxes;
      renderMailbox();
      renderList();
      setStatus(countLabel(state.total, state.direction));
    }
    if (!silent) setUpdating(true);
    inboxCheck.inFlight = true;
    inboxCheck.lastStartedAt = Date.now();
    try {
      const response = await fetch(api('api/threads?direction=' + encodeURIComponent(state.direction)), { cache: 'no-store' });
      if (!response.ok) throw new Error('list ' + response.status);
      const payload = await response.json();
      state.threads = payload.threads || [];
      state.total = payload.total ?? state.threads.length;
      state.latestAt = state.threads[0]?.latestAt || null;
      state.inboxes = payload.inboxes || state.inboxes;
      await cacheSet(cacheKey, payload);
      renderMailbox();
      renderList();
      setStatus(countLabel(state.total, state.direction));
      if (announce && inboxHasNewMail({
        previousTotal,
        nextTotal: state.total,
        previousLatestSentAt: previousLatest,
        nextLatestSentAt: state.latestAt,
      })) {
        const newest = state.threads[0];
        showToast(
          state.direction === 'outbound' ? 'New sent thread' : 'New received thread',
          newest?.subject || 'New mail is on the server.',
          newest ? 'Open' : 'Dismiss',
          newest ? () => openThread(newest.id) : () => { if (els.toast) els.toast.hidden = true; },
        );
      }
    } catch {
      if (state.threads.length) setStatus(countLabel(state.threads.length, state.direction) + ' · offline');
      else setStatus('Offline · no cached mail yet');
    } finally {
      if (!silent) setUpdating(false);
      inboxCheck.inFlight = false;
    }
  }

  els.mailbox?.addEventListener('click', (event) => {
    const button = event.target.closest('[data-direction]');
    if (!button) return;
    const next = button.getAttribute('data-direction');
    if (next !== 'inbound' && next !== 'outbound') return;
    if (next === state.direction) return;
    state.direction = next;
    state.selectedId = null;
    renderMailbox();
    refresh({ force: true });
  });
  els.list?.addEventListener('click', (event) => {
    const button = event.target.closest('[data-id]');
    if (button) openThread(button.getAttribute('data-id'));
  });
  els.search?.addEventListener('input', () => {
    state.query = els.search.value;
    renderList();
  });
  els.sheet?.addEventListener('click', async (event) => {
    if (event.target === els.sheet || event.target.closest('[data-close]')) {
      closeSheet();
      return;
    }
    const threadBtn = event.target.closest('[data-copy-thread]');
    if (threadBtn && state.selectedId) {
      try { await copyText(await promptFor('thread', state.selectedId), threadBtn); } catch {}
      return;
    }
    const msgBtn = event.target.closest('[data-copy-message]');
    if (msgBtn) {
      try { await copyText(await promptFor('message', msgBtn.getAttribute('data-copy-message')), msgBtn); } catch {}
    }
  });
  els.aboutSheet?.addEventListener('click', (event) => {
    if (event.target === els.aboutSheet || event.target.closest('[data-close]')) closeAbout();
  });
  els.about?.addEventListener('click', async () => {
    renderAbout(await loadVersion(), false);
  });
  els.toastDismiss?.addEventListener('click', () => { if (els.toast) els.toast.hidden = true; });
  els.installOpen?.addEventListener('click', (event) => {
    if (!els.installPopover) return;
    if (typeof els.installPopover.showPopover === 'function') return;
    event.preventDefault();
    els.installPopover.classList.toggle('is-open');
  });
  els.install?.addEventListener('click', promptInstall);
  els.installHintDismiss?.addEventListener('click', () => {
    try { localStorage.setItem('mailroom-install-hint-dismissed', String(Date.now())); } catch {}
    syncInstallUi();
  });
  document.addEventListener('keydown', (event) => {
    if (event.key !== 'Escape') return;
    if (els.aboutSheet && !els.aboutSheet.hidden) closeAbout();
    else if (els.sheet && !els.sheet.hidden) closeSheet();
  });
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'visible') onAppFocused();
  });
  window.addEventListener('pageshow', onAppFocused);
  window.addEventListener('focus', onAppFocused);
  window.addEventListener('hashchange', openDeepLinked);

  syncInstallUi();
  ensureServiceWorker().then(() => onAppFocused());
  startInboxPoll();
  refresh({ force: true }).then(openDeepLinked);
})();
`