")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/internal/apitoken/apitoken_test.go b/internal/apitoken/apitoken_test.go
index dc081c4..d9ce349 100644
--- a/internal/apitoken/apitoken_test.go
+++ b/internal/apitoken/apitoken_test.go
@@ -1,5 +1,4 @@
package apitoken
-package apitoken
import "testing"
diff --git a/internal/checkin/pds_list.go b/internal/checkin/pds_list.go
new file mode 100644
index 0000000..e96c265
--- /dev/null
+++ b/internal/checkin/pds_list.go
@@ -0,0 +1,80 @@
+package checkin
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/bluesky-social/indigo/atproto/atclient"
+ "github.com/bluesky-social/indigo/atproto/syntax"
+)
+
+const nsidListRecords = "com.atproto.repo.listRecords"
+
+// PDSEntry is one decoded quest.atmo.checkin record returned by ListFromPDS.
+type PDSEntry struct {
+ RecordURI string // at-uri of the checkin record itself
+ EventURI string // at-uri of the referenced event
+ CheckedInAt time.Time
+}
+
+type listRecordsResponse struct {
+ Records []listRecordsEntry `json:"records"`
+ Cursor string `json:"cursor,omitempty"`
+}
+
+type listRecordsEntry struct {
+ URI string `json:"uri"`
+ Value checkinValue `json:"value"`
+}
+
+type checkinValue struct {
+ Type string `json:"$type"`
+ Event string `json:"event"`
+ CheckedInAt string `json:"checkedInAt"`
+}
+
+// ListFromPDS fetches all quest.atmo.checkin records from a user's PDS via
+// the public com.atproto.repo.listRecords endpoint (no auth required).
+// Paginates until the full set is returned.
+func ListFromPDS(ctx context.Context, pdsHost string, did syntax.DID) ([]PDSEntry, error) {
+ c := atclient.NewAPIClient(pdsHost)
+
+ var all []PDSEntry
+ cursor := ""
+
+ for {
+ params := map[string]any{
+ "repo": did.String(),
+ "collection": NSID,
+ "limit": 100,
+ }
+ if cursor != "" {
+ params["cursor"] = cursor
+ }
+
+ var resp listRecordsResponse
+ if err := c.Get(ctx, syntax.NSID(nsidListRecords), params, &resp); err != nil {
+ return nil, fmt.Errorf("checkin.ListFromPDS %s: %w", did, err)
+ }
+
+ for _, r := range resp.Records {
+ if r.Value.Event == "" {
+ continue // skip malformed records without an event reference
+ }
+ t, _ := time.Parse(time.RFC3339, r.Value.CheckedInAt)
+ all = append(all, PDSEntry{
+ RecordURI: r.URI,
+ EventURI: r.Value.Event,
+ CheckedInAt: t,
+ })
+ }
+
+ if resp.Cursor == "" || len(resp.Records) == 0 {
+ break
+ }
+ cursor = resp.Cursor
+ }
+
+ return all, nil
+}
diff --git a/internal/connection/list.go b/internal/connection/list.go
index 8eb11ee..997c67a 100644
--- a/internal/connection/list.go
+++ b/internal/connection/list.go
@@ -27,8 +27,8 @@ type listRecordsResponse struct {
}
type listRecordsEntry struct {
- URI string `json:"uri"`
- CID string `json:"cid"`
+ URI string `json:"uri"`
+ CID string `json:"cid"`
Value connectionValue `json:"value"`
}
@@ -84,6 +84,26 @@ func List(ctx context.Context, pdsHost string, did syntax.DID) ([]ListEntry, err
return all, nil
}
+// Deduplicate returns a new slice with at most one entry per target DID.
+// When the same person appears across multiple records (e.g. connected at
+// different events), the entry with the most recent ConnectedAt is kept.
+// Insertion order of the first occurrence is otherwise preserved.
+func Deduplicate(entries []ListEntry) []ListEntry {
+ seen := make(map[syntax.DID]int, len(entries)) // DID → index in result
+ result := make([]ListEntry, 0, len(entries))
+ for _, e := range entries {
+ if idx, ok := seen[e.With]; ok {
+ if e.ConnectedAt.After(result[idx].ConnectedAt) {
+ result[idx] = e
+ }
+ } else {
+ seen[e.With] = len(result)
+ result = append(result, e)
+ }
+ }
+ return result
+}
+
// HasConnection checks if the owner already has a connection record with
// the given target at the given event (or with no event if eventURI is empty).
// Returns true if a matching record exists. Uses the public listRecords
diff --git a/internal/event/fetch.go b/internal/event/fetch.go
new file mode 100644
index 0000000..c60f006
--- /dev/null
+++ b/internal/event/fetch.go
@@ -0,0 +1,98 @@
+package event
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/bluesky-social/indigo/atproto/atclient"
+ "github.com/bluesky-social/indigo/atproto/syntax"
+)
+
+const nsidGetRecord = "com.atproto.repo.getRecord"
+
+// getRecordResponse is the wire shape of com.atproto.repo.getRecord.
+type getRecordResponse struct {
+ URI string `json:"uri"`
+ CID string `json:"cid"`
+ Value eventValue `json:"value"`
+}
+
+type eventValue struct {
+ Type string `json:"$type"`
+ Name string `json:"name"`
+ StartTime string `json:"startTime"`
+ EndTime string `json:"endTime"`
+ Location string `json:"location"`
+ ExpectedAttendees int `json:"expectedAttendees"`
+ Geofence *geofenceValue `json:"geofence,omitempty"`
+}
+
+type geofenceValue struct {
+ Lat float64 `json:"lat"`
+ Lng float64 `json:"lng"`
+ RadiusMeters int `json:"radiusMeters"`
+}
+
+// FetchFromPDS downloads a single quest.atmo.event record via the public
+// com.atproto.repo.getRecord endpoint (no auth required). pdsHost is the
+// base URL of the organizer's PDS. uri is the full at-uri of the record.
+func FetchFromPDS(ctx context.Context, pdsHost string, uri syntax.ATURI) (Record, error) {
+ // Authority() is the DID or handle; RecordKey() is the TID/rkey.
+ repo := uri.Authority().String()
+ rkey := uri.RecordKey().String()
+
+ if repo == "" || rkey == "" {
+ return Record{}, fmt.Errorf("event.FetchFromPDS: malformed at-uri %q", uri)
+ }
+
+ params := map[string]any{
+ "repo": repo,
+ "collection": NSID,
+ "rkey": rkey,
+ }
+
+ var resp getRecordResponse
+ c := atclient.NewAPIClient(pdsHost)
+ if err := c.Get(ctx, syntax.NSID(nsidGetRecord), params, &resp); err != nil {
+ return Record{}, fmt.Errorf("event.FetchFromPDS %s: %w", uri, err)
+ }
+
+ if resp.Value.Name == "" {
+ return Record{}, fmt.Errorf("event.FetchFromPDS: empty event name in response for %s", uri)
+ }
+
+ startTime, _ := time.Parse(time.RFC3339, resp.Value.StartTime)
+ endTime, _ := time.Parse(time.RFC3339, resp.Value.EndTime)
+
+ organizerDID, err := syntax.ParseDID(repo)
+ if err != nil {
+ return Record{}, fmt.Errorf("event.FetchFromPDS: bad repo DID %q: %w", repo, err)
+ }
+
+ // Use the URI from the response if the PDS returned one; fall back to the
+ // input uri so callers always get a non-empty URI back.
+ recordURI := resp.URI
+ if recordURI == "" {
+ recordURI = uri.String()
+ }
+
+ rec := Record{
+ URI: recordURI,
+ Name: resp.Value.Name,
+ StartTime: startTime,
+ EndTime: endTime,
+ Location: resp.Value.Location,
+ OrganizerDID: organizerDID,
+ ExpectedAttendees: resp.Value.ExpectedAttendees,
+ }
+ if resp.Value.Geofence != nil {
+ rec.Geofence = &Geofence{
+ Lat: resp.Value.Geofence.Lat,
+ Lng: resp.Value.Geofence.Lng,
+ RadiusMeters: resp.Value.Geofence.RadiusMeters,
+ }
+ }
+
+ return rec, nil
+}
diff --git a/router/router.go b/router/router.go
index 96c7447..1d30941 100644
--- a/router/router.go
+++ b/router/router.go
@@ -77,7 +77,7 @@ func SetupRoutes(
events.SetupRoutes(router, conn, authH)
admin.SetupRoutes(router, conn, authH, signer)
settings.SetupRoutes(router, conn, authH)
- about.SetupRoutes(router)
+ about.SetupRoutes(router, authH)
demo.SetupRoutes(router)
index.SetupRoutes(router, conn, authH)
diff --git a/web/resources/static/css/terminal.css b/web/resources/static/css/terminal.css
index e08a84b..407385f 100644
--- a/web/resources/static/css/terminal.css
+++ b/web/resources/static/css/terminal.css
@@ -3431,3 +3431,82 @@ footer a:hover { color: var(--lavender); }
color: var(--muted);
font-size: 12px;
}
+
+/* ── Handle autocomplete dropdown ───────────────────────────────────────── */
+
+.handle-suggestions {
+ position: fixed;
+ z-index: 200;
+ list-style: none;
+ background: var(--mantle);
+ border: 1px solid var(--overlay);
+ border-radius: 6px;
+ overflow: hidden;
+ box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
+ padding: 4px 0;
+}
+
+.handle-suggestion {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 12px;
+ cursor: pointer;
+ transition: background 0.1s ease;
+ border-bottom: 1px solid rgba(69, 71, 90, 0.4);
+}
+.handle-suggestion:last-child { border-bottom: 0; }
+
+.handle-suggestion:hover,
+.handle-suggestion[aria-selected="true"] {
+ background: var(--surface);
+}
+
+.handle-suggestion-avatar {
+ width: 30px;
+ height: 30px;
+ border-radius: 50%;
+ object-fit: cover;
+ flex-shrink: 0;
+ background: var(--overlay);
+}
+
+.handle-suggestion-avatar-placeholder {
+ width: 30px;
+ height: 30px;
+ border-radius: 50%;
+ background: var(--overlay);
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 12px;
+ color: var(--subtext);
+}
+
+.handle-suggestion-info {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.handle-suggestion-display {
+ font-size: 13px;
+ color: var(--text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.handle-suggestion-handle {
+ font-size: 11px;
+ color: var(--muted);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.handle-suggestion-handle::before {
+ content: '@';
+ color: var(--peach);
+}
diff --git a/web/resources/static/js/handle-autocomplete.js b/web/resources/static/js/handle-autocomplete.js
new file mode 100644
index 0000000..3fee6b3
--- /dev/null
+++ b/web/resources/static/js/handle-autocomplete.js
@@ -0,0 +1,213 @@
+// atmo.quest — Handle autocomplete for the ATProto sign-in form.
+//
+// Calls the Bluesky public typeahead API 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 Bluesky API used here is public and does not require authentication:
+// https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead
+(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(
+ "https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=" +
+ encodeURIComponent(q) +
+ "&limit=8",
+ { 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(); });
+}());