eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

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.
16import { defineApp, namespace } from "../apps.js";
17import { 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-".
23const N = namespace("dnsimple");
24
25// A tiny helper so both the Chat projection and the reducer agree on wording.
26const 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.
32const 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.
39const 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).
45let busy = false;
46let autoLoading = false;
47let filterText = "";
48
49export 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