| 1 | // The DNSimple companion app (docs/connected-services.md § companion apps). Once |
| 2 | // you've linked DNSimple (via the Integrations surface), this app "installs": |
| 3 | // it appears in New chat and opens as its own thread, where the Interface view |
| 4 | // lists your domains and the Chat view reads back as a conversation with it. |
| 5 | // |
| 6 | // There is NO extra sidecar and no new bot: the app fetches your domains through |
| 7 | // the host bridge (ctx.host.connected.requestData), which asks the instance's |
| 8 | // EXISTING bot to run the DNSimple call under a one-op, sealed grant and hand the |
| 9 | // result straight back. The browser can't call DNSimple itself (no CORS), and the |
| 10 | // server only ever relays ciphertext — the token never leaves your trust domain |
| 11 | // except sealed to the bot for that single call. |
| 12 | // |
| 13 | // It's a COMPANION app: implementing chatMessage(e) opts into the Chat | Interface |
| 14 | // split (see apps/README.md). Your domain list is folded state, so it persists |
| 15 | // across devices and survives reopen via the snapshot, just like any other app. |
| 16 | import { defineApp, namespace } from "../apps.js"; |
| 17 | import { LOGO_DNSIMPLE } from "../logos.js"; |
| 18 | |
| 19 | // One declaration of the app's event-type prefix, reused everywhere: N.ev("domains") |
| 20 | // → the "dnsimple.domains" wire type. NOTE: the CSS classes intentionally use a |
| 21 | // shorter `dns-` prefix (not matching the "dnsimple" name), so they're left as |
| 22 | // literals — a single namespace can't produce both "dnsimple." and "dns-". |
| 23 | const N = namespace("dnsimple"); |
| 24 | |
| 25 | // A tiny helper so both the Chat projection and the reducer agree on wording. |
| 26 | const summary = (domains) => |
| 27 | domains.length ? `${domains.length} domain${domains.length === 1 ? "" : "s"}` : "no domains yet"; |
| 28 | |
| 29 | // Domains arrive as { name, expires_on, auto_renew, state } objects; tolerate the |
| 30 | // older string form (and anything malformed) by normalising to an object with a |
| 31 | // name, dropping the rest. |
| 32 | const normalizeDomains = (list) => |
| 33 | (Array.isArray(list) ? list : []) |
| 34 | .map((d) => (typeof d === "string" ? { name: d } : d)) |
| 35 | .filter((d) => d && typeof d.name === "string"); |
| 36 | |
| 37 | // The domain's page on dnsimple.com, or "" if we don't have the account id (an |
| 38 | // older event before we captured it). encodeURIComponent the name for safety. |
| 39 | const domainUrl = (d) => |
| 40 | d && d.account_id && d.name ? `https://dnsimple.com/a/${encodeURIComponent(d.account_id)}/domains/${encodeURIComponent(d.name)}` : ""; |
| 41 | |
| 42 | // Module-level UI state (not folded — transient to the surface): `busy` drives the |
| 43 | // "Refreshing…" affordance, `autoLoading` guards the one-shot open fetch, and |
| 44 | // `filterText` is the live filter box value (kept across re-renders). |
| 45 | let busy = false; |
| 46 | let autoLoading = false; |
| 47 | let filterText = ""; |
| 48 | |
| 49 | export const app = defineApp({ |
| 50 | name: "dnsimple", |
| 51 | title: "DNSimple", |
| 52 | glyph: "🌐", |
| 53 | mark: "dnsimple", // native chrome: the vendored template asset iOS/Mac render (docs/icons.md) |
| 54 | logo: LOGO_DNSIMPLE, |
| 55 | places: ["you"], |
| 56 | // Only listed in New chat once DNSimple is linked (shell.js gates on this). |
| 57 | requiresService: "dnsimple", |
| 58 | initialState: () => ({ domains: [], fetchedAt: 0 }), |
| 59 | |
| 60 | // One event: a fetched domain list. The latest refresh wins (replace, not |
| 61 | // append). Validate defensively — a malformed event folds to a no-op. |
| 62 | events: { |
| 63 | [N.ev("domains")]: (s, e) => |
| 64 | Array.isArray(e.domains) ? { domains: normalizeDomains(e.domains), fetchedAt: e.ts || s.fetchedAt } : s, |
| 65 | }, |
| 66 | |
| 67 | // Takeover app (NO chatMessage): the domain table IS the whole thread — no Chat |
| 68 | // view, so a fetch never echoes the list into a chat bubble. (Conversational AI |
| 69 | // would re-introduce a Chat view later, once there's a bot to actually talk to.) |
| 70 | // No commands either: the Interface auto-loads + Refreshes; a /list verb would be |
| 71 | // redundant, and routing it through the bot only risks the bot speaking. |
| 72 | |
| 73 | styles: ` |
| 74 | .dns-head { display: flex; align-items: baseline; gap: 12px; margin: 0 0 14px; } |
| 75 | .dns-title { margin: 0; font-size: 18px; } |
| 76 | .dns-sub { margin-left: auto; font-size: 12px; color: var(--muted); } |
| 77 | .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); } |
| 78 | .dns-refresh:disabled { opacity: .6; cursor: default; } |
| 79 | .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; } |
| 80 | .dns-filter:focus { outline: none; border-color: var(--accent); } |
| 81 | .dns-scroll { overflow-x: auto; margin: 0 -4px; } |
| 82 | .dns-table { width: 100%; border-collapse: collapse; font-size: 14px; } |
| 83 | .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; } |
| 84 | .dns-table td { padding: 9px 10px; border-bottom: 1px solid var(--border); } |
| 85 | .dns-table tbody tr { cursor: default; } |
| 86 | .dns-table tbody tr:hover { background: var(--bubble-them); } |
| 87 | .dns-table tbody tr.dns-active { background: var(--bubble-them); box-shadow: inset 2px 0 0 var(--accent); } |
| 88 | .dns-name { font-weight: 500; overflow-wrap: anywhere; } |
| 89 | .dns-name a { color: var(--accent); text-decoration: none; } |
| 90 | .dns-name a:hover { text-decoration: underline; } |
| 91 | .dns-status { color: var(--muted); text-transform: capitalize; white-space: nowrap; } |
| 92 | .dns-exp { color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; } |
| 93 | .dns-price { font-variant-numeric: tabular-nums; } |
| 94 | .dns-c { text-align: right; white-space: nowrap; } |
| 95 | .dns-on { color: #1a8f4c; } |
| 96 | .dns-empty { color: var(--muted); padding: 8px 0; } |
| 97 | .dns-note { margin-top: 12px; min-height: 18px; font-size: 13px; color: var(--muted); } |
| 98 | `, |
| 99 | |
| 100 | render(state, el, ctx) { |
| 101 | const host = ctx.host && ctx.host.connected; |
| 102 | // appStateFor returns undefined for a thread with no folded state yet (a |
| 103 | // freshly-opened app-thread), so normalise before touching any field — an |
| 104 | // unguarded state.fetchedAt here is exactly what crashed the pane. |
| 105 | state = state || { domains: [], fetchedAt: 0 }; |
| 106 | // Normalise here too, not just in the reducer: a snapshot folded by an older |
| 107 | // version may hold the string form, which is seeded straight in (bypassing the |
| 108 | // reducer) — the table render needs objects with a .name. |
| 109 | const domains = normalizeDomains(state.domains); |
| 110 | |
| 111 | // Keyboard-nav state (typeahead style): the active row index, plus the rows |
| 112 | // currently shown and their <tr> nodes so ↑/↓/Enter can drive them. |
| 113 | let activeIndex = 0; |
| 114 | let currentRows = []; |
| 115 | let rowEls = []; |
| 116 | |
| 117 | const head = document.createElement("div"); |
| 118 | head.className = "dns-head"; |
| 119 | const title = document.createElement("h2"); |
| 120 | title.className = "dns-title"; |
| 121 | title.textContent = "Your domains"; |
| 122 | const sub = document.createElement("span"); |
| 123 | sub.className = "dns-sub"; |
| 124 | const refresh = document.createElement("button"); |
| 125 | refresh.type = "button"; |
| 126 | refresh.className = "dns-refresh"; |
| 127 | head.append(title, sub, refresh); |
| 128 | el.appendChild(head); |
| 129 | |
| 130 | // Filter box — value kept in module state so it survives re-renders. Hidden |
| 131 | // until there are domains to filter (drawList decides). |
| 132 | const filter = document.createElement("input"); |
| 133 | filter.className = "dns-filter"; |
| 134 | filter.type = "search"; |
| 135 | filter.placeholder = "Filter domains… (↑ ↓ to move, Enter to open)"; |
| 136 | filter.value = filterText; |
| 137 | filter.hidden = true; |
| 138 | filter.addEventListener("input", () => { |
| 139 | filterText = filter.value; |
| 140 | activeIndex = 0; // a new filter re-anchors selection to the top match |
| 141 | drawList(); |
| 142 | }); |
| 143 | // Typeahead keyboard nav: move the active row and open it, right from the box. |
| 144 | filter.addEventListener("keydown", (e) => { |
| 145 | if (e.key === "ArrowDown") { |
| 146 | e.preventDefault(); |
| 147 | setActive(activeIndex + 1); |
| 148 | } else if (e.key === "ArrowUp") { |
| 149 | e.preventDefault(); |
| 150 | setActive(activeIndex - 1); |
| 151 | } else if (e.key === "Enter") { |
| 152 | e.preventDefault(); |
| 153 | openActive(); |
| 154 | } else if (e.key === "Escape" && filterText) { |
| 155 | e.preventDefault(); |
| 156 | filter.value = ""; |
| 157 | filterText = ""; |
| 158 | activeIndex = 0; |
| 159 | drawList(); |
| 160 | } |
| 161 | }); |
| 162 | el.appendChild(filter); |
| 163 | |
| 164 | const listOrEmpty = document.createElement("div"); |
| 165 | el.appendChild(listOrEmpty); |
| 166 | |
| 167 | const note = document.createElement("div"); |
| 168 | note.className = "dns-note"; |
| 169 | el.appendChild(note); |
| 170 | |
| 171 | // No privileged bridge means we're in the native app (no session key to seal |
| 172 | // a grant with) or on a stale page that predates requestData — connected |
| 173 | // services are web-only for now. Say so loudly, not with a muted note. |
| 174 | if (!host || !host.requestData) { |
| 175 | sub.textContent = ""; |
| 176 | refresh.hidden = true; |
| 177 | listOrEmpty.className = "dns-empty"; |
| 178 | listOrEmpty.textContent = "DNSimple needs the web app for now — open this chat in a web browser (connecting isn't supported in the app yet)."; |
| 179 | return; |
| 180 | } |
| 181 | |
| 182 | const drawList = () => { |
| 183 | filter.hidden = !domains.length; // only offer the filter once there's something to filter |
| 184 | listOrEmpty.textContent = ""; |
| 185 | if (!domains.length) { |
| 186 | currentRows = []; |
| 187 | rowEls = []; |
| 188 | listOrEmpty.className = "dns-empty"; |
| 189 | listOrEmpty.textContent = state.fetchedAt ? "No domains in this account yet." : "No domains loaded yet — hit Refresh."; |
| 190 | return; |
| 191 | } |
| 192 | const q = filterText.trim().toLowerCase(); |
| 193 | const rows = domains |
| 194 | .filter((d) => !q || d.name.toLowerCase().includes(q)) |
| 195 | .sort((a, b) => a.name.localeCompare(b.name)); |
| 196 | currentRows = rows; |
| 197 | rowEls = []; |
| 198 | if (!rows.length) { |
| 199 | listOrEmpty.className = "dns-empty"; |
| 200 | listOrEmpty.textContent = `No domains match “${filterText.trim()}”.`; |
| 201 | return; |
| 202 | } |
| 203 | activeIndex = Math.max(0, Math.min(activeIndex, rows.length - 1)); |
| 204 | listOrEmpty.className = ""; |
| 205 | const scroll = document.createElement("div"); |
| 206 | scroll.className = "dns-scroll"; |
| 207 | const table = document.createElement("table"); |
| 208 | table.className = "dns-table"; |
| 209 | |
| 210 | const thead = document.createElement("thead"); |
| 211 | const hr = document.createElement("tr"); |
| 212 | for (const [label, cls] of [["Domain", ""], ["Status", ""], ["Expires", "dns-exp"], ["Renewal", "dns-c"], ["Auto-renew", "dns-c"]]) { |
| 213 | const th = document.createElement("th"); |
| 214 | th.textContent = label; |
| 215 | if (cls === "dns-c") th.className = "dns-c"; |
| 216 | hr.appendChild(th); |
| 217 | } |
| 218 | thead.appendChild(hr); |
| 219 | table.appendChild(thead); |
| 220 | |
| 221 | const tbody = document.createElement("tbody"); |
| 222 | rows.forEach((d, i) => { |
| 223 | const tr = document.createElement("tr"); |
| 224 | tr.onclick = () => setActive(i); // clicking a row makes it the keyboard-active one |
| 225 | |
| 226 | const nameCell = document.createElement("td"); |
| 227 | nameCell.className = "dns-name"; |
| 228 | // Link straight to the domain's page on dnsimple.com when we have the |
| 229 | // account id; otherwise plain text. Never innerHTML — build the node. |
| 230 | const url = domainUrl(d); |
| 231 | if (url) { |
| 232 | const a = document.createElement("a"); |
| 233 | a.href = url; |
| 234 | a.target = "_blank"; |
| 235 | a.rel = "noopener noreferrer"; |
| 236 | a.textContent = d.name; |
| 237 | nameCell.appendChild(a); |
| 238 | } else { |
| 239 | nameCell.textContent = d.name; |
| 240 | } |
| 241 | |
| 242 | const statusCell = document.createElement("td"); |
| 243 | statusCell.className = "dns-status"; |
| 244 | statusCell.textContent = d.state || "—"; // e.g. "registered" / "hosted" |
| 245 | |
| 246 | const expCell = document.createElement("td"); |
| 247 | expCell.className = "dns-exp"; |
| 248 | expCell.textContent = d.expires_on || "—"; |
| 249 | |
| 250 | const priceCell = document.createElement("td"); |
| 251 | priceCell.className = "dns-c dns-price"; |
| 252 | priceCell.textContent = typeof d.price === "number" ? `$${d.price.toFixed(2)}` : "—"; |
| 253 | |
| 254 | const renewCell = document.createElement("td"); |
| 255 | renewCell.className = "dns-c" + (d.auto_renew ? " dns-on" : ""); |
| 256 | renewCell.textContent = d.auto_renew ? "On" : "Off"; |
| 257 | |
| 258 | tr.append(nameCell, statusCell, expCell, priceCell, renewCell); |
| 259 | rowEls.push(tr); |
| 260 | tbody.appendChild(tr); |
| 261 | }); |
| 262 | table.appendChild(tbody); |
| 263 | scroll.appendChild(table); |
| 264 | listOrEmpty.appendChild(scroll); |
| 265 | highlight(); |
| 266 | // Focus the filter so ↑/↓/Enter work immediately — but only when focus is |
| 267 | // free (never steal it from the chat composer). On a re-render the old, |
| 268 | // focused filter was removed so activeElement falls back to <body>, which |
| 269 | // lets us restore focus to the fresh one. |
| 270 | if (!document.activeElement || document.activeElement === document.body) filter.focus({ preventScroll: true }); |
| 271 | }; |
| 272 | |
| 273 | // Keyboard-nav helpers (used by the filter's keydown + row clicks). |
| 274 | const highlight = () => { |
| 275 | rowEls.forEach((tr, i) => tr.classList.toggle("dns-active", i === activeIndex)); |
| 276 | const active = rowEls[activeIndex]; |
| 277 | if (active && active.scrollIntoView) active.scrollIntoView({ block: "nearest" }); |
| 278 | }; |
| 279 | const setActive = (i) => { |
| 280 | if (!currentRows.length) return; |
| 281 | activeIndex = Math.max(0, Math.min(i, currentRows.length - 1)); |
| 282 | highlight(); |
| 283 | }; |
| 284 | const openActive = () => { |
| 285 | const url = domainUrl(currentRows[activeIndex]); |
| 286 | if (url) window.open(url, "_blank", "noopener"); |
| 287 | }; |
| 288 | |
| 289 | // Transient bits (busy label, count line) live outside folded state, so paint |
| 290 | // them directly rather than waiting for a re-render. |
| 291 | const paint = () => { |
| 292 | refresh.textContent = busy ? "Refreshing…" : "↻ Refresh"; |
| 293 | refresh.disabled = busy; |
| 294 | sub.textContent = busy ? "loading…" : domains.length ? summary(domains) : ""; |
| 295 | }; |
| 296 | |
| 297 | const refreshNow = async () => { |
| 298 | if (busy) return; |
| 299 | busy = true; |
| 300 | note.textContent = ""; |
| 301 | paint(); |
| 302 | let res; |
| 303 | try { |
| 304 | res = await host.requestData("dnsimple", "list"); |
| 305 | } finally { |
| 306 | busy = false; |
| 307 | autoLoading = false; |
| 308 | } |
| 309 | if (res && res.ok) { |
| 310 | // Emit the list as an event: it folds into state (so the Interface and |
| 311 | // Chat both update and it syncs across your devices) and triggers a |
| 312 | // re-render from the framework with the fresh domains. |
| 313 | ctx.emit({ type: N.ev("domains"), domains: res.domains || [] }); |
| 314 | } else { |
| 315 | // Loud, in the main area — a failed load must never look like "no domains". |
| 316 | const msg = (res && res.error) || "Couldn't reach DNSimple just now."; |
| 317 | listOrEmpty.className = "dns-empty"; |
| 318 | listOrEmpty.textContent = domains.length ? "" : `Couldn't load your domains: ${msg}`; |
| 319 | note.textContent = domains.length ? `Couldn't refresh: ${msg}` : ""; |
| 320 | paint(); |
| 321 | } |
| 322 | }; |
| 323 | refresh.onclick = refreshNow; |
| 324 | |
| 325 | drawList(); |
| 326 | paint(); |
| 327 | |
| 328 | // Opened with nothing loaded yet → pull the list once, automatically. |
| 329 | if (!state.fetchedAt && !busy && !autoLoading) { |
| 330 | autoLoading = true; |
| 331 | refreshNow(); |
| 332 | } |
| 333 | }, |
| 334 | }); |
| 335 | |