From d75249e42255115988fa0fb9d9eadf7611ce7e8c Mon Sep 17 00:00:00 2001 From: Brittany Ellich Date: Sat, 23 May 2026 20:51:06 -0700 Subject: [PATCH] Make some adjustments --- features/about/pages/about.templ | 19 +- features/about/pages/about_templ.go | 24 +- features/about/routes.go | 17 +- features/admin/pages/dashboard.templ | 2 +- features/admin/pages/dashboard_templ.go | 2 +- features/auth/handlers.go | 8 +- features/auth/pages/signin.templ | 8 +- features/auth/pages/signin_atproto.templ | 6 +- features/auth/pages/signin_atproto_templ.go | 6 +- features/auth/pages/signin_templ.go | 4 +- features/auth/pds_sync.go | 201 +++++++++++++++++ features/connect/pages/connect_templ.go | 10 +- features/connections/handlers.go | 18 +- features/connections/pages/connections.templ | 2 +- .../connections/pages/connections_templ.go | 11 +- features/connections/pages/profile.templ | 2 +- features/connections/pages/profile_templ.go | 2 +- features/event/pages/event.templ | 2 +- features/event/pages/event_templ.go | 2 +- features/events/pages/detail.templ | 2 +- features/events/pages/detail_templ.go | 2 +- features/events/pages/events.templ | 2 +- features/events/pages/events_templ.go | 11 +- features/index/handlers.go | 4 + features/index/pages/index.templ | 8 +- features/index/pages/index_templ.go | 18 +- features/profile/pages/profile.templ | 2 +- features/profile/pages/profile_edit.templ | 2 +- features/profile/pages/profile_edit_templ.go | 2 +- features/profile/pages/profile_templ.go | 2 +- features/settings/pages/notes_list.templ | 2 +- features/settings/pages/notes_list_templ.go | 2 +- features/settings/pages/pds_data.templ | 2 +- features/settings/pages/pds_data_templ.go | 2 +- features/settings/pages/settings.templ | 16 +- features/settings/pages/settings_templ.go | 4 +- internal/apitoken/apitoken_test.go | 1 - internal/checkin/pds_list.go | 80 +++++++ internal/connection/list.go | 24 +- internal/event/fetch.go | 98 ++++++++ router/router.go | 2 +- web/resources/static/css/terminal.css | 79 +++++++ .../static/js/handle-autocomplete.js | 213 ++++++++++++++++++ 43 files changed, 829 insertions(+), 97 deletions(-) create mode 100644 features/auth/pds_sync.go create mode 100644 internal/checkin/pds_list.go create mode 100644 internal/event/fetch.go create mode 100644 web/resources/static/js/handle-autocomplete.js diff --git a/features/about/pages/about.templ b/features/about/pages/about.templ index 8d2f536..9f7e58d 100644 --- a/features/about/pages/about.templ +++ b/features/about/pages/about.templ @@ -2,18 +2,18 @@ package pages import "atmoquest/features/common/layouts" -// AboutATProto is a short explainer page linked from the guest landing. -// Intentionally tiny — its job is to give a curious visitor enough context -// to understand what "your data goes to your repo, not ours" means before -// they sign in. -templ AboutATProto() { +// AboutATProto is a short explainer page linked from the guest landing and +// settings. Intentionally tiny — its job is to give a curious visitor enough +// context to understand what "your data goes to your repo, not ours" means. +// When isAuthed is true the "sign in or register" CTA is hidden. +templ AboutATProto(isAuthed bool) { @layouts.Base("What's ATProto?", "ATProto is an open social protocol — your records live in your repo, not ours.") {
-
man atproto
+
about atproto
@@ -34,8 +34,11 @@ templ AboutATProto() {

diff --git a/features/about/pages/about_templ.go b/features/about/pages/about_templ.go index 08cd774..40a333b 100644 --- a/features/about/pages/about_templ.go +++ b/features/about/pages/about_templ.go @@ -10,11 +10,11 @@ import templruntime "github.com/a-h/templ/runtime" import "atmoquest/features/common/layouts" -// AboutATProto is a short explainer page linked from the guest landing. -// Intentionally tiny — its job is to give a curious visitor enough context -// to understand what "your data goes to your repo, not ours" means before -// they sign in. -func AboutATProto() templ.Component { +// AboutATProto is a short explainer page linked from the guest landing and +// settings. Intentionally tiny — its job is to give a curious visitor enough +// context to understand what "your data goes to your repo, not ours" means. +// When isAuthed is true the "sign in or register" CTA is hidden. +func AboutATProto(isAuthed bool) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -47,7 +47,7 @@ func AboutATProto() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
man atproto
you") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
about atproto
you") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -60,7 +60,17 @@ func AboutATProto() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "atmo.quest:~$ cat /etc/atproto/about.md
protocol · open · yours

your data

ATProto is an open social protocol. Instead of accounts living inside one company's database, your records live in your repo — a personal data store you can take with you.

atmo.quest writes connection, check-in, and badge records to your PDS. If atmo.quest disappears tomorrow, those records still exist in your repo and can be read by any other ATProto-aware app.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
atmo.quest:~$ cat /etc/atproto/about.md
protocol · open · yours

your data

ATProto is an open social protocol. Instead of accounts living inside one company's database, your records live in your repo — a personal data store you can take with you.

atmo.quest writes connection, check-in, and badge records to your PDS. If atmo.quest disappears tomorrow, those records still exist in your repo and can be read by any other ATProto-aware app.

← back read the protocol docs ↗ ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !isAuthed { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "▸ sign in or register ↵") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/features/about/routes.go b/features/about/routes.go index 3f2d79b..22b88d3 100644 --- a/features/about/routes.go +++ b/features/about/routes.go @@ -11,16 +11,21 @@ import ( "github.com/go-chi/chi/v5" "atmoquest/features/about/pages" + "atmoquest/features/auth" ) // SetupRoutes registers the /about-atproto route. -func SetupRoutes(router chi.Router) { - router.Get("/about-atproto", aboutATProto) +func SetupRoutes(router chi.Router, authH *auth.Handlers) { + router.Get("/about-atproto", aboutATProto(authH)) } -func aboutATProto(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := pages.AboutATProto().Render(r.Context(), w); err != nil { - slog.Error("render about-atproto", "err", err) +func aboutATProto(authH *auth.Handlers) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + didStr, _ := authH.Sessions.Get(r) + isAuthed := didStr != "" + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := pages.AboutATProto(isAuthed).Render(r.Context(), w); err != nil { + slog.Error("render about-atproto", "err", err) + } } } diff --git a/features/admin/pages/dashboard.templ b/features/admin/pages/dashboard.templ index 4c2d4de..9c6d635 100644 --- a/features/admin/pages/dashboard.templ +++ b/features/admin/pages/dashboard.templ @@ -61,7 +61,7 @@ templ adminShell(active, currentHandle string) {
-
~/atmo.quest — admin
+
admin
diff --git a/features/admin/pages/dashboard_templ.go b/features/admin/pages/dashboard_templ.go index ff096b9..0897feb 100644 --- a/features/admin/pages/dashboard_templ.go +++ b/features/admin/pages/dashboard_templ.go @@ -243,7 +243,7 @@ func adminShell(active, currentHandle string) templ.Component { templ_7745c5c3_Var10 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
~/atmo.quest — admin
admin") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
admin
admin") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/features/auth/handlers.go b/features/auth/handlers.go index 57c9c36..cced8f5 100644 --- a/features/auth/handlers.go +++ b/features/auth/handlers.go @@ -84,7 +84,7 @@ func (h *Handlers) OAuthLogin(w http.ResponseWriter, r *http.Request) { ) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusBadRequest) - _ = pages.SigninATProto("couldn't start sign-in: " + sanitizeAuthError(err)).Render(r.Context(), w) + _ = pages.SigninATProto("couldn't start sign-in: "+sanitizeAuthError(err)).Render(r.Context(), w) return } slog.Info("oauth start ok", "identifier", identifier, "redirect", redirectURL) @@ -119,6 +119,12 @@ func (h *Handlers) OAuthCallback(w http.ResponseWriter, r *http.Request) { // profile + admin features are ported. go h.RecordUserLogin(sessData) + // Best-effort: crawl the user's PDS for checkin and connection records, + // resolve referenced events, and upsert everything into local SQLite. + // This ensures past events the user attended are available in the DB + // without requiring admin intervention. + go h.SyncPDSHistory(sessData) + // Best-effort: flush any reciprocal connection writes queued while this // user was offline. Runs synchronously so the user lands on /profile with // their freshly-flushed connections visible. Errors here don't block the diff --git a/features/auth/pages/signin.templ b/features/auth/pages/signin.templ index 46a4e8a..d02b9e5 100644 --- a/features/auth/pages/signin.templ +++ b/features/auth/pages/signin.templ @@ -11,7 +11,7 @@ templ Signin() {
-
~/atmo.quest — auth
+
auth
@@ -33,12 +33,12 @@ templ Signin() {
already have a handle like you.bsky.social or any ATProto PDS? Go this way.
▸ continue ↵
- +
▸ continue ↗
+
diff --git a/features/auth/pages/signin_atproto.templ b/features/auth/pages/signin_atproto.templ index 3bc5411..894fc4d 100644 --- a/features/auth/pages/signin_atproto.templ +++ b/features/auth/pages/signin_atproto.templ @@ -11,7 +11,7 @@ templ SigninATProto(errorMsg string) {
-
~/atmo.quest — auth/atproto
+
auth/atproto
@@ -25,6 +25,7 @@ templ SigninATProto(errorMsg string) {
{ "@" }
diff --git a/features/auth/pages/signin_atproto_templ.go b/features/auth/pages/signin_atproto_templ.go index 237cd40..d2a4786 100644 --- a/features/auth/pages/signin_atproto_templ.go +++ b/features/auth/pages/signin_atproto_templ.go @@ -45,7 +45,7 @@ func SigninATProto(errorMsg string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
~/atmo.quest — auth/atproto
you") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
auth/atproto
you") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -71,7 +71,7 @@ func SigninATProto(errorMsg string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
also accepts did:plc:… or a PDS URL like https://pds.example.com ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
also accepts did:plc:… or a PDS URL like https://pds.example.com ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -83,7 +83,7 @@ func SigninATProto(errorMsg string) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(errorMsg) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/auth/pages/signin_atproto.templ`, Line: 44, Col: 61} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/auth/pages/signin_atproto.templ`, Line: 48, Col: 61} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { diff --git a/features/auth/pages/signin_templ.go b/features/auth/pages/signin_templ.go index 7410b9c..2d81e54 100644 --- a/features/auth/pages/signin_templ.go +++ b/features/auth/pages/signin_templ.go @@ -45,7 +45,7 @@ func Signin() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
~/atmo.quest — auth
you") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
auth
you") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -58,7 +58,7 @@ func Signin() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "atmo.quest:~$ auth --pick
choose how to start your quest.
step 1 of 1 · pick a path

sign in

atmo.quest is built on ATProto. Your records live in your repo, not ours. You'll sign in with the same identity you use anywhere on the open social web.

option a
sign in with your Atmosphere account
already have a handle like you.bsky.social or any ATProto PDS? Go this way.
▸ continue ↵
option b
create an Atmosphere account
new to the open social web? We'll help you pick a host and get a handle.
▸ soon …
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
atmo.quest:~$ auth --pick
choose how to start your quest.
step 1 of 1 · pick a path

sign in

atmo.quest is built on ATProto. Your records live in your repo, not ours. You'll sign in with the same identity you use anywhere on the open social web.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/features/auth/pds_sync.go b/features/auth/pds_sync.go new file mode 100644 index 0000000..b9f36db --- /dev/null +++ b/features/auth/pds_sync.go @@ -0,0 +1,201 @@ +package auth + +// SyncPDSHistory is called in a background goroutine after a successful +// login. It reads the user's quest.atmo.checkin and quest.atmo.connection +// records from their PDS, resolves the referenced events from organiser +// PDSes, and upserts everything into the local SQLite cache so that: +// +// - Past events the user attended appear on their connections and +// events pages even if the admin never explicitly imported them. +// - Connection records that reference events surface those events in +// filter chips and the summary page. +// +// Events whose EndTime is before "now" are treated as concluded: they are +// cached like any other event, and the time-based IsOngoing() check the +// rest of the app uses ensures they are shown as past events automatically. +// +// All steps are best-effort. A failure to fetch one event doesn't block the +// rest of the sync; errors are logged at Debug level so they don't pollute +// normal operation logs. + +import ( + "context" + "database/sql" + "log/slog" + "time" + + "github.com/bluesky-social/indigo/atproto/auth/oauth" + "github.com/bluesky-social/indigo/atproto/identity" + "github.com/bluesky-social/indigo/atproto/syntax" + + "atmoquest/internal/checkin" + "atmoquest/internal/connection" + "atmoquest/internal/event" +) + +// SyncPDSHistory is designed to be called as `go h.SyncPDSHistory(sessData)` +// right after OAuthCallback sets the session. It uses a generous timeout so +// that users with many records don't get cut off. +func (h *Handlers) SyncPDSHistory(sessData *oauth.ClientSessionData) { + if h.DB == nil || sessData == nil { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + did := sessData.AccountDID + pdsHost := sessData.HostURL + if pdsHost == "" { + pdsHost = "https://bsky.social" + } + + dir := identity.DefaultDirectory() + + // Collect all event URIs we discover so we can fetch them in one pass. + // Map from event AT-URI → true so we naturally deduplicate. + eventURIs := make(map[string]bool) + + // ── 1. Checkin records ──────────────────────────────────────────────── + // + // Read the user's checkin records from their own PDS. For each one, + // note the referenced event and upsert the checkin row locally. + + checkinEntries, err := checkin.ListFromPDS(ctx, pdsHost, did) + if err != nil { + slog.Debug("pds_sync: list checkins", "did", did.String(), "err", err) + // Non-fatal: continue and try connection records below. + } + + for _, ce := range checkinEntries { + if ce.EventURI != "" { + eventURIs[ce.EventURI] = true + } + // Upsert into local checkins table. Ignore errors — worst case the + // row is missing and the app fetches it on next interaction. + if ce.RecordURI != "" && ce.EventURI != "" { + cacheCheckin(ctx, h.DB, did, ce) + } + } + + // ── 2. Connection records ───────────────────────────────────────────── + // + // Connection records optionally carry an EventURI. We harvest those to + // discover events the user connected at even if they never explicitly + // checked in (e.g. admin-created event with manual connection import). + + connEntries, err := connection.List(ctx, pdsHost, did) + if err != nil { + slog.Debug("pds_sync: list connections", "did", did.String(), "err", err) + } + for _, ce := range connEntries { + if ce.EventURI != "" { + eventURIs[ce.EventURI] = true + } + } + + // ── 3. Resolve and cache events ─────────────────────────────────────── + // + // For each discovered event AT-URI: + // a. Skip if it's already cached locally (Cache() idempotently updates, + // but we avoid unnecessary PDS round-trips on subsequent logins). + // b. Parse the AT-URI to get the organiser's DID. + // c. Resolve the organiser's PDS host via the identity directory. + // d. Fetch the record and upsert into SQLite. + + now := time.Now() + + for uriStr := range eventURIs { + aturi, err := syntax.ParseATURI(uriStr) + if err != nil { + slog.Debug("pds_sync: parse event uri", "uri", uriStr, "err", err) + continue + } + + // Already in local DB? Skip the network round-trip. + if _, err := event.Get(ctx, h.DB, uriStr); err == nil { + continue + } + + // Resolve organiser's PDS. + organizerPDS, err := resolveOrganizerPDS(ctx, dir, aturi, h.DB) + if err != nil { + slog.Debug("pds_sync: resolve organiser PDS", "uri", uriStr, "err", err) + continue + } + + rec, err := event.FetchFromPDS(ctx, organizerPDS, aturi) + if err != nil { + slog.Debug("pds_sync: fetch event", "uri", uriStr, "pds", organizerPDS, "err", err) + continue + } + + // Events in the past are cached as-is. IsOngoing() in the rest of + // the app derives "concluded" purely from end_time vs. now, so no + // extra flag is needed. + if err := event.Cache(ctx, h.DB, rec); err != nil { + slog.Debug("pds_sync: cache event", "uri", uriStr, "err", err) + continue + } + + if rec.EndTime.Before(now) { + slog.Info("pds_sync: imported past event", "uri", uriStr, "name", rec.Name, "ended", rec.EndTime.Format(time.DateOnly)) + } else { + slog.Info("pds_sync: imported event", "uri", uriStr, "name", rec.Name) + } + } +} + +// resolveOrganizerPDS returns the PDS host for the organiser DID embedded in +// the event AT-URI. It first tries the local oauth_sessions table (fast, no +// network), then falls back to the identity directory (one DNS + HTTP lookup). +func resolveOrganizerPDS(ctx context.Context, dir identity.Directory, uri syntax.ATURI, db *sql.DB) (string, error) { + organizerDIDStr := uri.Authority().String() + + // Fast path: organiser has logged into this app before. + if db != nil { + var host string + _ = db.QueryRowContext(ctx, ` + SELECT data ->> 'host_url' FROM oauth_sessions + WHERE did = ? + ORDER BY updated_at DESC LIMIT 1 + `, organizerDIDStr).Scan(&host) + if host != "" { + return host, nil + } + } + + // Slow path: resolve via identity directory. + organizerDID, err := syntax.ParseDID(organizerDIDStr) + if err != nil { + return "", err + } + ident, err := dir.LookupDID(ctx, organizerDID) + if err != nil || ident == nil { + return "https://bsky.social", nil // best guess + } + if pds := ident.PDSEndpoint(); pds != "" { + return pds, nil + } + return "https://bsky.social", nil +} + +// cacheCheckin upserts one checkin row into the local SQLite cache. +// Tolerates the event not yet being in the events table (the FK is +// deferred, but SQLite's foreign-key checks aren't enforced by default; +// we'll have upserted the event shortly after this call in the same sync). +func cacheCheckin(ctx context.Context, db *sql.DB, did syntax.DID, ce checkin.PDSEntry) { + at := ce.CheckedInAt + if at.IsZero() { + at = time.Now().UTC() + } + _, err := db.ExecContext(ctx, ` + INSERT INTO checkins (record_uri, did, event_uri, checked_in_at, cached_at) + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(record_uri) DO UPDATE SET + cached_at = CURRENT_TIMESTAMP + `, ce.RecordURI, did.String(), ce.EventURI, at.UTC()) + if err != nil { + slog.Debug("pds_sync: upsert checkin", "uri", ce.RecordURI, "err", err) + } +} diff --git a/features/connect/pages/connect_templ.go b/features/connect/pages/connect_templ.go index c344501..df3f16c 100644 --- a/features/connect/pages/connect_templ.go +++ b/features/connect/pages/connect_templ.go @@ -5,10 +5,12 @@ package pages //lint:file-ignore SA4006 This context is only used if a nested component is present. -import "github.com/a-h/templ" -import templruntime "github.com/a-h/templ/runtime" +import ( + "atmoquest/features/common/layouts" -import "atmoquest/features/common/layouts" + "github.com/a-h/templ" + templruntime "github.com/a-h/templ/runtime" +) // ConnectView is the data the Connect handler renders. type ConnectView struct { @@ -70,7 +72,7 @@ func Connect(v ConnectView) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
~/atmoquest — connect
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
connect
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/features/connections/handlers.go b/features/connections/handlers.go index e504e4b..74d9dd0 100644 --- a/features/connections/handlers.go +++ b/features/connections/handlers.go @@ -75,14 +75,24 @@ func (h *Handlers) List(w http.ResponseWriter, r *http.Request) { sortBy = "recent" } + // Apply event filter, then deduplicate by target DID so each person + // appears at most once regardless of how many events they connected at. + if filterEvent != "" { + var evEntries []connection.ListEntry + for _, e := range entries { + if e.EventURI == filterEvent { + evEntries = append(evEntries, e) + } + } + entries = connection.Deduplicate(evEntries) + } else { + entries = connection.Deduplicate(entries) + } + // Enrich each connection with display name + avatar from the target's // public Bluesky profile. Each fetch is independent and soft-fails. var items []pages.ConnectionItem for _, entry := range entries { - // Apply event filter before enrichment to avoid unnecessary PDS calls. - if filterEvent != "" && entry.EventURI != filterEvent { - continue - } item := pages.ConnectionItem{ DID: entry.With.String(), diff --git a/features/connections/pages/connections.templ b/features/connections/pages/connections.templ index 854b5d2..30dc070 100644 --- a/features/connections/pages/connections.templ +++ b/features/connections/pages/connections.templ @@ -45,7 +45,7 @@ templ Connections(v ConnectionsView) {
-
~/atmo.quest — connections
+
connections
@layouts.TopNav("connections")
diff --git a/features/connections/pages/connections_templ.go b/features/connections/pages/connections_templ.go index 06690c4..413da25 100644 --- a/features/connections/pages/connections_templ.go +++ b/features/connections/pages/connections_templ.go @@ -5,14 +5,13 @@ package pages //lint:file-ignore SA4006 This context is only used if a nested component is present. -import "github.com/a-h/templ" -import templruntime "github.com/a-h/templ/runtime" - import ( + "atmoquest/features/common/layouts" "fmt" "time" - "atmoquest/features/common/layouts" + "github.com/a-h/templ" + templruntime "github.com/a-h/templ/runtime" ) // ConnectionsView is the data passed to the connections list template. @@ -79,7 +78,7 @@ func Connections(v ConnectionsView) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
~/atmo.quest — connections
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
connections
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -271,7 +270,7 @@ func Connections(v ConnectionsView) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
connections live in your repo · notes stay on this device
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
connections live in your repo · notes stay private in this app
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/features/connections/pages/profile.templ b/features/connections/pages/profile.templ index 851398d..cb337e4 100644 --- a/features/connections/pages/profile.templ +++ b/features/connections/pages/profile.templ @@ -35,7 +35,7 @@ templ ProfileViewPage(v ProfileView) {
-
~/atmo.quest — profile
+
profile
@layouts.TopNav("connections")
diff --git a/features/connections/pages/profile_templ.go b/features/connections/pages/profile_templ.go index e29e641..daa9619 100644 --- a/features/connections/pages/profile_templ.go +++ b/features/connections/pages/profile_templ.go @@ -69,7 +69,7 @@ func ProfileViewPage(v ProfileView) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
~/atmo.quest — profile
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
profile
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/features/event/pages/event.templ b/features/event/pages/event.templ index 88f28f2..c9a7497 100644 --- a/features/event/pages/event.templ +++ b/features/event/pages/event.templ @@ -30,7 +30,7 @@ templ EventScan(v EventScanView) {
-
~/atmo.quest — event
+
event
@layouts.TopNav("event")
diff --git a/features/event/pages/event_templ.go b/features/event/pages/event_templ.go index 18f9030..3d084fe 100644 --- a/features/event/pages/event_templ.go +++ b/features/event/pages/event_templ.go @@ -64,7 +64,7 @@ func EventScan(v EventScanView) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
~/atmo.quest — event
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
event
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/features/events/pages/detail.templ b/features/events/pages/detail.templ index bd128f5..d70608e 100644 --- a/features/events/pages/detail.templ +++ b/features/events/pages/detail.templ @@ -104,7 +104,7 @@ templ EventDetail(v EventDetailView) {
-
~/atmo.quest — event detail
+
event detail
@layouts.TopNav("event")
diff --git a/features/events/pages/detail_templ.go b/features/events/pages/detail_templ.go index 0d56511..200173d 100644 --- a/features/events/pages/detail_templ.go +++ b/features/events/pages/detail_templ.go @@ -138,7 +138,7 @@ func EventDetail(v EventDetailView) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
~/atmo.quest — event detail
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
event detail
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/features/events/pages/events.templ b/features/events/pages/events.templ index 78f109e..f94f116 100644 --- a/features/events/pages/events.templ +++ b/features/events/pages/events.templ @@ -39,7 +39,7 @@ templ Events(v EventsView) {
-
~/atmo.quest — events
+
events
@layouts.TopNav("event")
diff --git a/features/events/pages/events_templ.go b/features/events/pages/events_templ.go index afcfc81..33646ff 100644 --- a/features/events/pages/events_templ.go +++ b/features/events/pages/events_templ.go @@ -5,13 +5,12 @@ package pages //lint:file-ignore SA4006 This context is only used if a nested component is present. -import "github.com/a-h/templ" -import templruntime "github.com/a-h/templ/runtime" - import ( + "atmoquest/features/common/layouts" "fmt" - "atmoquest/features/common/layouts" + "github.com/a-h/templ" + templruntime "github.com/a-h/templ/runtime" ) // EventsView is the data for the /events page. @@ -73,7 +72,7 @@ func Events(v EventsView) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
~/atmo.quest — events
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
events
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -112,7 +111,7 @@ func Events(v EventsView) templ.Component { return templ_7745c5c3_Err } if !v.HasAny { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

you haven't checked into any events yet.

scan an event QR code to get started.

← back home
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

you haven't checked into any events yet.

scan an event QR code or connect at an event to get started.

← back home
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/features/index/handlers.go b/features/index/handlers.go index 66bc751..e58496c 100644 --- a/features/index/handlers.go +++ b/features/index/handlers.go @@ -165,6 +165,10 @@ func (h *Handlers) fillRecentConnections(r *http.Request, did syntax.DID, view * return } + // Deduplicate so the same person only appears once even if connected + // at multiple events. + entries = connection.Deduplicate(entries) + const maxRecent = 8 limit := len(entries) if limit > maxRecent { diff --git a/features/index/pages/index.templ b/features/index/pages/index.templ index 2b03890..0ba5b06 100644 --- a/features/index/pages/index.templ +++ b/features/index/pages/index.templ @@ -82,13 +82,13 @@ templ indexAuthed(v IndexView) {
-
~/atmo.quest — home
+
home
@layouts.TopNav("home")
you{ "@" }atmo.quest:~$ - whoami --short + whoami
@@ -241,7 +241,7 @@ templ indexGuest() {
-
~/atmo.quest — zsh
+
zsh
@@ -292,7 +292,7 @@ templ indexGuest() {
◆
-
Check in to CascadiaJS
+
Check in to an event
Drops a checkin record dated June 1–2, 2026. You'll find it in your repo forever.
+1 badge attendee
diff --git a/features/index/pages/index_templ.go b/features/index/pages/index_templ.go index 72b5f76..dc5de63 100644 --- a/features/index/pages/index_templ.go +++ b/features/index/pages/index_templ.go @@ -5,11 +5,11 @@ package pages //lint:file-ignore SA4006 This context is only used if a nested component is present. -import "github.com/a-h/templ" -import templruntime "github.com/a-h/templ/runtime" - import ( "atmoquest/features/common/layouts" + + "github.com/a-h/templ" + templruntime "github.com/a-h/templ/runtime" ) // IndexView passes session-aware values into the landing page. When a user @@ -144,7 +144,7 @@ func indexAuthed(v IndexView) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
~/atmo.quest — home
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
home
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -165,7 +165,7 @@ func indexAuthed(v IndexView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "atmo.quest:~$ whoami --short
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " notes ›
◆
PDS data
stored in your ATProto PDS
›
Danger zone
") 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(); }); +}()); -- 2.51.2