// The DNSimple companion app (docs/connected-services.md § companion apps). Once // you've linked DNSimple (via the Integrations surface), this app "installs": // it appears in New chat and opens as its own thread, where the Interface view // lists your domains and the Chat view reads back as a conversation with it. // // There is NO extra sidecar and no new bot: the app fetches your domains through // the host bridge (ctx.host.connected.requestData), which asks the instance's // EXISTING bot to run the DNSimple call under a one-op, sealed grant and hand the // result straight back. The browser can't call DNSimple itself (no CORS), and the // server only ever relays ciphertext — the token never leaves your trust domain // except sealed to the bot for that single call. // // It's a COMPANION app: implementing chatMessage(e) opts into the Chat | Interface // split (see apps/README.md). Your domain list is folded state, so it persists // across devices and survives reopen via the snapshot, just like any other app. import { defineApp, namespace } from "../apps.js"; import { LOGO_DNSIMPLE } from "../logos.js"; // One declaration of the app's event-type prefix, reused everywhere: N.ev("domains") // → the "dnsimple.domains" wire type. NOTE: the CSS classes intentionally use a // shorter `dns-` prefix (not matching the "dnsimple" name), so they're left as // literals — a single namespace can't produce both "dnsimple." and "dns-". const N = namespace("dnsimple"); // A tiny helper so both the Chat projection and the reducer agree on wording. const summary = (domains) => domains.length ? `${domains.length} domain${domains.length === 1 ? "" : "s"}` : "no domains yet"; // Domains arrive as { name, expires_on, auto_renew, state } objects; tolerate the // older string form (and anything malformed) by normalising to an object with a // name, dropping the rest. const normalizeDomains = (list) => (Array.isArray(list) ? list : []) .map((d) => (typeof d === "string" ? { name: d } : d)) .filter((d) => d && typeof d.name === "string"); // The domain's page on dnsimple.com, or "" if we don't have the account id (an // older event before we captured it). encodeURIComponent the name for safety. const domainUrl = (d) => d && d.account_id && d.name ? `https://dnsimple.com/a/${encodeURIComponent(d.account_id)}/domains/${encodeURIComponent(d.name)}` : ""; // Module-level UI state (not folded — transient to the surface): `busy` drives the // "Refreshing…" affordance, `autoLoading` guards the one-shot open fetch, and // `filterText` is the live filter box value (kept across re-renders). let busy = false; let autoLoading = false; let filterText = ""; export const app = defineApp({ name: "dnsimple", title: "DNSimple", glyph: "🌐", mark: "dnsimple", // native chrome: the vendored template asset iOS/Mac render (docs/icons.md) logo: LOGO_DNSIMPLE, places: ["you"], // Only listed in New chat once DNSimple is linked (shell.js gates on this). requiresService: "dnsimple", initialState: () => ({ domains: [], fetchedAt: 0 }), // One event: a fetched domain list. The latest refresh wins (replace, not // append). Validate defensively — a malformed event folds to a no-op. events: { [N.ev("domains")]: (s, e) => Array.isArray(e.domains) ? { domains: normalizeDomains(e.domains), fetchedAt: e.ts || s.fetchedAt } : s, }, // Takeover app (NO chatMessage): the domain table IS the whole thread — no Chat // view, so a fetch never echoes the list into a chat bubble. (Conversational AI // would re-introduce a Chat view later, once there's a bot to actually talk to.) // No commands either: the Interface auto-loads + Refreshes; a /list verb would be // redundant, and routing it through the bot only risks the bot speaking. styles: ` .dns-head { display: flex; align-items: baseline; gap: 12px; margin: 0 0 14px; } .dns-title { margin: 0; font-size: 18px; } .dns-sub { margin-left: auto; font-size: 12px; color: var(--muted); } .dns-refresh { flex: 0 0 auto; padding: 6px 12px; border-radius: 9px; border: 1px solid var(--border); background: var(--panel); color: var(--ink); font: inherit; cursor: var(--ui-cursor, pointer); } .dns-refresh:disabled { opacity: .6; cursor: default; } .dns-filter { width: 100%; box-sizing: border-box; padding: 8px 12px; margin: 0 0 12px; border-radius: 10px; border: 1px solid var(--border); background: var(--panel); color: var(--ink); font: inherit; } .dns-filter:focus { outline: none; border-color: var(--accent); } .dns-scroll { overflow-x: auto; margin: 0 -4px; } .dns-table { width: 100%; border-collapse: collapse; font-size: 14px; } .dns-table th { text-align: left; font-weight: 600; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding: 6px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; } .dns-table td { padding: 9px 10px; border-bottom: 1px solid var(--border); } .dns-table tbody tr { cursor: default; } .dns-table tbody tr:hover { background: var(--bubble-them); } .dns-table tbody tr.dns-active { background: var(--bubble-them); box-shadow: inset 2px 0 0 var(--accent); } .dns-name { font-weight: 500; overflow-wrap: anywhere; } .dns-name a { color: var(--accent); text-decoration: none; } .dns-name a:hover { text-decoration: underline; } .dns-status { color: var(--muted); text-transform: capitalize; white-space: nowrap; } .dns-exp { color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; } .dns-price { font-variant-numeric: tabular-nums; } .dns-c { text-align: right; white-space: nowrap; } .dns-on { color: #1a8f4c; } .dns-empty { color: var(--muted); padding: 8px 0; } .dns-note { margin-top: 12px; min-height: 18px; font-size: 13px; color: var(--muted); } `, render(state, el, ctx) { const host = ctx.host && ctx.host.connected; // appStateFor returns undefined for a thread with no folded state yet (a // freshly-opened app-thread), so normalise before touching any field — an // unguarded state.fetchedAt here is exactly what crashed the pane. state = state || { domains: [], fetchedAt: 0 }; // Normalise here too, not just in the reducer: a snapshot folded by an older // version may hold the string form, which is seeded straight in (bypassing the // reducer) — the table render needs objects with a .name. const domains = normalizeDomains(state.domains); // Keyboard-nav state (typeahead style): the active row index, plus the rows // currently shown and their nodes so ↑/↓/Enter can drive them. let activeIndex = 0; let currentRows = []; let rowEls = []; const head = document.createElement("div"); head.className = "dns-head"; const title = document.createElement("h2"); title.className = "dns-title"; title.textContent = "Your domains"; const sub = document.createElement("span"); sub.className = "dns-sub"; const refresh = document.createElement("button"); refresh.type = "button"; refresh.className = "dns-refresh"; head.append(title, sub, refresh); el.appendChild(head); // Filter box — value kept in module state so it survives re-renders. Hidden // until there are domains to filter (drawList decides). const filter = document.createElement("input"); filter.className = "dns-filter"; filter.type = "search"; filter.placeholder = "Filter domains… (↑ ↓ to move, Enter to open)"; filter.value = filterText; filter.hidden = true; filter.addEventListener("input", () => { filterText = filter.value; activeIndex = 0; // a new filter re-anchors selection to the top match drawList(); }); // Typeahead keyboard nav: move the active row and open it, right from the box. filter.addEventListener("keydown", (e) => { if (e.key === "ArrowDown") { e.preventDefault(); setActive(activeIndex + 1); } else if (e.key === "ArrowUp") { e.preventDefault(); setActive(activeIndex - 1); } else if (e.key === "Enter") { e.preventDefault(); openActive(); } else if (e.key === "Escape" && filterText) { e.preventDefault(); filter.value = ""; filterText = ""; activeIndex = 0; drawList(); } }); el.appendChild(filter); const listOrEmpty = document.createElement("div"); el.appendChild(listOrEmpty); const note = document.createElement("div"); note.className = "dns-note"; el.appendChild(note); // No privileged bridge means we're in the native app (no session key to seal // a grant with) or on a stale page that predates requestData — connected // services are web-only for now. Say so loudly, not with a muted note. if (!host || !host.requestData) { sub.textContent = ""; refresh.hidden = true; listOrEmpty.className = "dns-empty"; listOrEmpty.textContent = "DNSimple needs the web app for now — open this chat in a web browser (connecting isn't supported in the app yet)."; return; } const drawList = () => { filter.hidden = !domains.length; // only offer the filter once there's something to filter listOrEmpty.textContent = ""; if (!domains.length) { currentRows = []; rowEls = []; listOrEmpty.className = "dns-empty"; listOrEmpty.textContent = state.fetchedAt ? "No domains in this account yet." : "No domains loaded yet — hit Refresh."; return; } const q = filterText.trim().toLowerCase(); const rows = domains .filter((d) => !q || d.name.toLowerCase().includes(q)) .sort((a, b) => a.name.localeCompare(b.name)); currentRows = rows; rowEls = []; if (!rows.length) { listOrEmpty.className = "dns-empty"; listOrEmpty.textContent = `No domains match “${filterText.trim()}”.`; return; } activeIndex = Math.max(0, Math.min(activeIndex, rows.length - 1)); listOrEmpty.className = ""; const scroll = document.createElement("div"); scroll.className = "dns-scroll"; const table = document.createElement("table"); table.className = "dns-table"; const thead = document.createElement("thead"); const hr = document.createElement("tr"); for (const [label, cls] of [["Domain", ""], ["Status", ""], ["Expires", "dns-exp"], ["Renewal", "dns-c"], ["Auto-renew", "dns-c"]]) { const th = document.createElement("th"); th.textContent = label; if (cls === "dns-c") th.className = "dns-c"; hr.appendChild(th); } thead.appendChild(hr); table.appendChild(thead); const tbody = document.createElement("tbody"); rows.forEach((d, i) => { const tr = document.createElement("tr"); tr.onclick = () => setActive(i); // clicking a row makes it the keyboard-active one const nameCell = document.createElement("td"); nameCell.className = "dns-name"; // Link straight to the domain's page on dnsimple.com when we have the // account id; otherwise plain text. Never innerHTML — build the node. const url = domainUrl(d); if (url) { const a = document.createElement("a"); a.href = url; a.target = "_blank"; a.rel = "noopener noreferrer"; a.textContent = d.name; nameCell.appendChild(a); } else { nameCell.textContent = d.name; } const statusCell = document.createElement("td"); statusCell.className = "dns-status"; statusCell.textContent = d.state || "—"; // e.g. "registered" / "hosted" const expCell = document.createElement("td"); expCell.className = "dns-exp"; expCell.textContent = d.expires_on || "—"; const priceCell = document.createElement("td"); priceCell.className = "dns-c dns-price"; priceCell.textContent = typeof d.price === "number" ? `$${d.price.toFixed(2)}` : "—"; const renewCell = document.createElement("td"); renewCell.className = "dns-c" + (d.auto_renew ? " dns-on" : ""); renewCell.textContent = d.auto_renew ? "On" : "Off"; tr.append(nameCell, statusCell, expCell, priceCell, renewCell); rowEls.push(tr); tbody.appendChild(tr); }); table.appendChild(tbody); scroll.appendChild(table); listOrEmpty.appendChild(scroll); highlight(); // Focus the filter so ↑/↓/Enter work immediately — but only when focus is // free (never steal it from the chat composer). On a re-render the old, // focused filter was removed so activeElement falls back to , which // lets us restore focus to the fresh one. if (!document.activeElement || document.activeElement === document.body) filter.focus({ preventScroll: true }); }; // Keyboard-nav helpers (used by the filter's keydown + row clicks). const highlight = () => { rowEls.forEach((tr, i) => tr.classList.toggle("dns-active", i === activeIndex)); const active = rowEls[activeIndex]; if (active && active.scrollIntoView) active.scrollIntoView({ block: "nearest" }); }; const setActive = (i) => { if (!currentRows.length) return; activeIndex = Math.max(0, Math.min(i, currentRows.length - 1)); highlight(); }; const openActive = () => { const url = domainUrl(currentRows[activeIndex]); if (url) window.open(url, "_blank", "noopener"); }; // Transient bits (busy label, count line) live outside folded state, so paint // them directly rather than waiting for a re-render. const paint = () => { refresh.textContent = busy ? "Refreshing…" : "↻ Refresh"; refresh.disabled = busy; sub.textContent = busy ? "loading…" : domains.length ? summary(domains) : ""; }; const refreshNow = async () => { if (busy) return; busy = true; note.textContent = ""; paint(); let res; try { res = await host.requestData("dnsimple", "list"); } finally { busy = false; autoLoading = false; } if (res && res.ok) { // Emit the list as an event: it folds into state (so the Interface and // Chat both update and it syncs across your devices) and triggers a // re-render from the framework with the fresh domains. ctx.emit({ type: N.ev("domains"), domains: res.domains || [] }); } else { // Loud, in the main area — a failed load must never look like "no domains". const msg = (res && res.error) || "Couldn't reach DNSimple just now."; listOrEmpty.className = "dns-empty"; listOrEmpty.textContent = domains.length ? "" : `Couldn't load your domains: ${msg}`; note.textContent = domains.length ? `Couldn't refresh: ${msg}` : ""; paint(); } }; refresh.onclick = refreshNow; drawList(); paint(); // Opened with nothing loaded yet → pull the list once, automatically. if (!state.fetchedAt && !busy && !autoLoading) { autoLoading = true; refreshNow(); } }, });