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.

Add spect and fix notes

authored by

Brittany Ellich and committed by
Tangled
(May 18, 2026, 5:37 AM UTC) a6f6eba7 9d57045c

+529 -18
+368
docs/atmo-quest-spec.md
··· 1 + # atmo.quest — Product Spec 2 + 3 + > Working document for the atmo.quest project. Intended audience: human collaborators and the AI assistants helping them. If you are an AI assistant reading this as context, treat decisions in this doc as authoritative unless the user explicitly overrides them in conversation. 4 + 5 + **Status:** Pre-launch, targeting CascadiaJS 2026 (June 1–2, Seattle) 6 + **Domain:** atmo.quest 7 + **Last updated:** May 2026 8 + 9 + --- 10 + 11 + ## What it is 12 + 13 + A conference companion app on ATProto. You scan a QR, you take a note, you leave with a real follow-up list. Your connections live in your own ATProto repo, not in a database that will be gone next year. 14 + 15 + The launch event is CascadiaJS 2026. The longer-term goal is a general-purpose tool any organizer can use for their event. 16 + 17 + ### One-line pitch 18 + 19 + > The people you meet are yours to keep. 20 + 21 + ### Why it exists 22 + 23 + Conferences produce stacks of business cards and zero memory of who anyone was. Closed event apps own that data and shut down. ATProto offers a path where connections, notes, and event records live in the attendee's own repo — portable, durable, theirs. 24 + 25 + --- 26 + 27 + ## Goals 28 + 29 + 1. **Make it easy to connect at events through ATProto.** Scanning a QR should work in under 5 seconds, with or without an existing account. 30 + 2. **Remove the "you need an account first" wall.** Guest mode collects connections in local storage; account creation happens on the user's schedule, not the app's. 31 + 3. **Demonstrate ATProto's portability story in a concrete way.** Records are written to user PDSes. The lesson is in the product, not the marketing copy. 32 + 4. **Ship something polished for CascadiaJS.** A working, polished v1 by June 1, 2026. 33 + 34 + ### Non-goals (v1) 35 + 36 + - Replacing Bluesky, Linkedin, or any general-purpose social app 37 + - Real-time messaging between users 38 + - Public profiles browsable outside an active event 39 + - Multi-event analytics dashboards for organizers 40 + - The "side quests published by other atproto accounts" idea from Will — interesting but explicit scope creep for v1 41 + 42 + --- 43 + 44 + ## Aesthetic direction 45 + 46 + **Terminal Quest.** Catppuccin-themed terminal/dev aesthetic. JetBrains Mono for body, Instrument Serif italic for display accents. Peach (#fab387) as the accent color, lavender (#b4befe) for secondary highlights. Dark theme only for v1. 47 + 48 + Reference files: `app-screens.html`, `design-3-terminal.html` in the design folder. 49 + 50 + **Avoid:** generic SaaS landing-page aesthetics, purple gradients on white, Inter/Roboto/system fonts. The audience is JS developers at Cascadia; they recognize and reject generic AI design. 51 + 52 + --- 53 + 54 + ## Users 55 + 56 + 1. **The connected attendee** — already has a Bluesky/ATProto account. Most ATProto-curious folks. 57 + 2. **The guest attendee** — does not have a Bluesky/ATProto account. Wants to scan a stranger's QR without committing to anything first. 58 + 3. **The event organizer** (post-Cascadia) — wants to set up an event for their meetup/conf. 59 + 4. **The admin** — Brittany + core team. Needs usage stats and operational visibility. 60 + 61 + --- 62 + 63 + ## Features 64 + 65 + ### Onboarding 66 + 67 + #### When a user lands on atmo.quest cold 68 + 69 + - See the home page (`design-3-terminal.html`) 70 + - Two CTAs: "Start scanning" and "What's ATProto?" 71 + - "What's ATProto?" routes to `/about-atproto` 72 + 73 + #### When a user scans someone's QR before signing in 74 + 75 + - Land on the scanned person's profile preview 76 + - Prompt: "Connect with [name]? You don't need an account yet." 77 + - If accepted: connection saved to local storage, prompted lightly to sign up "when you have time" 78 + - If user later signs up, local connections are migrated to their PDS 79 + 80 + #### Sign-up flow 81 + 82 + - "Do you already have an Atmosphere account?" → Login (OAuth via `@atproto/oauth-client-*`) 83 + - "Create an account" → default flow creates account on `bsky.social` 84 + - **Advanced (disclosure):** "Choose your own PDS / handle" — hidden behind a collapse for v1 to avoid edge-case support load 85 + - **Stretch (not v1):** "Log in with Mastodon via bridgy fed" 86 + 87 + #### Account creation default 88 + 89 + - New accounts default to `bsky.social` PDS 90 + - Default handle suggestion: from user's name, like `firstname.bsky.social` 91 + - The user can override but the defaults make signup near-frictionless 92 + 93 + ### Profile 94 + 95 + #### Self profile 96 + 97 + - Pull display name, avatar, bio from the user's `app.bsky.actor.profile` 98 + - Enrich with atmo.quest-specific fields: 99 + - Important links (GitHub, personal site, mastodon, etc. — arbitrary pills) 100 + - Interests (free-text tags, used for matching at events) 101 + - Location (city/region) 102 + - Where they work 103 + - Best contact method 104 + - Optional flags: `hiring` (employer), `looking` (job seeker) 105 + - These extra fields live in a `quest.atmo.profile` record in the user's repo 106 + 107 + #### Other person's profile (after connecting) 108 + 109 + - All public profile info 110 + - "Connected · [date]" badge 111 + - Hiring/looking badges if set 112 + - Pill links and interests 113 + - **Notes section** (private to the viewer, local storage only for v1) 114 + - **Follow-up checkbox** — flags this person for the post-conference summary 115 + 116 + ### QR / Connection 117 + 118 + - Each logged-in user has a QR code that encodes a `bsky://` or `at://` URI pointing to their profile 119 + - Home screen shows your avatar; tap to flip to QR 120 + - Scanning a QR: 121 + - **Both users logged in:** writes a `quest.atmo.connection` record to each user's repo 122 + - **Scanner is a guest:** saves the connection to local storage with a "claim me later" flag 123 + - **Scanned person is a guest:** rare edge case; same as above but reversed 124 + - Connections during an active event window (both parties checked in, scan happens within event start/end) are automatically associated with that event in the connection record 125 + - Users can connect to the same person at multiple future events; each creates a new connection record with a different event reference (or no event) 126 + 127 + #### Stretch: mobile app with widget 128 + 129 + - Native iOS/Android wrapper around the web app 130 + - Home screen widget showing your QR code for one-tap-from-lockscreen sharing 131 + - Not in v1 scope, but treat as plausible v1.1 132 + 133 + ### Events 134 + 135 + #### Event creation 136 + 137 + - **Hidden from public UI until after CascadiaJS** — "hey, you can use this at your events too!" launch 138 + - Required fields: name, start time, end time, expected participant count 139 + - Optional fields: geofence (lat/long + radius), location name, organizer Bluesky handle 140 + - Calculated: total possible *unique connecting pairs* = N attendees (NOT N×(N−1)/2, see Metrics section) 141 + 142 + #### Event check-in 143 + 144 + - Triggered manually by the user (tap "check in" on the event) 145 + - Optional soft geofence check: if user has location permission and is outside the radius, show "you don't seem to be at the event, are you sure?" prompt — never a hard block (indoor venues mess with GPS, desktop users have no GPS) 146 + - Writes a `quest.atmo.checkin` record to the user's repo, referencing the event 147 + 148 + #### Event merging (post-Cascadia) 149 + 150 + - Two event creators can merge their events into a single super-event 151 + - Both get the original event-starter badge plus a special "interop" badge 152 + - Connection records continue to reference the merged event 153 + 154 + ### Unlocks (group progress) 155 + 156 + Progress metric: **unique attendees who have made at least one connection** at the event. NOT total connection count (gameable), NOT N×(N−1)/2 possible pairs (mathematically unreachable for any real event size). 157 + 158 + Unlock thresholds for CascadiaJS (375-attendee estimate, ~30% adoption target = ~115 active users): 159 + 160 + | Threshold | Unlock | Notes | 161 + |---|---|---| 162 + | 1 (everyone, on check-in) | Badge, mini profile, scan-to-connect, private notes, basic post-conference summary | Default | 163 + | 20 unique connectors | Leaderboard appears | Early signal, ~6% adoption | 164 + | 75 unique connectors | Interest matching surface (see attendees with overlapping interests you haven't connected with) | Mid, ~20% adoption | 165 + | 150 unique connectors | Enhanced in-app summary with stats | Stretch, ~40% adoption | 166 + 167 + **Note:** These numbers should be revisited closer to the event based on actual ticket sales and known attendance. Numbers must feel reachable but not trivial. 168 + 169 + **The progress bar should fill via WebSocket** so the experience feels alive when more people are joining. 170 + 171 + ### Badges 172 + 173 + Stored as `quest.atmo.badge` records in user repo. Cosmetic, not gating. 174 + 175 + - `first-connect` — your first connection ever 176 + - `total-connections-50`, `-100`, `-200`, etc. 177 + - `profile-complete` — all profile fields filled 178 + - `event-attendee` — checked in to an event 179 + - `event-starter` — created an event 180 + - `event-interop` — merged your event with another 181 + - `event-unlock-N` — earned per group-unlock tier hit (e.g. you were here when the leaderboard unlocked) 182 + 183 + ### Notes & Follow-ups 184 + 185 + - **Notes are local storage only for v1.** Reasoning: ATProto doesn't have production-ready private records yet (see Daniel Holmgren's permissioned data work). Putting personal observations about someone in your public repo is bad. Notes live in `localStorage` keyed by `(viewer DID, target DID)`. 186 + - **Follow-up flag** on each connection — included in the post-conference summary email/export. 187 + - Future migration plan: when ATProto supports private records, offer to migrate notes to the user's repo. 188 + 189 + ### Post-conference summary 190 + 191 + - **Everyone gets a basic summary:** list of people met, your notes, links to their profiles 192 + - **Enhanced version (at 150-unlock):** adds interest overlap analysis, "people you almost met" (shared interests, didn't connect), conference-wide stats 193 + - Delivered: in-app view + optional emailed/exported markdown 194 + 195 + ### About-ATProto page 196 + 197 + - `/about-atproto` 198 + - Plain-language explanation of: 199 + - What ATProto is (decentralized social protocol, your data lives in your repo) 200 + - What a PDS is, what a handle is 201 + - Why atmo.quest is built on it 202 + - How to get involved (links to Bluesky, atproto.com, other atmosphere apps) 203 + - Link visible from onboarding ("What's ATProto?" CTA) and from a header link on the home page 204 + 205 + ### Admin interface 206 + 207 + - Auth: any logged-in user with `is_admin = true` in the database (set initially via DB script) 208 + - Pages: 209 + - Usage summary (DAU, total connections, total users, active events) 210 + - User list (search by handle) 211 + - Event list with per-event stats 212 + - Connection trends over time 213 + - Initial admins: Brittany + core team 214 + 215 + --- 216 + 217 + ## Lexicons (preliminary) 218 + 219 + > These are working drafts. Real lexicon definitions to be finalized before the alpha. Namespace `quest.atmo.*` (reverse-DNS of the `atmo.quest` domain). 220 + 221 + ### `quest.atmo.profile` 222 + 223 + ``` 224 + { 225 + "links": [{ "label": "GitHub", "url": "..." }, ...], 226 + "interests": ["edge computing", "rust", ...], 227 + "location": "Vancouver, WA", 228 + "worksAt": "GitHub", 229 + "contactMethod": "DM on bsky", 230 + "hiring": false, 231 + "looking": true, 232 + "updatedAt": "2026-05-..." 233 + } 234 + ``` 235 + 236 + ### `quest.atmo.event` 237 + 238 + ``` 239 + { 240 + "name": "CascadiaJS 2026", 241 + "startTime": "2026-06-01T08:00-08:00", 242 + "endTime": "2026-06-02T23:00-08:00", 243 + "expectedAttendees": 375, 244 + "location": "Seattle, WA", 245 + "geofence": { "lat": ..., "lng": ..., "radiusMeters": 500 }, 246 + "organizerDid": "did:plc:..." 247 + } 248 + ``` 249 + 250 + ### `quest.atmo.checkin` 251 + 252 + ``` 253 + { 254 + "event": "at://did:plc:.../quest.atmo.event/cascadiajs-2026", 255 + "checkedInAt": "2026-06-01T09:42-08:00" 256 + } 257 + ``` 258 + 259 + ### `quest.atmo.connection` 260 + 261 + ``` 262 + { 263 + "with": "did:plc:...", 264 + "connectedAt": "2026-06-01T10:42-08:00", 265 + "event": "at://did:plc:.../quest.atmo.event/cascadiajs-2026" // optional 266 + } 267 + ``` 268 + 269 + ### `quest.atmo.badge` 270 + 271 + ``` 272 + { 273 + "type": "first-connect" | "event-attendee" | ..., 274 + "earnedAt": "2026-06-01T10:42-08:00", 275 + "event": "at://..." // optional, for event-specific badges 276 + } 277 + ``` 278 + 279 + --- 280 + 281 + ## Screens 282 + 283 + Reference: `app-screens.html` 284 + 285 + ### Home 286 + 287 + - Top bar: logo + user handle 288 + - Greeting + time 289 + - QR flip card (tap avatar to flip to QR) 290 + - Active event banner (when checked into one) — links to event screen 291 + - Recent connections grid (4-col, ~8 visible, tap to view profile) 292 + 293 + ### Connections (replaces "last seen" tab) 294 + 295 + - Full list of all your connections (across all events + non-event) 296 + - Search by handle 297 + - Filter by event (dropdown) 298 + - Sort: most recent, alphabetical, has-follow-up-flag 299 + - Tap any connection → that person's profile screen 300 + 301 + ### Profile (other person) 302 + 303 + - Avatar + name + handle 304 + - Connection metadata badge ("connected · Jun 1") 305 + - Hiring/looking badges 306 + - Profile detail card (bio, works at, location, contact) 307 + - Pill rows: links + interests 308 + - Notes textarea (private, local) 309 + - Follow-up checkbox 310 + 311 + ### Event 312 + 313 + - Event title, dates, status indicator (live / upcoming / ended) 314 + - Progress bar with tick-marks for unlock thresholds 315 + - Earned/locked rewards list 316 + - Checked-in attendees grid 317 + - Connected = color avatar w/ peach border 318 + - Unconnected = grayscale avatar 319 + - Filter: all / mine 320 + - Live (WebSocket) indicator 321 + 322 + ### Bottom nav 323 + 324 + - Home / Connections / Event (when applicable, otherwise Events list) 325 + 326 + --- 327 + 328 + ## Tech stack (proposed) 329 + 330 + > These are starting points, not commitments. Adjust per implementation reality. 331 + 332 + - **Frontend:** TBD — needs to be lightweight, mobile-first, dark-mode-only, offline-capable for the guest-mode local-storage path. Astro + a sprinkle of interactivity, or a plain React SPA, both viable. 333 + - **Auth:** `@atproto/oauth-client-browser` for browser-based OAuth, `@atproto/oauth-client-node` for any server-side flows 334 + - **Records:** Standard `@atproto/api` `Agent` for record CRUD 335 + - **Backend:** Minimal — needs to handle the WebSocket for live event progress, store admin flags, possibly cache event stats. Could be a thin Node/Hono server. 336 + - **Database:** Whatever's quickest. SQLite + Kysely (like Statusphere) is fine for v1. 337 + 338 + --- 339 + 340 + ## Marketing 341 + 342 + - **May 31 pre-party:** hacker train to Seattle, socialize the app in person 343 + - **Printed postcards** with QR to atmo.quest and a "best way to connect at CascadiaJS" tagline 344 + - **ATProto PDX community sponsor slot:** mention during their stage time 345 + - **Brittany's talk** is near the end of the conference — final shoutout, possibly a live stats reveal if unlocks have happened 346 + - **Emcee/organizer shoutouts** where possible 347 + - **Bluesky posts** from `@brittany.dev` and atmosphere community accounts 348 + 349 + --- 350 + 351 + ## Open questions / decisions pending 352 + 353 + - [ ] Final unlock thresholds based on actual ticket sales / attendance estimate 354 + - [ ] Mobile app stretch goal: yes for v1.1 or punt? 355 + - [ ] Connection record privacy model — currently both parties write a record referencing each other. Is the existence of a `quest.atmo.connection` referencing another DID itself sensitive? Probably not, but worth thinking about. 356 + - [ ] Confirm `quest.atmo.*` namespace is OK / not conflicting with existing lexicons 357 + - [ ] What's the rate limit / sustainability story if this goes viral mid-conference? 358 + 359 + --- 360 + 361 + ## Hard rules for AI assistants helping on this repo 362 + 363 + - Notes are local storage only in v1. Do not write code that puts user notes about other people into a public PDS record. 364 + - Do not invent ATProto API behavior. Check `@atproto/api` docs, atproto.com, or the bluesky-social/atproto repo for actual specs. 365 + - Default new accounts to `bsky.social`. Custom PDS / handle is an advanced disclosure, not a top-level choice. 366 + - The aesthetic is Terminal Quest (Catppuccin + JetBrains Mono + Instrument Serif italic accents, peach as accent). Do not propose Inter, do not propose purple gradients on white. 367 + - The unlock metric is **unique attendees who have made at least one connection**. Not total connections, not pairs. 368 + - Geofencing is a soft prompt, never a hard block.
+36 -4
features/auth/drain_middleware.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "database/sql" 5 6 "log/slog" 6 7 "net/http" 7 8 "strings" 8 9 "sync" 9 10 "time" 10 11 12 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 11 13 "github.com/bluesky-social/indigo/atproto/syntax" 12 14 15 + "atmoquest/internal/badge" 16 + "atmoquest/internal/checkin" 13 17 "atmoquest/internal/connection" 18 + "atmoquest/internal/event" 14 19 ) 15 20 16 21 // DrainMiddleware returns an http.Handler middleware that automatically ··· 18 23 // request. To avoid hammering SQLite + PDS on every page load, it debounces 19 24 // per-DID: after a successful check it won't re-check for the same user 20 25 // until drainInterval has elapsed. 21 - func DrainMiddleware(authH *Handlers, queue *connection.Queue, interval time.Duration) func(http.Handler) http.Handler { 26 + func DrainMiddleware(authH *Handlers, queue *connection.Queue, db *sql.DB, interval time.Duration) func(http.Handler) http.Handler { 22 27 dm := &drainState{ 23 28 interval: interval, 24 29 last: make(map[string]time.Time), ··· 41 46 // Extract session data now — the goroutine must NOT 42 47 // use r or r.Context() because the request will be 43 48 // done by the time it runs. 44 - go drainAsync(authH, queue, did, sid, dm) 49 + go drainAsync(authH, queue, db, did, sid, dm) 45 50 } 46 51 } 47 52 } ··· 76 81 // drainAsync runs the queue drain in a background goroutine so it doesn't 77 82 // block the page render. Uses a fresh context because the originating HTTP 78 83 // request will be done by the time this runs. 79 - func drainAsync(authH *Handlers, queue *connection.Queue, did syntax.DID, sid string, dm *drainState) { 84 + func drainAsync(authH *Handlers, queue *connection.Queue, db *sql.DB, did syntax.DID, sid string, dm *drainState) { 80 85 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 81 86 defer cancel() 82 87 ··· 95 100 return 96 101 } 97 102 98 - res, err := connection.Drain(ctx, queue, sess, slog.Default()) 103 + // Auto-check-in hook: when a drained connection references an ongoing 104 + // event, check the user into that event so both parties end up as 105 + // attendees regardless of who scanned whose QR. 106 + onDrain := func(ctx context.Context, sess *oauth.ClientSession, item connection.PendingItem) { 107 + if item.EventURI == "" { 108 + return 109 + } 110 + ev, err := event.Get(ctx, db, item.EventURI) 111 + if err != nil || !ev.IsOngoing(time.Now()) { 112 + return 113 + } 114 + if _, err := checkin.Put(ctx, sess, db, item.EventURI, time.Time{}); err != nil { 115 + slog.Warn("drain middleware: auto-checkin", "did", did.String(), "event_uri", item.EventURI, "err", err) 116 + return 117 + } 118 + slog.Info("drain middleware: auto-checked in via connection", "did", did.String(), "event_uri", item.EventURI) 119 + if _, err := badge.Award(ctx, sess, db, badge.AwardEventAttendee, item.EventURI); err != nil { 120 + slog.Info("drain middleware: event-attendee badge", "err", err) 121 + } 122 + if err := event.IncrementCheckins(ctx, db, item.EventURI); err != nil { 123 + slog.Info("drain middleware: increment checkins", "err", err) 124 + } 125 + if err := event.IncrementConnectors(ctx, db, item.EventURI); err != nil { 126 + slog.Info("drain middleware: increment connectors", "err", err) 127 + } 128 + } 129 + 130 + res, err := connection.Drain(ctx, queue, sess, slog.Default(), onDrain) 99 131 if err != nil { 100 132 slog.Warn("drain middleware: drain failed", "did", did.String(), "err", err) 101 133 dm.reset(did) // allow retry on next request
+1 -1
features/auth/handlers.go
··· 127 127 if h.ConnQueue != nil { 128 128 sess, err := h.OAuth.ResumeSession(r.Context(), sessData.AccountDID, sessData.SessionID) 129 129 if err == nil { 130 - res, err := connection.Drain(r.Context(), h.ConnQueue, sess, slog.Default()) 130 + res, err := connection.Drain(r.Context(), h.ConnQueue, sess, slog.Default(), nil) 131 131 if err != nil { 132 132 slog.Warn("connect drain", "did", sessData.AccountDID.String(), "err", err) 133 133 } else if res.Written > 0 || res.Skipped > 0 {
+6 -1
features/connections/handlers.go
··· 8 8 "errors" 9 9 "log/slog" 10 10 "net/http" 11 + "net/url" 11 12 "sort" 12 13 "strings" 13 14 "time" ··· 303 304 return 304 305 } 305 306 306 - targetStr := chi.URLParam(r, "did") 307 + targetStr, err := url.PathUnescape(chi.URLParam(r, "did")) 308 + if err != nil { 309 + http.Error(w, "invalid DID", http.StatusBadRequest) 310 + return 311 + } 307 312 if _, err := syntax.ParseDID(targetStr); err != nil { 308 313 http.Error(w, "invalid DID", http.StatusBadRequest) 309 314 return
+94 -3
features/settings/handlers.go
··· 10 10 "net/http" 11 11 "strings" 12 12 13 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 13 14 "github.com/bluesky-social/indigo/atproto/identity" 14 15 "github.com/bluesky-social/indigo/atproto/syntax" 15 16 ··· 119 120 } 120 121 121 122 // DeleteData handles POST /settings/delete — deletes all the viewer's notes 122 - // after verifying the handle confirmation field matches. 123 + // after verifying the handle confirmation field matches. If the "purge_pds" 124 + // checkbox is checked, also deletes all quest.atmo.* records from the user's 125 + // PDS and clears local cache tables. 123 126 func (h *Handlers) DeleteData(w http.ResponseWriter, r *http.Request) { 124 - did, _, ok := h.Auth.RequireSession(w, r) 127 + did, sess, ok := h.Auth.RequireSession(w, r) 125 128 if !ok { 126 129 return 127 130 } ··· 133 136 134 137 handle := h.resolveHandle(r, did.String()) 135 138 confirm := strings.TrimSpace(r.FormValue("confirm_handle")) 139 + purgePDS := r.FormValue("purge_pds") == "1" 136 140 137 141 if confirm != handle { 138 142 // Re-render with error. ··· 154 158 http.Error(w, "failed to delete", http.StatusInternalServerError) 155 159 return 156 160 } 161 + slog.Info("settings: deleted notes", "did", did.String(), "count", count) 157 162 158 - slog.Info("settings: deleted notes", "did", did.String(), "count", count) 163 + if purgePDS { 164 + h.purgePDSRecords(r, did, sess) 165 + } 166 + 159 167 http.Redirect(w, r, "/settings?deleted=1", http.StatusSeeOther) 168 + } 169 + 170 + // purgePDSRecords deletes all quest.atmo.* records from the user's PDS 171 + // and clears local cache tables. Best-effort: errors are logged but don't 172 + // block the redirect. 173 + func (h *Handlers) purgePDSRecords(r *http.Request, did syntax.DID, sess *oauth.ClientSession) { 174 + collections := []string{ 175 + "quest.atmo.connection", 176 + "quest.atmo.checkin", 177 + "quest.atmo.badge", 178 + "quest.atmo.event", 179 + "quest.atmo.profile", 180 + } 181 + 182 + apiClient := sess.APIClient() 183 + for _, coll := range collections { 184 + deleted, errors := 0, 0 185 + cursor := "" 186 + for { 187 + params := map[string]any{ 188 + "repo": did.String(), 189 + "collection": coll, 190 + "limit": 100, 191 + } 192 + if cursor != "" { 193 + params["cursor"] = cursor 194 + } 195 + var resp struct { 196 + Records []struct { 197 + URI string `json:"uri"` 198 + } `json:"records"` 199 + Cursor string `json:"cursor,omitempty"` 200 + } 201 + if err := apiClient.Get(r.Context(), syntax.NSID("com.atproto.repo.listRecords"), params, &resp); err != nil { 202 + slog.Warn("settings purge: list", "collection", coll, "err", err) 203 + break 204 + } 205 + for _, rec := range resp.Records { 206 + rkey := rkeyFromURI(rec.URI) 207 + if rkey == "" { 208 + errors++ 209 + continue 210 + } 211 + input := map[string]any{ 212 + "repo": did.String(), 213 + "collection": coll, 214 + "rkey": rkey, 215 + } 216 + if err := apiClient.Post(r.Context(), syntax.NSID("com.atproto.repo.deleteRecord"), input, nil); err != nil { 217 + slog.Warn("settings purge: delete", "uri", rec.URI, "err", err) 218 + errors++ 219 + } else { 220 + deleted++ 221 + } 222 + } 223 + if resp.Cursor == "" || len(resp.Records) == 0 { 224 + break 225 + } 226 + cursor = resp.Cursor 227 + } 228 + if deleted > 0 || errors > 0 { 229 + slog.Info("settings purge", "collection", coll, "deleted", deleted, "errors", errors) 230 + } 231 + } 232 + 233 + // Clear local cache tables. 234 + _, _ = h.DB.ExecContext(r.Context(), `DELETE FROM checkins WHERE did = ?`, did.String()) 235 + _, _ = h.DB.ExecContext(r.Context(), `DELETE FROM pending_connections WHERE target_did = ? OR initiator_did = ?`, did.String(), did.String()) 236 + _, _ = h.DB.ExecContext(r.Context(), `DELETE FROM earned_badges WHERE did = ?`, did.String()) 237 + _, _ = h.DB.ExecContext(r.Context(), `DELETE FROM connection_notes WHERE target_did = ?`, did.String()) 238 + _, _ = h.DB.ExecContext(r.Context(), `DELETE FROM oauth_sessions WHERE did = ?`, did.String()) 239 + _, _ = h.DB.ExecContext(r.Context(), `DELETE FROM users WHERE did = ? AND is_admin = 0`, did.String()) 240 + 241 + slog.Info("settings purge: local cache cleared", "did", did.String()) 242 + } 243 + 244 + // rkeyFromURI extracts the record key (last path segment) from an at:// URI. 245 + func rkeyFromURI(uri string) string { 246 + parts := strings.Split(uri, "/") 247 + if len(parts) == 0 { 248 + return "" 249 + } 250 + return parts[len(parts)-1] 160 251 } 161 252 162 253 // resolveHandle returns the user's atproto handle, falling back to a short
+6 -2
features/settings/pages/settings.templ
··· 40 40 if v.Deleted { 41 41 <div class="settings-success" role="status"> 42 42 <span class="settings-success-icon">✓</span> 43 - all your notes and follow-up flags have been deleted. 43 + your atmo.quest data has been deleted. 44 44 </div> 45 45 } 46 46 ··· 101 101 <div class="settings-row-icon">◇</div> 102 102 <div class="settings-row-body"> 103 103 <div class="settings-row-title">Local notes</div> 104 - <div class="settings-row-desc">stored on this device only</div> 104 + <div class="settings-row-desc">stored in the app</div> 105 105 </div> 106 106 <div class="settings-row-value">{ settingsItoa(v.NoteCount) } notes <span class="arrow">›</span></div> 107 107 </div> ··· 203 203 </div> 204 204 } 205 205 <form method="POST" action="/settings/delete" class="settings-delete-form" style="margin-top:8px;"> 206 + <label class="pv-followup-toggle" style="margin-bottom:8px;display:flex;align-items:center;gap:8px;cursor:pointer;"> 207 + <input type="checkbox" name="purge_pds" value="1"/> 208 + <span class="settings-row-desc" style="margin:0;">also delete all quest.atmo.* records from my PDS</span> 209 + </label> 206 210 <input 207 211 type="text" 208 212 name="confirm_handle"
+4 -4
features/settings/pages/settings_templ.go
··· 76 76 return templ_7745c5c3_Err 77 77 } 78 78 if v.Deleted { 79 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"settings-success\" role=\"status\"><span class=\"settings-success-icon\">✓</span> all your notes and follow-up flags have been deleted.</div>") 79 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"settings-success\" role=\"status\"><span class=\"settings-success-icon\">✓</span> your atmo.quest data has been deleted.</div>") 80 80 if templ_7745c5c3_Err != nil { 81 81 return templ_7745c5c3_Err 82 82 } ··· 168 168 return templ_7745c5c3_Err 169 169 } 170 170 } 171 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div></div><div class=\"settings-group\"><div class=\"settings-group-label\">Privacy</div><div class=\"settings-list\"><div class=\"settings-row\"><div class=\"settings-row-icon\">◆</div><div class=\"settings-row-body\"><div class=\"settings-row-title\">Show me on event attendee lists</div><div class=\"settings-row-desc\">when off, you can still scan and connect</div></div><div class=\"toggle on\"></div></div><div class=\"settings-row\"><div class=\"settings-row-icon\">◇</div><div class=\"settings-row-body\"><div class=\"settings-row-title\">Appear on the leaderboard</div><div class=\"settings-row-desc\">opt out anytime</div></div><div class=\"toggle on\"></div></div></div></div><div class=\"settings-group\"><div class=\"settings-group-label\">Your data</div><div class=\"settings-list\"><a href=\"/settings/export\" class=\"settings-row\" download><div class=\"settings-row-icon\">◆</div><div class=\"settings-row-body\"><div class=\"settings-row-title\">Export connections & notes</div><div class=\"settings-row-desc\">download a CSV summary</div></div><div class=\"settings-row-value\"><span class=\"arrow\">›</span></div></a><div class=\"settings-row\"><div class=\"settings-row-icon\">◇</div><div class=\"settings-row-body\"><div class=\"settings-row-title\">Local notes</div><div class=\"settings-row-desc\">stored on this device only</div></div><div class=\"settings-row-value\">") 171 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div></div><div class=\"settings-group\"><div class=\"settings-group-label\">Privacy</div><div class=\"settings-list\"><div class=\"settings-row\"><div class=\"settings-row-icon\">◆</div><div class=\"settings-row-body\"><div class=\"settings-row-title\">Show me on event attendee lists</div><div class=\"settings-row-desc\">when off, you can still scan and connect</div></div><div class=\"toggle on\"></div></div><div class=\"settings-row\"><div class=\"settings-row-icon\">◇</div><div class=\"settings-row-body\"><div class=\"settings-row-title\">Appear on the leaderboard</div><div class=\"settings-row-desc\">opt out anytime</div></div><div class=\"toggle on\"></div></div></div></div><div class=\"settings-group\"><div class=\"settings-group-label\">Your data</div><div class=\"settings-list\"><a href=\"/settings/export\" class=\"settings-row\" download><div class=\"settings-row-icon\">◆</div><div class=\"settings-row-body\"><div class=\"settings-row-title\">Export connections & notes</div><div class=\"settings-row-desc\">download a CSV summary</div></div><div class=\"settings-row-value\"><span class=\"arrow\">›</span></div></a><div class=\"settings-row\"><div class=\"settings-row-icon\">◇</div><div class=\"settings-row-body\"><div class=\"settings-row-title\">Local notes</div><div class=\"settings-row-desc\">stored in the app</div></div><div class=\"settings-row-value\">") 172 172 if templ_7745c5c3_Err != nil { 173 173 return templ_7745c5c3_Err 174 174 } ··· 261 261 return templ_7745c5c3_Err 262 262 } 263 263 } 264 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<form method=\"POST\" action=\"/settings/delete\" class=\"settings-delete-form\" style=\"margin-top:8px;\"><input type=\"text\" name=\"confirm_handle\" placeholder=\"") 264 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<form method=\"POST\" action=\"/settings/delete\" class=\"settings-delete-form\" style=\"margin-top:8px;\"><label class=\"pv-followup-toggle\" style=\"margin-bottom:8px;display:flex;align-items:center;gap:8px;cursor:pointer;\"><input type=\"checkbox\" name=\"purge_pds\" value=\"1\"> <span class=\"settings-row-desc\" style=\"margin:0;\">also delete all quest.atmo.* records from my PDS</span></label> <input type=\"text\" name=\"confirm_handle\" placeholder=\"") 265 265 if templ_7745c5c3_Err != nil { 266 266 return templ_7745c5c3_Err 267 267 } 268 268 var templ_7745c5c3_Var12 string 269 269 templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("type " + v.Handle + " to confirm") 270 270 if templ_7745c5c3_Err != nil { 271 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/settings/pages/settings.templ`, Line: 209, Col: 53} 271 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/settings/pages/settings.templ`, Line: 213, Col: 53} 272 272 } 273 273 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12) 274 274 if templ_7745c5c3_Err != nil {
+12 -1
internal/connection/drain.go
··· 17 17 Skipped int 18 18 } 19 19 20 + // DrainHook is called after each successfully drained pending connection. 21 + // It gives the caller a chance to perform side-effects like auto-check-in 22 + // without coupling the connection package to the checkin/badge packages. 23 + type DrainHook func(ctx context.Context, sess *oauth.ClientSession, item PendingItem) 24 + 20 25 // Drain flushes every pending_connection row for sess.AccountDID by calling 21 26 // Put for each, then deleting the row on success. Errors on individual rows 22 27 // are logged (if logger is non-nil) and the row is left in the queue for a 23 28 // future retry. 24 29 // 30 + // If onDrain is non-nil it is called after each successfully written + 31 + // deleted pending item, e.g. to auto-check-in the user to the event. 32 + // 25 33 // Returns a DrainResult summarizing the operation; never returns an error 26 34 // from the per-row writes — the goal is best-effort flush on login, not 27 35 // blocking the user's redirect to /profile. 28 36 // 29 37 // A wrapper-level error (e.g. queue lookup failure) is returned as-is. 30 - func Drain(ctx context.Context, q *Queue, sess *oauth.ClientSession, logger *slog.Logger) (DrainResult, error) { 38 + func Drain(ctx context.Context, q *Queue, sess *oauth.ClientSession, logger *slog.Logger, onDrain DrainHook) (DrainResult, error) { 31 39 res := DrainResult{} 32 40 if sess == nil { 33 41 return res, errNoSession ··· 64 72 continue 65 73 } 66 74 res.Written++ 75 + if onDrain != nil { 76 + onDrain(ctx, sess, item) 77 + } 67 78 } 68 79 return res, nil 69 80 }
+1 -1
router/router.go
··· 57 57 58 58 // Drain pending reciprocal connections on every authenticated page load, 59 59 // debounced to once per 30 seconds per user. 60 - router.Use(auth.DrainMiddleware(authH, connQueue, 30*time.Second)) 60 + router.Use(auth.DrainMiddleware(authH, connQueue, conn, 30*time.Second)) 61 61 62 62 if config.Global.Environment == config.Dev { 63 63 setupReload(router)
+1 -1
web/resources/static/js/notes.js
··· 28 28 notes: textarea ? textarea.value : "", 29 29 followUp: checkbox ? checkbox.checked : false 30 30 }; 31 - fetch("/connections/" + encodeURIComponent(targetDID) + "/notes", { 31 + fetch("/connections/" + encodeURI(targetDID) + "/notes", { 32 32 method: "POST", 33 33 credentials: "same-origin", 34 34 headers: { "Content-Type": "application/json" },