An event companion and onboarding experience for the ATmosphere (alpha) atmo.quest
6

Configure Feed

Select the types of activity you want to include in your feed.

Update sign in templates, event handler, and add atproto links to profiles

Brittany Ellich (Jun 2, 2026, 7:33 AM -0700) e3376d59 8f57555e

+205 -24
+3 -5
features/auth/pages/signin.templ
··· 9 9 // Signin renders the chooser: existing Atmosphere account vs create a new one. 10 10 // next is an optional path to redirect to after sign-in. 11 11 templ Signin(next string) { 12 - @layouts.Base("sign in", "Sign in with your ATProto account.") { 12 + @layouts.Base("sign in", "Sign in with your Atmosphere (Bluesky) account.") { 13 13 <div class="window"> 14 14 <div class="window-bar"> 15 15 <div class="dot dot-r"></div> ··· 22 22 <span class="user">you</span><span class="at">{ "@" }</span><span class="path">atmo.quest</span><span class="sep">:~$</span> 23 23 <span class="cmd">auth --pick</span> 24 24 </div> 25 - <div class="output indent">choose how to start your quest.</div> 26 25 <div class="hero-block"> 27 - <span class="hero-tag">step 1 of 1 · pick a path</span> 28 26 <h1>sign <span class="quest">in</span></h1> 29 27 <p class="lede"> 30 28 atmo.quest is built on <span class="kw">ATProto</span>. Your records live in <span class="kw2">your repo</span>, not ours. You'll sign in with the same identity you use anywhere on the open social web. ··· 33 31 <div class="auth-choices"> 34 32 <a href="/signin/atproto" class="auth-card"> 35 33 <div class="auth-card-tag">option a</div> 36 - <div class="auth-card-title">sign in with your <span class="kw2">Atmosphere</span> account</div> 34 + <div class="auth-card-title">Sign in with your <span class="kw2">Atmosphere (Bluesky)</span> account</div> 37 35 <div class="auth-card-desc">already have a handle like <code>you.bsky.social</code> or any ATProto PDS? Go this way.</div> 38 36 <div class="auth-card-cta">▸ continue <span class="shortcut">↵</span></div> 39 37 </a> 40 38 <a href={ templ.SafeURL(continueLocalURL(next)) } class="auth-card"> 41 39 <div class="auth-card-tag">option b</div> 42 - <div class="auth-card-title">Continue without an <span class="kw2">Atmosphere</span> account</div> 40 + <div class="auth-card-title">Continue without an <span class="kw2">Atmosphere (Bluesky)</span> account</div> 43 41 <div class="auth-card-desc">use atmo.quest locally — link an Atmosphere account later in Settings to claim your data.</div> 44 42 <div class="auth-card-cta">▸ continue</div> 45 43 </a>
+4 -5
features/auth/pages/signin_templ.go
··· 5 5 6 6 //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 7 8 - import "github.com/a-h/templ" 9 - import templruntime "github.com/a-h/templ/runtime" 10 - 11 8 import ( 9 + "atmoquest/features/common/layouts" 12 10 "net/url" 13 11 14 - "atmoquest/features/common/layouts" 12 + "github.com/a-h/templ" 13 + templruntime "github.com/a-h/templ/runtime" 15 14 ) 16 15 17 16 // Signin renders the chooser: existing Atmosphere account vs create a new one. ··· 62 61 if templ_7745c5c3_Err != nil { 63 62 return templ_7745c5c3_Err 64 63 } 65 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</span><span class=\"path\">atmo.quest</span><span class=\"sep\">:~$</span> <span class=\"cmd\">auth --pick</span></div><div class=\"output indent\">choose how to start your quest.</div><div class=\"hero-block\"><span class=\"hero-tag\">step 1 of 1 · pick a path</span><h1>sign <span class=\"quest\">in</span></h1><p class=\"lede\">atmo.quest is built on <span class=\"kw\">ATProto</span>. Your records live in <span class=\"kw2\">your repo</span>, not ours. You'll sign in with the same identity you use anywhere on the open social web.</p></div><div class=\"auth-choices\"><a href=\"/signin/atproto\" class=\"auth-card\"><div class=\"auth-card-tag\">option a</div><div class=\"auth-card-title\">sign in with your <span class=\"kw2\">Atmosphere</span> account</div><div class=\"auth-card-desc\">already have a handle like <code>you.bsky.social</code> or any ATProto PDS? Go this way.</div><div class=\"auth-card-cta\">▸ continue <span class=\"shortcut\">↵</span></div></a> <a href=\"") 64 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</span><span class=\"path\">atmo.quest</span><span class=\"sep\">:~$</span> <span class=\"cmd\">auth --pick</span></div><div class=\"hero-block\"><span class=\"hero-tag\">step 1 of 1 · pick a path</span><h1>sign <span class=\"quest\">in</span></h1><p class=\"lede\">atmo.quest is built on <span class=\"kw\">ATProto</span>. Your records live in <span class=\"kw2\">your repo</span>, not ours. You'll sign in with the same identity you use anywhere on the open social web.</p></div><div class=\"auth-choices\"><a href=\"/signin/atproto\" class=\"auth-card\"><div class=\"auth-card-tag\">option a</div><div class=\"auth-card-title\">sign in with your <span class=\"kw2\">Atmosphere</span> account</div><div class=\"auth-card-desc\">already have a handle like <code>you.bsky.social</code> or any ATProto PDS? Go this way.</div><div class=\"auth-card-cta\">▸ continue <span class=\"shortcut\">↵</span></div></a> <a href=\"") 66 65 if templ_7745c5c3_Err != nil { 67 66 return templ_7745c5c3_Err 68 67 }
+66 -9
features/connect/handlers.go
··· 18 18 "strings" 19 19 "time" 20 20 21 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 21 22 "github.com/bluesky-social/indigo/atproto/syntax" 22 23 "github.com/go-chi/chi/v5" 23 24 ··· 430 431 continue 431 432 } 432 433 if strings.HasPrefix(raw, "local_") { 433 - // local→local: write local_connections 434 - err := connection.WriteLocal(r.Context(), h.DB, "", identity.DID, "", raw, "") 434 + // local→local: write local_connections, linking to either 435 + // party's ongoing event and checking the viewer in. 436 + eventURI := h.flushEventURI(r.Context(), raw, identity.DID) 437 + err := connection.WriteLocal(r.Context(), h.DB, "", identity.DID, "", raw, eventURI) 435 438 if err != nil { 436 439 skipped++ 437 440 errs = append(errs, "write failed for "+raw) 438 441 continue 439 442 } 443 + h.autoCheckinViewer(r.Context(), nil, identity.DID, eventURI) 440 444 _ = badge.AwardLocal(r.Context(), h.DB, identity.DID, badge.AwardFirstConnect) 441 445 written++ 442 446 } else { ··· 446 450 skipped++ 447 451 continue 448 452 } 449 - err = connection.WriteLocal(r.Context(), h.DB, "", identity.DID, target.String(), "", "") 453 + eventURI := h.flushEventURI(r.Context(), target.String(), identity.DID) 454 + err = connection.WriteLocal(r.Context(), h.DB, "", identity.DID, target.String(), "", eventURI) 450 455 if err != nil { 451 456 skipped++ 452 457 continue 453 458 } 454 - _ = h.Queue.EnqueueLocal(r.Context(), target, identity.DID, "") 459 + _ = h.Queue.EnqueueLocal(r.Context(), target, identity.DID, eventURI) 460 + h.autoCheckinViewer(r.Context(), nil, identity.DID, eventURI) 455 461 _ = badge.AwardLocal(r.Context(), h.DB, identity.DID, badge.AwardFirstConnect) 456 462 written++ 457 463 } ··· 479 485 } 480 486 481 487 viewerDIDStr := viewerDID.String() 482 - err := connection.WriteLocal(r.Context(), h.DB, viewerDIDStr, "", "", raw, "") 488 + eventURI := h.flushEventURI(r.Context(), raw, viewerDIDStr) 489 + err := connection.WriteLocal(r.Context(), h.DB, viewerDIDStr, "", "", raw, eventURI) 483 490 if err != nil { 484 491 slog.Warn("flush-local: write local connection", "target", raw, "err", err) 485 492 skipped++ 486 493 errs = append(errs, "write failed for "+raw) 487 494 continue 488 495 } 496 + h.autoCheckinViewer(r.Context(), viewerSess, viewerDIDStr, eventURI) 489 497 _ = badge.AwardLocal(r.Context(), h.DB, viewerDIDStr, badge.AwardFirstConnect) 490 498 written++ 491 499 continue ··· 500 508 if target == viewerDID { 501 509 continue 502 510 } 511 + // Link to the target's ongoing event (fall back to the viewer's) 512 + // so the replayed connection records the event and checks the 513 + // viewer in, exactly as the live /c/{did} handler would have. 514 + eventURI := h.flushEventURI(r.Context(), target.String(), viewerDID.String()) 503 515 viewerPDS := h.lookupPDSForDID(r, viewerDID) 504 - if connection.HasConnection(r.Context(), viewerPDS, viewerDID, target, "") { 516 + if connection.HasConnection(r.Context(), viewerPDS, viewerDID, target, eventURI) { 505 517 skipped++ 506 518 continue 507 519 } 508 - if _, _, err := connection.Put(r.Context(), viewerSess, h.DB, connection.Record{With: target}); err != nil { 520 + if _, _, err := connection.Put(r.Context(), viewerSess, h.DB, connection.Record{With: target, EventURI: eventURI}); err != nil { 509 521 slog.Warn("flush-local: viewer record", "target", target.String(), "err", err) 510 522 skipped++ 511 523 errs = append(errs, "write failed for "+target.String()) 512 524 continue 513 525 } 514 - if err := h.Queue.Enqueue(r.Context(), target, viewerDID, ""); err != nil { 526 + if err := h.Queue.Enqueue(r.Context(), target, viewerDID, eventURI); err != nil { 515 527 slog.Warn("flush-local: enqueue", "target", target.String(), "err", err) 516 528 } 529 + h.autoCheckinViewer(r.Context(), viewerSess, viewerDID.String(), eventURI) 517 530 // Award first-connect badge (idempotent). 518 - if _, err := badge.Award(r.Context(), viewerSess, h.DB, badge.AwardFirstConnect, ""); err != nil { 531 + if _, err := badge.Award(r.Context(), viewerSess, h.DB, badge.AwardFirstConnect, eventURI); err != nil { 519 532 slog.Info("flush-local: first-connect badge", "err", err) 520 533 } 521 534 written++ ··· 552 565 return evURI 553 566 } 554 567 return "" 568 + } 569 + 570 + // flushEventURI resolves the ongoing event tying a flushed connection to a 571 + // check-in: prefer the scanned target's current event, fall back to the 572 + // viewer's, mirroring the live /c/{did} handler. Returns "" if neither party 573 + // is at an ongoing event. targetID/viewerID may be ATProto DIDs or local_xxx 574 + // IDs (the underlying checkin lookup keys on the string id). 575 + func (h *Handlers) flushEventURI(ctx context.Context, targetID, viewerID string) string { 576 + if ev := h.ongoingEventURI(ctx, targetID); ev != "" { 577 + return ev 578 + } 579 + return h.ongoingEventURI(ctx, viewerID) 580 + } 581 + 582 + // autoCheckinViewer checks the viewer into eventURI and records the 583 + // event-attendance side effects (attendee badge + event stats), mirroring the 584 + // auto-checkin the live /c/{did} handler performs so a connection replayed 585 + // through flush-local also checks the viewer in. A non-nil sess writes the 586 + // check-in through the viewer's PDS; a nil sess means a local viewer whose 587 + // check-in is written straight to the local cache keyed on viewerID. No-op 588 + // when eventURI is "". 589 + func (h *Handlers) autoCheckinViewer(ctx context.Context, sess *oauth.ClientSession, viewerID, eventURI string) { 590 + if eventURI == "" { 591 + return 592 + } 593 + if sess != nil { 594 + if _, err := checkin.Put(ctx, sess, h.DB, eventURI, time.Time{}); err != nil { 595 + slog.Warn("flush-local: auto-checkin", "event_uri", eventURI, "err", err) 596 + } 597 + if _, err := badge.Award(ctx, sess, h.DB, badge.AwardEventAttendee, eventURI); err != nil { 598 + slog.Info("flush-local: event-attendee badge", "err", err) 599 + } 600 + } else { 601 + if err := checkin.PutLocal(ctx, h.DB, viewerID, eventURI, time.Time{}); err != nil { 602 + slog.Warn("flush-local: auto-checkin local", "event_uri", eventURI, "err", err) 603 + } 604 + _ = badge.AwardLocal(ctx, h.DB, viewerID, badge.AwardEventAttendee) 605 + } 606 + if err := event.IncrementCheckins(ctx, h.DB, eventURI); err != nil { 607 + slog.Info("flush-local: increment checkins", "err", err) 608 + } 609 + if err := event.IncrementConnectors(ctx, h.DB, eventURI); err != nil { 610 + slog.Info("flush-local: increment connectors", "err", err) 611 + } 555 612 } 556 613 557 614 // lookupPDSForDID returns the PDS host for the given DID, using whatever we
+127
features/connect/handlers_test.go
··· 1 + package connect 2 + 3 + import ( 4 + "context" 5 + "net/http" 6 + "net/http/httptest" 7 + "path/filepath" 8 + "strings" 9 + "testing" 10 + "time" 11 + 12 + "atmoquest/config" 13 + "atmoquest/features/auth" 14 + "atmoquest/internal/checkin" 15 + "atmoquest/internal/connection" 16 + atdb "atmoquest/internal/db" 17 + "atmoquest/internal/session" 18 + 19 + "database/sql" 20 + ) 21 + 22 + // newTestHandlers wires a connect.Handlers backed by a fresh in-temp-dir DB and 23 + // a session manager that only needs to read the local cookie (no OAuth). 24 + func newTestHandlers(t *testing.T) (*Handlers, *sql.DB) { 25 + t.Helper() 26 + dir := t.TempDir() 27 + dsn := "file:" + filepath.Join(dir, "test.db") + "?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)" 28 + conn, err := atdb.Open(dsn) 29 + if err != nil { 30 + t.Fatalf("open db: %v", err) 31 + } 32 + t.Cleanup(func() { _ = conn.Close() }) 33 + if err := atdb.Migrate(conn); err != nil { 34 + t.Fatalf("migrate: %v", err) 35 + } 36 + 37 + mgr, err := session.New(&config.Config{ 38 + Environment: config.Dev, 39 + SessionSecret: "0123456789abcdef0123456789abcdef", 40 + PublicURL: "http://127.0.0.1:8080", 41 + }) 42 + if err != nil { 43 + t.Fatalf("session.New: %v", err) 44 + } 45 + authH := &auth.Handlers{DB: conn, Sessions: mgr} 46 + return NewHandlers(conn, authH, connection.NewQueue(conn)), conn 47 + } 48 + 49 + // seedOngoingEvent inserts an events row whose window contains now, plus a 50 + // check-in tying `attendeeDID` to it, so the user counts as "at the event". 51 + func seedOngoingEvent(t *testing.T, db *sql.DB, eventURI, attendeeDID string) { 52 + t.Helper() 53 + ctx := context.Background() 54 + now := time.Now().UTC() 55 + _, err := db.ExecContext(ctx, ` 56 + INSERT INTO events (uri, name, start_time, end_time) 57 + VALUES (?, 'Test Event', ?, ?) 58 + `, eventURI, now.Add(-time.Hour), now.Add(time.Hour)) 59 + if err != nil { 60 + t.Fatalf("seed event: %v", err) 61 + } 62 + _, err = db.ExecContext(ctx, ` 63 + INSERT INTO checkins (record_uri, did, event_uri, checked_in_at) 64 + VALUES (?, ?, ?, ?) 65 + `, "rec://"+attendeeDID, attendeeDID, eventURI, now) 66 + if err != nil { 67 + t.Fatalf("seed checkin: %v", err) 68 + } 69 + } 70 + 71 + // TestConnectFlushLocal_ChecksInLocalViewerToTargetEvent reproduces the bug: a 72 + // not-logged-in user scans an ATProto user's QR while that user is checked into 73 + // an ongoing event. The target is stashed in localStorage and replayed through 74 + // /connect/flush-local after sign-in. The connection record should be created 75 + // AND the viewer should be auto-checked into the target's current event. 76 + func TestConnectFlushLocal_ChecksInLocalViewerToTargetEvent(t *testing.T) { 77 + h, db := newTestHandlers(t) 78 + ctx := context.Background() 79 + 80 + const ( 81 + viewerLocalID = "local_viewer1" 82 + targetDID = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" 83 + eventURI = "at://did:plc:org/quest.atmo.event/evt1" 84 + ) 85 + 86 + // Target must exist in users (local_connections.target_did FK) and be at 87 + // an ongoing event. 88 + if _, err := db.ExecContext(ctx, 89 + `INSERT INTO users (did, handle) VALUES (?, 'target.test')`, targetDID); err != nil { 90 + t.Fatalf("seed target user: %v", err) 91 + } 92 + seedOngoingEvent(t, db, eventURI, targetDID) 93 + 94 + req := httptest.NewRequest(http.MethodPost, "/connect/flush-local", 95 + strings.NewReader(`{"targets":["`+targetDID+`"]}`)) 96 + req.Header.Set("Content-Type", "application/json") 97 + req.AddCookie(&http.Cookie{Name: "atmoquest_local", Value: viewerLocalID}) 98 + rr := httptest.NewRecorder() 99 + 100 + h.ConnectFlushLocal(rr, req) 101 + 102 + if rr.Code != http.StatusOK { 103 + t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) 104 + } 105 + 106 + // The viewer should now be checked into the target's event. 107 + evURI, ok, err := checkin.CurrentForLocal(ctx, db, viewerLocalID) 108 + if err != nil { 109 + t.Fatalf("lookup viewer checkin: %v", err) 110 + } 111 + if !ok || evURI != eventURI { 112 + t.Fatalf("viewer checkin = (%q, %v), want (%q, true)", evURI, ok, eventURI) 113 + } 114 + 115 + // And the connection record should be linked to that event. 116 + var connEvent string 117 + err = db.QueryRowContext(ctx, ` 118 + SELECT event_uri FROM local_connections 119 + WHERE viewer_local_id = ? AND target_did = ? 120 + `, viewerLocalID, targetDID).Scan(&connEvent) 121 + if err != nil { 122 + t.Fatalf("query local_connection: %v", err) 123 + } 124 + if connEvent != eventURI { 125 + t.Errorf("connection event_uri = %q, want %q", connEvent, eventURI) 126 + } 127 + }
+1 -1
features/index/pages/index.templ
··· 262 262 263 263 <div class="hero-block"> 264 264 <span class="hero-tag">Find your people in the Atmosphere.</span> 265 - <h1>atmo<span class="quest">.quest</span><span class="cursor">&nbsp;</span></h1> 265 + <h1>atmo<span class="quest">.quest</span></h1> 266 266 <p class="lede"> 267 267 An <span class="kw">event companion</span> { "for" } the open social web. Scan a QR, write a note, leave with a real follow-up list. Your data goes to <span class="kw2">your repo</span>, not ours. 268 268 </p>
+4 -4
features/index/pages/index_templ.go
··· 5 5 6 6 //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 7 8 - import "github.com/a-h/templ" 9 - import templruntime "github.com/a-h/templ/runtime" 10 - 11 8 import ( 12 9 "atmoquest/features/common/layouts" 10 + 11 + "github.com/a-h/templ" 12 + templruntime "github.com/a-h/templ/runtime" 13 13 ) 14 14 15 15 // IndexView passes session-aware values into the landing page. When a user ··· 693 693 if templ_7745c5c3_Err != nil { 694 694 return templ_7745c5c3_Err 695 695 } 696 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</span><span class=\"path\">cascadiajs</span><span class=\"sep\">:~$</span> <span class=\"cmd\">whoami</span></div><div class=\"hero-block\"><span class=\"hero-tag\">Find your people in the Atmosphere.</span><h1>atmo<span class=\"quest\">.quest</span><span class=\"cursor\">&nbsp;</span></h1><p class=\"lede\">An <span class=\"kw\">event companion</span> ") 696 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</span><span class=\"path\">cascadiajs</span><span class=\"sep\">:~$</span> <span class=\"cmd\">whoami</span></div><div class=\"hero-block\"><span class=\"hero-tag\">Find your people in the Atmosphere.</span><h1>atmo<span class=\"quest\">.quest</span></h1><p class=\"lede\">An <span class=\"kw\">event companion</span> ") 697 697 if templ_7745c5c3_Err != nil { 698 698 return templ_7745c5c3_Err 699 699 }