// atmo.quest — Handle autocomplete for the ATProto sign-in form. // // Calls /handle-search (a backend proxy) as the user types and shows a // dropdown of matching actors (avatar, display name, handle). Selecting // one fills in the input so the OAuth flow can continue. // // The backend proxies app.bsky.actor.searchActorsTypeahead via the Bluesky // public AppView, which indexes the full ATProto relay — results include users // on any federated PDS, not just bsky.social. (function () { "use strict"; var input = document.getElementById("handle-input"); if (!input) return; var dropdown = null; var debounceTimer = null; var activeIndex = -1; var inflightController = null; var lastQuery = ""; // ── Dropdown lifecycle ────────────────────────────────────────────────── function createDropdown() { if (dropdown) return; dropdown = document.createElement("ul"); dropdown.className = "handle-suggestions"; dropdown.setAttribute("role", "listbox"); dropdown.setAttribute("aria-label", "Handle suggestions"); document.body.appendChild(dropdown); } function destroyDropdown() { if (dropdown) { dropdown.remove(); dropdown = null; } activeIndex = -1; input.setAttribute("aria-expanded", "false"); } function positionDropdown() { if (!dropdown) return; var wrap = input.closest(".field-input-wrap"); var rect = (wrap || input).getBoundingClientRect(); dropdown.style.top = (rect.bottom + 4) + "px"; dropdown.style.left = rect.left + "px"; dropdown.style.width = rect.width + "px"; } // ── Rendering ─────────────────────────────────────────────────────────── function renderSuggestions(actors) { if (!actors || actors.length === 0) { destroyDropdown(); return; } createDropdown(); positionDropdown(); dropdown.innerHTML = ""; activeIndex = -1; input.setAttribute("aria-expanded", "true"); actors.forEach(function (actor) { var li = document.createElement("li"); li.className = "handle-suggestion"; li.setAttribute("role", "option"); li.setAttribute("aria-selected", "false"); li.setAttribute("data-handle", actor.handle); // Avatar if (actor.avatar) { var img = document.createElement("img"); img.className = "handle-suggestion-avatar"; img.src = actor.avatar; img.alt = ""; img.loading = "lazy"; li.appendChild(img); } else { var placeholder = document.createElement("div"); placeholder.className = "handle-suggestion-avatar-placeholder"; // Use first letter of display name or handle — textContent only, no XSS risk placeholder.textContent = (actor.displayName || actor.handle).charAt(0).toUpperCase(); li.appendChild(placeholder); } // Text info var info = document.createElement("div"); info.className = "handle-suggestion-info"; var displaySpan = document.createElement("span"); displaySpan.className = "handle-suggestion-display"; displaySpan.textContent = actor.displayName || actor.handle; var handleSpan = document.createElement("span"); handleSpan.className = "handle-suggestion-handle"; handleSpan.textContent = actor.handle; info.appendChild(displaySpan); info.appendChild(handleSpan); li.appendChild(info); // mousedown (not click) so the blur on the input fires after we fill the value li.addEventListener("mousedown", function (e) { e.preventDefault(); selectHandle(actor.handle); }); dropdown.appendChild(li); }); } // ── Selection ─────────────────────────────────────────────────────────── function selectHandle(handle) { input.value = handle; destroyDropdown(); input.focus(); } function setActive(index) { if (!dropdown) return; var items = dropdown.querySelectorAll(".handle-suggestion"); var clamped = Math.max(0, Math.min(index, items.length - 1)); items.forEach(function (el, i) { el.setAttribute("aria-selected", i === clamped ? "true" : "false"); }); activeIndex = clamped; } // ── Search ────────────────────────────────────────────────────────────── function search(q) { if (!q || q.length < 2) { destroyDropdown(); return; } // Don't suggest for DIDs or PDS URLs — let those go straight through if (q.startsWith("did:") || q.startsWith("http://") || q.startsWith("https://")) { destroyDropdown(); return; } // Abort any in-flight request if (inflightController) { inflightController.abort(); } inflightController = new AbortController(); var thisQuery = q; lastQuery = q; var timeoutId = setTimeout(function () { inflightController.abort(); }, 4000); fetch( "/handle-search?q=" + encodeURIComponent(q), { signal: inflightController.signal } ) .then(function (resp) { clearTimeout(timeoutId); if (!resp.ok) throw new Error("search failed"); return resp.json(); }) .then(function (data) { // Discard stale responses if (lastQuery !== thisQuery) return; renderSuggestions(data.actors); }) .catch(function () { clearTimeout(timeoutId); // Silently ignore network errors and aborts — the user can still type a handle }); } // ── Event listeners ───────────────────────────────────────────────────── input.addEventListener("input", function () { clearTimeout(debounceTimer); var q = input.value.trim(); debounceTimer = setTimeout(function () { search(q); }, 220); }); input.addEventListener("keydown", function (e) { if (!dropdown) return; var items = dropdown.querySelectorAll(".handle-suggestion"); if (e.key === "ArrowDown") { e.preventDefault(); setActive(activeIndex < 0 ? 0 : activeIndex + 1); } else if (e.key === "ArrowUp") { e.preventDefault(); setActive(activeIndex <= 0 ? 0 : activeIndex - 1); } else if (e.key === "Enter" && activeIndex >= 0) { var selected = items[activeIndex]; if (selected) { e.preventDefault(); selectHandle(selected.getAttribute("data-handle")); } } else if (e.key === "Escape") { destroyDropdown(); } }); input.addEventListener("blur", function () { // Small delay so mousedown on a suggestion fires first setTimeout(destroyDropdown, 200); }); // Reposition on scroll or resize so the dropdown tracks the input window.addEventListener("scroll", function () { if (dropdown) positionDropdown(); }, true); window.addEventListener("resize", function () { if (dropdown) positionDropdown(); }); }());