// 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();
}
},
});