From ca87d888ac61ac8add0f8f66443adc0fa1ccafcb Mon Sep 17 00:00:00 2001 From: Brittany Ellich Date: Mon, 1 Jun 2026 07:46:40 -0700 Subject: [PATCH] Create PWA, install tap for listening to connections, update camera functionality --- .env.example | 8 + .tangled/workflows/deploy-tap.yml | 75 +++++ cmd/web/eventimport/main.go | 140 ++++++++ config/config.go | 13 + deploy/tap/README.md | 72 +++++ deploy/tap/fly.toml | 45 +++ docs/atmo-quest-spec.md | 2 +- .../specs/2026-06-01-pwa-design.md | 156 +++++++++ features/auth/drain_middleware.go | 62 ++-- features/auth/handlers.go | 4 +- features/connect/handlers.go | 80 +++-- features/events/handlers.go | 43 ++- features/events/pages/detail.templ | 14 +- features/events/pages/detail_templ.go | 130 ++++---- features/profile/pages/profile.templ | 2 +- features/profile/pages/profile_templ.go | 2 +- features/pwa/handlers.go | 41 +++ features/pwa/handlers_test.go | 91 ++++++ features/pwa/pages/offline.templ | 61 ++++ features/pwa/pages/offline_templ.go | 44 +++ features/pwa/routes.go | 9 + features/tap/consumer.go | 303 ++++++++++++++++++ features/tap/consumer_test.go | 138 ++++++++ go.mod | 1 + go.sum | 2 + internal/checkin/import.go | 60 ++++ internal/checkin/import_test.go | 64 ++++ internal/connection/drain.go | 82 +++-- internal/connection/local.go | 32 ++ internal/connection/local_test.go | 89 +++++ internal/event/import.go | 153 +++++++++ internal/event/import_test.go | 128 ++++++++ internal/users/users.go | 11 + internal/users/users_test.go | 25 ++ router/router.go | 16 + web/resources/static/assets/site.webmanifest | 10 + web/resources/static/js/sw.js | 119 +++++++ web/resources/static_dev.go | 8 + web/resources/static_prod.go | 7 + 39 files changed, 2190 insertions(+), 152 deletions(-) create mode 100644 .tangled/workflows/deploy-tap.yml create mode 100644 cmd/web/eventimport/main.go create mode 100644 deploy/tap/README.md create mode 100644 deploy/tap/fly.toml create mode 100644 docs/superpowers/specs/2026-06-01-pwa-design.md create mode 100644 features/pwa/handlers.go create mode 100644 features/pwa/handlers_test.go create mode 100644 features/pwa/pages/offline.templ create mode 100644 features/pwa/pages/offline_templ.go create mode 100644 features/pwa/routes.go create mode 100644 features/tap/consumer.go create mode 100644 features/tap/consumer_test.go create mode 100644 internal/checkin/import.go create mode 100644 internal/checkin/import_test.go create mode 100644 internal/connection/local_test.go create mode 100644 internal/event/import.go create mode 100644 internal/event/import_test.go create mode 100644 web/resources/static/js/sw.js diff --git a/.env.example b/.env.example index 7f5fc06..d5d0ec0 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,14 @@ PUBLIC_URL=http://127.0.0.1:9090 # SQLite DSN. Default is fine; override if you want a different file. # DATABASE_URL=file:data/atmoquest.db?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON) +# WebSocket URL of a self-hosted Bluesky Tap service for real-time reciprocal +# connections. Run Tap with TAP_SIGNAL_COLLECTION=quest.atmo.connection and add +# tracked DIDs via its /repos/add endpoint. When unset (the default) reciprocity +# falls back to the login-drain queue. Example: ws://127.0.0.1:2480/channel +# TAP_WS_ENDPOINT=ws://127.0.0.1:2480/channel +# Optional bearer token if your Tap deployment requires auth. +# TAP_AUTH_TOKEN= + # --- Production only --- # # Both are auto-generated in dev (random session key per process, on-disk diff --git a/.tangled/workflows/deploy-tap.yml b/.tangled/workflows/deploy-tap.yml new file mode 100644 index 0000000..3104f2b --- /dev/null +++ b/.tangled/workflows/deploy-tap.yml @@ -0,0 +1,75 @@ +# Set up AND deploy the self-hosted Tap service (atmoquest-tap) to Fly.io. +# +# Manual trigger only — run it yourself from the repo's Pipelines page on any +# branch (the `branch` filter has no effect for manual events). It is fully +# idempotent: safe to re-run. It creates the Fly app + volume if missing, +# deploys Tap, and points the main `atmoquest` app at it. +# +# REQUIREMENTS (one-time, in this repo's Tangled settings → secrets): +# FLY_API_TOKEN — an ORG-scoped Fly token (so it can create the app/volume). +# Create with: flyctl tokens create org +# flyctl reads this automatically; do not echo it. +# Optional pipeline environment override (Settings → not secret): +# FLY_ORG — Fly org slug. Auto-detected from the `atmoquest` app if unset. +when: + - event: ["manual"] + +engine: "nixery" + +dependencies: + nixpkgs: + - flyctl + - jq + +steps: + - name: "Set up and deploy Tap" + command: | + set -euo pipefail + + APP=atmoquest-tap + MAIN_APP=atmoquest + REGION=lax + VOLUME=atmoquest_tap_data + + # Resolve the Fly org from the existing main app (override via FLY_ORG). + ORG="${FLY_ORG:-$(flyctl apps list --json \ + | jq -r --arg a "$MAIN_APP" '.[] | select((.Name // .name)==$a) | (.Organization.Slug // .organization.slug)' \ + | head -n1)}" + if [ -z "$ORG" ]; then + echo "ERROR: could not resolve Fly org. Set FLY_ORG in the pipeline environment." >&2 + exit 1 + fi + echo "Using Fly org: $ORG" + + # 1. Create the Tap app if it doesn't already exist. + if flyctl apps list --json | jq -e --arg a "$APP" '.[] | select((.Name // .name)==$a)' >/dev/null; then + echo "App $APP already exists; skipping create." + else + echo "Creating app $APP..." + flyctl apps create "$APP" --org "$ORG" + fi + + # 2. Create the data volume if it doesn't already exist. + if flyctl volumes list -a "$APP" --json | jq -e --arg v "$VOLUME" '.[] | select((.name // .Name)==$v)' >/dev/null 2>&1; then + echo "Volume $VOLUME already exists; skipping create." + else + echo "Creating volume $VOLUME..." + flyctl volumes create "$VOLUME" -a "$APP" --region "$REGION" --size 1 --yes + fi + + # 3. Build + deploy Tap. The Dockerfile go-installs cmd/tap from the + # network and COPYs nothing from the build context, so context dir is + # irrelevant; we run from deploy/tap so fly.toml is picked up. + echo "Deploying Tap..." + ( cd deploy/tap && flyctl deploy --remote-only ) + + # 4. Wire the main app to Tap over the private network — only if not + # already set, since setting a secret restarts atmoquest. + if flyctl secrets list -a "$MAIN_APP" --json | jq -e '.[] | select((.Name // .name)=="TAP_WS_ENDPOINT")' >/dev/null 2>&1; then + echo "TAP_WS_ENDPOINT already set on $MAIN_APP; leaving it." + else + echo "Setting TAP_WS_ENDPOINT on $MAIN_APP..." + flyctl secrets set TAP_WS_ENDPOINT="ws://atmoquest-tap.internal:2480/channel" -a "$MAIN_APP" + fi + + echo "✓ Tap setup complete." diff --git a/cmd/web/eventimport/main.go b/cmd/web/eventimport/main.go new file mode 100644 index 0000000..4faed1e --- /dev/null +++ b/cmd/web/eventimport/main.go @@ -0,0 +1,140 @@ +// Command eventimport searches a repo's PDS for quest.atmo.event records (and +// the matching quest.atmo.checkin records) and adds any that are missing from +// the local app database. +// +// It's meant for pulling an event you created in production (where the record +// lives in your PDS) into a local/dev database that doesn't have it cached yet. +// Your check-ins are imported alongside the events so you still show as +// "checked in" locally. Existing events are left untouched — re-running is safe +// and never changes an already-imported event's QR token. +// +// Usage: +// +// go run ./cmd/web/eventimport -handle you.bsky.social +// go run ./cmd/web/eventimport -did did:plc:xxxx +// go run ./cmd/web/eventimport -handle you.bsky.social -dry-run +// +// The database defaults to the same DATABASE_URL the web server uses; override +// with -dsn if needed. +package main + +import ( + "context" + "flag" + "fmt" + "os" + + "github.com/bluesky-social/indigo/atproto/identity" + "github.com/bluesky-social/indigo/atproto/syntax" + + "atmoquest/config" + "atmoquest/internal/checkin" + "atmoquest/internal/db" + "atmoquest/internal/event" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "eventimport:", err) + os.Exit(1) + } +} + +func run() error { + var ( + identArg = flag.String("handle", "", "handle or DID of the repo to search (alias: -did)") + didArg = flag.String("did", "", "DID of the repo to search (same as -handle, accepts a DID)") + dsn = flag.String("dsn", config.Global.DatabaseURL, "SQLite DSN for the local app database") + dryRun = flag.Bool("dry-run", false, "list what would be imported without writing to the database") + ) + flag.Parse() + + raw := *identArg + if raw == "" { + raw = *didArg + } + if raw == "" { + flag.Usage() + return fmt.Errorf("provide -handle or -did") + } + + ctx := context.Background() + + // Resolve the handle/DID to a DID + PDS host via the identity directory. + atid, err := syntax.ParseAtIdentifier(raw) + if err != nil { + return fmt.Errorf("parse %q as handle or DID: %w", raw, err) + } + ident, err := identity.DefaultDirectory().Lookup(ctx, atid) + if err != nil { + return fmt.Errorf("resolve identity %q: %w", raw, err) + } + pdsHost := ident.PDSEndpoint() + if pdsHost == "" { + return fmt.Errorf("no PDS endpoint found for %s", ident.DID) + } + fmt.Printf("Searching %s (PDS %s) for %s records…\n", ident.DID, pdsHost, event.NSID) + + events, err := event.ListFromPDS(ctx, pdsHost, ident.DID) + if err != nil { + return fmt.Errorf("list events from PDS: %w", err) + } + if len(events) == 0 { + fmt.Println("No events found in that repo.") + return nil + } + fmt.Printf("Found %d event(s) in the PDS.\n", len(events)) + + // The user's own check-ins live in the same repo. We import them too so + // they show as "checked in" locally (the events must be imported first to + // satisfy the checkins -> events foreign key). + checkins, err := checkin.ListFromPDS(ctx, pdsHost, ident.DID) + if err != nil { + return fmt.Errorf("list check-ins from PDS: %w", err) + } + fmt.Printf("Found %d check-in(s) in the PDS.\n", len(checkins)) + + if *dryRun { + for _, ev := range events { + fmt.Printf(" [dry-run] event %s — %s\n", ev.Name, ev.URI) + } + for _, c := range checkins { + fmt.Printf(" [dry-run] checkin %s @ %s\n", c.EventURI, c.CheckedInAt.Format("Jan 2 15:04")) + } + return nil + } + + conn, err := db.Open(*dsn) + if err != nil { + return fmt.Errorf("open db %q: %w", *dsn, err) + } + defer conn.Close() + if err := db.Migrate(conn); err != nil { + return fmt.Errorf("migrate db: %w", err) + } + + var added, skipped int + for _, ev := range events { + token, inserted, err := event.InsertCache(ctx, conn, ev) + if err != nil { + return fmt.Errorf("import %s: %w", ev.URI, err) + } + if inserted { + added++ + fmt.Printf(" + added %q\n uri: %s\n open: /e/%s\n", ev.Name, ev.URI, token) + } else { + skipped++ + fmt.Printf(" = present %q (already in DB, /e/%s)\n", ev.Name, token) + } + } + + // Import check-ins now that the events they reference exist locally. + ciImported, ciSkipped, err := checkin.ImportFromPDS(ctx, conn, ident.DID, checkins) + if err != nil { + return fmt.Errorf("import check-ins: %w", err) + } + + fmt.Printf("\nDone: %d event(s) added, %d already present; %d check-in(s) imported, %d skipped.\n", + added, skipped, ciImported, ciSkipped) + return nil +} diff --git a/config/config.go b/config/config.go index cce183b..432bbc8 100644 --- a/config/config.go +++ b/config/config.go @@ -42,6 +42,17 @@ type Config struct { // OAuth client. Auto-generated on first run if the file is missing. // Ignored when IsLocalhost() is true (loopback dev uses a public client). OAuthPrivateKeyPath string + + // TapWSEndpoint is the WebSocket URL of the self-hosted Bluesky Tap + // service, in collection-signal mode for quest.atmo.connection. When empty + // (the default, incl. dev/tests) the Tap consumer is disabled and + // reciprocity falls back to the login-drain queue. Example: + // ws://127.0.0.1:9000/subscribe + TapWSEndpoint string + + // TapAuthToken is an optional bearer token sent when connecting to Tap. + // Leave empty if Tap is unauthenticated (e.g. on a private network). + TapAuthToken string } var ( @@ -90,6 +101,8 @@ func loadBase() *Config { PublicURL: getEnv("PUBLIC_URL", defaultPublicURL), DatabaseURL: getEnv("DATABASE_URL", "file:data/atmoquest.db?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)&_pragma=busy_timeout(5000)"), OAuthPrivateKeyPath: getEnv("OAUTH_PRIVATE_KEY_PATH", "data/oauth_key.pem"), + TapWSEndpoint: getEnv("TAP_WS_ENDPOINT", ""), + TapAuthToken: getEnv("TAP_AUTH_TOKEN", ""), } } diff --git a/deploy/tap/README.md b/deploy/tap/README.md new file mode 100644 index 0000000..dd44ccd --- /dev/null +++ b/deploy/tap/README.md @@ -0,0 +1,72 @@ +# Self-hosted Tap deploy + +Tap is a separate, always-on service that subscribes to the AT Protocol relay, +verifies + backfills repos, and streams `quest.atmo.connection` record events to +atmoquest over a private WebSocket. atmoquest's consumer +([features/tap/consumer.go](../../features/tap/consumer.go)) writes the +reciprocal connection in real time (or queues it for the target's next login). + +It runs as its **own Fly app** (`atmoquest-tap`), not part of the main +`atmoquest` deploy. atmoquest works fine without it — the consumer is a no-op +unless `TAP_WS_ENDPOINT` is set, and reciprocity falls back to the login-drain +queue. + +## Setup & deploy (Tangled pipeline) + +Everything is done by one **manual** Tangled pipeline: +[deploy-tap.yml](../../.tangled/workflows/deploy-tap.yml). Trigger it from the +repo's Pipelines page (any branch — `manual` ignores the branch filter). It is +idempotent and safe to re-run; it: + +1. creates the `atmoquest-tap` Fly app (if missing), +2. creates the `atmoquest_tap_data` volume (if missing), +3. builds + deploys Tap, +4. sets `TAP_WS_ENDPOINT` on the `atmoquest` app (if not already set). + +**Before the first run**, add one secret in this repo's Tangled settings: + +- `FLY_API_TOKEN` — an **org-scoped** Fly token so the pipeline can create the + app/volume. Create it locally with `flyctl tokens create org ` and + paste the value into Tangled → repo settings → secrets. (An app-scoped deploy + token can deploy but cannot create apps/volumes.) + +Optional: set `FLY_ORG` as a pipeline environment variable if auto-detection +from the `atmoquest` app doesn't find your org. + +Tap runs in collection-signal mode, so it auto-discovers every repo that writes +a `quest.atmo.connection` record — no `/repos/add` calls needed. + +### Doing it by hand instead + +If you'd rather not use the pipeline, the equivalent local commands are: + +```bash +flyctl apps create atmoquest-tap --org +flyctl volumes create atmoquest_tap_data --app atmoquest-tap --region lax --size 1 +( cd deploy/tap && flyctl deploy --remote-only ) +flyctl secrets set TAP_WS_ENDPOINT="ws://atmoquest-tap.internal:2480/channel" --app atmoquest +``` + +Tap rarely changes, so it is intentionally **not** part of the per-push +`atmoquest` deploy — redeploy it via the manual pipeline when you bump the +indigo/Tap version. + +## Keeping versions in sync + +`deploy/tap/Dockerfile`'s `INDIGO_VERSION` **must** match the +`github.com/bluesky-social/indigo` version in [go.mod](../../go.mod) so the Tap +wire format matches what the consumer decodes. Bump both together. + +## Notes + +- **Networking:** Tap has no public service — it's reachable only at + `atmoquest-tap.internal:2480` on Fly's 6PN network. Don't expose it publicly; + `/repos/add` and the event stream are unauthenticated by default. +- **Always-on:** the Fly config defines no services, so the machine never + scale-to-zeroes. Tap keeps consuming the firehose and buffers undelivered + events in its outbox (at-least-once + acks), so reciprocals survive atmoquest + restarts / idle windows. +- **Auth (optional):** if you front Tap with an auth proxy, set a bearer token + on atmoquest with `flyctl secrets set TAP_AUTH_TOKEN=… --app atmoquest`. +- **Relay:** defaults to Bluesky's relay (`relay1.us-east.bsky.network`). + Override with `TAP_RELAY_URL` in `fly.toml` to use a different relay. diff --git a/deploy/tap/fly.toml b/deploy/tap/fly.toml new file mode 100644 index 0000000..fe64d8d --- /dev/null +++ b/deploy/tap/fly.toml @@ -0,0 +1,45 @@ +# Self-hosted Tap — deployed as its OWN Fly app, separate from `atmoquest`. +# +# Deploy with: +# flyctl deploy --config deploy/tap/fly.toml --dockerfile deploy/tap/Dockerfile +# +# This is an internal worker: it has NO public [http_service], so it is only +# reachable over Fly's private 6PN network. atmoquest connects to it at +# ws://atmoquest-tap.internal:2480/channel +# (set as the TAP_WS_ENDPOINT secret on the atmoquest app — see README.md). +# +# With no services defined the machine runs continuously (no scale-to-zero), +# which is what we want: Tap must keep consuming the firehose and buffering +# events in its outbox so nothing is lost while atmoquest is asleep. + +app = "atmoquest-tap" +primary_region = "lax" + +[build] + dockerfile = "Dockerfile" + +[env] + TAP_BIND = ":2480" + TAP_DATABASE_URL = "sqlite:///data/tap.db" + # Collection-signal mode: auto-track every repo that writes a connection + # record. No manual /repos/add needed. + TAP_SIGNAL_COLLECTION = "quest.atmo.connection" + # Keep the in-process identity cache small — we only follow atmo.quest users, + # not the whole network. The default (2M) would blow the VM's memory budget. + RELAY_IDENT_CACHE_SIZE = "100000" + # Defaults left in place (override here if needed): + # TAP_RELAY_URL = "https://relay1.us-east.bsky.network" # Bluesky's relay + # TAP_PLC_URL = "https://plc.directory" + +[[vm]] + memory = "512mb" + cpu_kind = "shared" + cpus = 1 + +# Persistent storage for Tap's SQLite DB (firehose cursor, tracked repos, +# record index, outbox). Reconstructible by re-backfill if lost, so it does +# not need Litestream like the main app DB does. +[[mounts]] + source = "atmoquest_tap_data" + destination = "/data" + initial_size = "1gb" diff --git a/docs/atmo-quest-spec.md b/docs/atmo-quest-spec.md index 4e54c26..571e439 100644 --- a/docs/atmo-quest-spec.md +++ b/docs/atmo-quest-spec.md @@ -116,7 +116,7 @@ Reference files: `app-screens.html`, `design-3-terminal.html` in the design fold ### QR / Connection - Each logged-in user has a QR code that encodes a `bsky://` or `at://` URI pointing to their profile -- Home screen shows your avatar; tap to flip to QR +- Home screen shows your avatar; tap image to show QR code to QR - Scanning a QR: - **Both users logged in:** writes a `quest.atmo.connection` record to each user's repo - **Scanner is a guest:** saves the connection to local storage with a "claim me later" flag diff --git a/docs/superpowers/specs/2026-06-01-pwa-design.md b/docs/superpowers/specs/2026-06-01-pwa-design.md new file mode 100644 index 0000000..a2ee58e --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-pwa-design.md @@ -0,0 +1,156 @@ +# Design: Progressive Web App (minimum-installable + offline) + +**Date:** 2026-06-01 +**Status:** Approved, pending implementation plan + +## Summary + +Make atmo.quest installable and offline-resilient. The app already ships a +linked web manifest, the required icons, HTTPS in production, and a responsive +layout — the missing piece is a service worker. This adds a production-only +service worker with strict fetch rules, runtime caching of fingerprinted +static assets, network-first caching of viewed content pages, a branded +offline fallback page, and a hardened manifest. + +Install uses the **browser's native prompt** (no custom install UI in v1). + +## Goals / non-goals + +**Goals:** installable PWA; fast repeat loads (cached static shell); limited +offline viewing of recently-visited pages; branded offline fallback; passing +Lighthouse PWA "installable" + "works offline". + +**Non-goals (explicitly out of scope for v1):** web push notifications; +full offline data/sync; a custom in-app install button; offline mutations. + +## Components + +| Piece | Location | Purpose | +|---|---|---| +| Service worker | `web/resources/static/js/sw.js` | Caching + offline logic | +| PWA feature package | `features/pwa/` (routes.go, handlers.go, pages/) | Owns `GET /sw.js` and `GET /offline`, mirroring the `about` feature pattern | +| `GET /sw.js` | `features/pwa` | Serve the SW **raw** at a stable root URL | +| `GET /offline` + page | `features/pwa` (`pages/offline.templ`) | Self-contained offline fallback | +| Route wiring | `router/router.go` | `pwa.SetupRoutes(router)` | +| Registration snippet | `features/common/layouts/base.templ` | Register SW, **production only** | +| Manifest hardening | `web/resources/static/assets/site.webmanifest` + `base.templ` | start_url/scope/id/description/maskable + meta tag | + +## Serving the service worker + +A service worker only controls URLs at or below its own path, so it must be +served from the origin root, not `/static/...`. It also must have a **stable** +URL, so it bypasses the `hashfs` content-fingerprinting used for other static +assets. + +- `GET /sw.js` reads `js/sw.js` from the embedded static FS (raw bytes, not via + the `hashfs` fingerprinting file server) and serves it with: + - `Content-Type: application/javascript` + - `Service-Worker-Allowed: /` + - `Cache-Control: no-cache` (so SW updates are picked up promptly). + +## Registration (production only) + +`base.templ` includes an inline registration script guarded by +`config.Global.Environment != config.Dev`: + +```js +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => + navigator.serviceWorker.register('/sw.js')); +} +``` + +In dev, `air` live-reload, templ regeneration, and the `/reload` SSE stream +would fight a caching SW, so the SW is never registered there. (Doc note: to +clear a SW after testing a prod build locally, unregister via +`chrome://serviceworker-internals` or DevTools → Application.) + +## Caching strategy & fetch rules + +Caches (versioned via a `CACHE_VERSION` constant): +- `static-v` — fingerprinted static assets, cache-first. +- `pages-v` — runtime cache of viewed content pages, network-first. +- The offline page (`/offline`) is precached on `install`. + +The SW handles **only same-origin GET** requests. All other requests pass +through untouched. Handled requests, in order: + +1. **Bypass (no intercept):** requests with `Accept: text/event-stream` (the + `/reload` dev stream and the event live-progress SSE), OAuth/auth routes + (`/oauth/*`, `/signin`), and any non-GET method. +2. **`/static/*`** → **cache-first** into `static-v`. Safe because these URLs + are content-hash fingerprinted (a changed asset gets a new URL). +3. **Navigations** (`request.mode === 'navigate'`) to safe content routes + (`/`, `/events/*`, `/profile*`, `/connections*`) → **network-first**: try + the network, cache a clone into `pages-v` on success, fall back to the + cached copy when offline, then to `/offline` if nothing is cached. +4. **Any other navigation** with no usable cache → `/offline`. + +Network-first for HTML means online users always receive fresh server-rendered +markup; cached HTML is only ever served when genuinely offline. + +## Offline fallback page + +`GET /offline` renders a minimal templ page that: +- requires no session and starts no SSE; +- is **self-contained** (inline-styled) so it renders correctly even if the + external CSS bundle isn't cached yet; +- explains the user is offline and offers a retry/back link. + +## Cross-user safety (accepted trade-off) + +Caching viewed authed pages means that, offline on a shared device, a +signed-out or different user could see a previously cached page. Accepted as +low-likelihood for a personal-device conference app. Mitigations: +- network-first while online (cached HTML only shows when truly offline); +- the SW clears `pages-v` when it observes a logout navigation + (`/oauth/logout`). + +## Manifest hardening + +Add to `site.webmanifest`: `start_url: "/"`, `scope: "/"`, `id: "/"`, +`description`, and a `purpose: "maskable"` icon entry (reusing the 512px icon). +Add `` to `base.templ`. + +## Lifecycle / updates + +- `install`: precache `/offline`; `self.skipWaiting()`. +- `activate`: delete caches whose names don't match the current + `CACHE_VERSION`; `clients.claim()`. +- Bumping `CACHE_VERSION` is the single switch for invalidating all caches. + Safe because static assets are fingerprinted, so stale references still + resolve from the network. + +## Testing + +**Go tests (automated):** +- `GET /sw.js` → 200, `Content-Type: application/javascript`, + `Service-Worker-Allowed: /`, body is the SW source, and the path is stable + (no hash in the URL). +- `GET /offline` → 200 and renders without a session (no auth redirect). +- `site.webmanifest` parses as JSON and contains `name`, `start_url`, `scope`, + `display`, and at least one `192` and one `512` icon. + +**Manual verification (no JS test harness in this repo):** +- DevTools → Application → Service Workers: SW registers on a prod build. +- Offline toggle: a visited page loads from cache; an unvisited page shows + `/offline`; SSE/live features degrade without breaking the page. +- Lighthouse PWA audit: installable + works-offline pass. +- Install on Android/Chrome and iOS ("Add to Home Screen"); app launches + standalone with correct name/icon/theme color. + +## Files touched + +- `web/resources/static/js/sw.js` — new service worker. +- `features/pwa/routes.go` + `features/pwa/handlers.go` — `GET /sw.js` (raw, + stable, via the embedded static FS) and `GET /offline`. +- `features/pwa/pages/offline.templ` (+ regenerated `_templ.go`) — the + self-contained offline page. +- `router/router.go` — wire `pwa.SetupRoutes(router)`. +- `features/common/layouts/base.templ` — prod-only registration script + + `mobile-web-app-capable` meta. +- `web/resources/static/assets/site.webmanifest` — hardened fields. +- `features/pwa/handlers_test.go` — `/sw.js`, `/offline`, and manifest + assertions. + +No new dependencies; no esbuild changes (the SW is hand-authored, plain JS). diff --git a/features/auth/drain_middleware.go b/features/auth/drain_middleware.go index 32b96b2..c6278a0 100644 --- a/features/auth/drain_middleware.go +++ b/features/auth/drain_middleware.go @@ -78,6 +78,41 @@ func (d *drainState) reset(did syntax.DID) { delete(d.last, did.String()) } +// AutoCheckinDrainHook builds a connection.DrainHook that, when a drained +// reciprocal connection references an ongoing event, checks the draining user +// into that event, awards the event-attendee badge, and bumps event stats. +// +// Shared by the login-drain middleware, OAuthCallback, and the Tap consumer so +// every reciprocity path produces identical event-check-in side-effects. This +// is what makes check-in bidirectional: whoever ends up with the reciprocal +// record also lands as an attendee of the event it references. +func AutoCheckinDrainHook(db *sql.DB) connection.DrainHook { + return func(ctx context.Context, sess *oauth.ClientSession, item connection.PendingItem) { + if item.EventURI == "" { + return + } + did := sess.Data.AccountDID + ev, err := event.Get(ctx, db, item.EventURI) + if err != nil || !ev.IsOngoing(time.Now()) { + return + } + if _, err := checkin.Put(ctx, sess, db, item.EventURI, time.Time{}); err != nil { + slog.Warn("auto-checkin hook: checkin", "did", did.String(), "event_uri", item.EventURI, "err", err) + return + } + slog.Info("auto-checkin hook: checked in via connection", "did", did.String(), "event_uri", item.EventURI) + if _, err := badge.Award(ctx, sess, db, badge.AwardEventAttendee, item.EventURI); err != nil { + slog.Info("auto-checkin hook: event-attendee badge", "err", err) + } + if err := event.IncrementCheckins(ctx, db, item.EventURI); err != nil { + slog.Info("auto-checkin hook: increment checkins", "err", err) + } + if err := event.IncrementConnectors(ctx, db, item.EventURI); err != nil { + slog.Info("auto-checkin hook: increment connectors", "err", err) + } + } +} + // drainAsync runs the queue drain in a background goroutine so it doesn't // block the page render. Uses a fresh context because the originating HTTP // request will be done by the time this runs. @@ -102,30 +137,9 @@ func drainAsync(authH *Handlers, queue *connection.Queue, db *sql.DB, did syntax // Auto-check-in hook: when a drained connection references an ongoing // event, check the user into that event so both parties end up as - // attendees regardless of who scanned whose QR. - onDrain := func(ctx context.Context, sess *oauth.ClientSession, item connection.PendingItem) { - if item.EventURI == "" { - return - } - ev, err := event.Get(ctx, db, item.EventURI) - if err != nil || !ev.IsOngoing(time.Now()) { - return - } - if _, err := checkin.Put(ctx, sess, db, item.EventURI, time.Time{}); err != nil { - slog.Warn("drain middleware: auto-checkin", "did", did.String(), "event_uri", item.EventURI, "err", err) - return - } - slog.Info("drain middleware: auto-checked in via connection", "did", did.String(), "event_uri", item.EventURI) - if _, err := badge.Award(ctx, sess, db, badge.AwardEventAttendee, item.EventURI); err != nil { - slog.Info("drain middleware: event-attendee badge", "err", err) - } - if err := event.IncrementCheckins(ctx, db, item.EventURI); err != nil { - slog.Info("drain middleware: increment checkins", "err", err) - } - if err := event.IncrementConnectors(ctx, db, item.EventURI); err != nil { - slog.Info("drain middleware: increment connectors", "err", err) - } - } + // attendees regardless of who scanned whose QR. Shared with the Tap + // consumer via AutoCheckinDrainHook. + onDrain := AutoCheckinDrainHook(db) // Look up the user's PDS for dedup checks. pdsHost := "https://bsky.social" diff --git a/features/auth/handlers.go b/features/auth/handlers.go index bd72137..12e7c1f 100644 --- a/features/auth/handlers.go +++ b/features/auth/handlers.go @@ -176,7 +176,7 @@ func (h *Handlers) OAuthCallback(w http.ResponseWriter, r *http.Request) { if h.ConnQueue != nil { sess, err := h.OAuth.ResumeSession(r.Context(), sessData.AccountDID, sessData.SessionID) if err == nil { - res, err := connection.Drain(r.Context(), h.ConnQueue, sess, sess.Data.HostURL, slog.Default(), nil) + res, err := connection.Drain(r.Context(), h.ConnQueue, sess, sess.Data.HostURL, slog.Default(), AutoCheckinDrainHook(h.DB)) if err != nil { slog.Warn("connect drain", "did", sessData.AccountDID.String(), "err", err) } else if res.Written > 0 || res.Skipped > 0 { @@ -451,5 +451,3 @@ func (h *Handlers) LocalLogout(w http.ResponseWriter, r *http.Request) { h.Sessions.ClearLocal(w) http.Redirect(w, r, "/", http.StatusFound) } - - diff --git a/features/connect/handlers.go b/features/connect/handlers.go index ee026a2..6267ddf 100644 --- a/features/connect/handlers.go +++ b/features/connect/handlers.go @@ -103,7 +103,13 @@ func (h *Handlers) Connect(w http.ResponseWriter, r *http.Request) { identity := h.Auth.ResolveIdentity(r) if !identity.IsAuth { - http.Redirect(w, r, "/signin?next="+r.URL.Path, http.StatusFound) + // Anonymous visitor: render the connect page so its [data-queue-did] + // marker stashes this target in localStorage. profile.js POSTs the + // stash to /connect/flush-local after the user signs in (local or + // ATProto), so the connection survives the login round-trip. We render + // instead of redirecting to /signin because the OAuth flow drops the + // `next` param, which would otherwise lose the connection. + h.renderConnectPage(w, r, target) return } @@ -123,12 +129,13 @@ func (h *Handlers) Connect(w http.ResponseWriter, r *http.Request) { return } - // Auto-detect if the target is currently at an ongoing event. - var eventURI string - if evURI, ok, err := checkin.Current(r.Context(), h.DB, target); err == nil && ok { - if ev, err := event.Get(r.Context(), h.DB, evURI); err == nil && ev.IsOngoing(time.Now()) { - eventURI = evURI - } + // Auto-detect the ongoing event to link this connection to. Prefer the + // target's current event; if they're not at one, fall back to the + // viewer's. This makes check-in bidirectional: whoever is at the event, + // scanning either QR ties the connection to it and checks both parties in. + eventURI := h.ongoingEventURI(r.Context(), target.String()) + if eventURI == "" { + eventURI = h.ongoingEventURI(r.Context(), viewerDID.String()) } // Write the viewer's connection record (with event linkage if applicable). @@ -205,12 +212,11 @@ func (h *Handlers) connectLocalToATProto(w http.ResponseWriter, r *http.Request, } } - // Auto-detect if target is at an ongoing event - var eventURI string - if evURI, ok, err := checkin.Current(r.Context(), h.DB, target); err == nil && ok { - if ev, err := event.Get(r.Context(), h.DB, evURI); err == nil && ev.IsOngoing(time.Now()) { - eventURI = evURI - } + // Auto-detect the ongoing event: prefer the target's, fall back to the + // local viewer's, so check-in works regardless of who scanned whom. + eventURI := h.ongoingEventURI(r.Context(), target.String()) + if eventURI == "" { + eventURI = h.ongoingEventURI(r.Context(), localID) } // Write local connection @@ -300,12 +306,11 @@ func (h *Handlers) ConnectLocal(w http.ResponseWriter, r *http.Request) { slog.Warn("connect local: write connection", "err", err) } - // Auto-checkin if target is at an ongoing event - var eventURI string - if evURI, ok, err := checkin.CurrentForLocal(r.Context(), h.DB, targetLocalID); err == nil && ok { - if ev, err := event.Get(r.Context(), h.DB, evURI); err == nil && ev.IsOngoing(time.Now()) { - eventURI = evURI - } + // Auto-checkin if either party is at an ongoing event. Prefer the + // target's event, fall back to the viewer's, so scanning works both ways. + eventURI := h.ongoingEventURI(r.Context(), targetLocalID) + if eventURI == "" { + eventURI = h.ongoingEventURI(r.Context(), viewerID) } if eventURI != "" { if err := checkin.PutLocal(r.Context(), h.DB, viewerID, eventURI, time.Time{}); err == nil { @@ -344,6 +349,29 @@ func (h *Handlers) writeLocalConnection(ctx context.Context, viewerDID, viewerLo return connection.WriteLocal(ctx, h.DB, viewerDID, viewerLocalID, targetDID, targetLocalID, eventURI) } +// renderConnectPage renders the connect page for an anonymous visitor who +// scanned an ATProto user's QR. The page's [data-queue-did] marker stashes the +// target in localStorage; profile.js flushes it to /connect/flush-local after +// sign-in. Target profile fields are best-effort from the local users cache — +// a miss just shows the DID, which is fine for the stash flow. +func (h *Handlers) renderConnectPage(w http.ResponseWriter, r *http.Request, target syntax.DID) { + var displayName, bio string + _ = h.DB.QueryRowContext(r.Context(), + `SELECT COALESCE(display_name, ''), COALESCE(bio, '') FROM users WHERE did = ?`, + target.String()).Scan(&displayName, &bio) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := pages.Connect(pages.ConnectView{ + TargetDID: target.String(), + TargetDisplayName: displayName, + TargetBio: bio, + ViewerLoggedIn: false, + SelfConnect: false, + }).Render(r.Context(), w); err != nil { + slog.Error("render connect", "err", err) + } +} + // renderConnectLocalPage renders the connect page for local user targets. func (h *Handlers) renderConnectLocalPage(w http.ResponseWriter, r *http.Request, targetLocalID string, viewerLoggedIn bool) { // Fetch target profile from users table @@ -512,6 +540,20 @@ func connectURLLocal(localID string) string { return strings.TrimRight(config.Global.PublicURL, "/") + "/c/l/" + localID } +// ongoingEventURI returns the at-uri of the ongoing event the given identity +// is currently checked into, or "" if none. Accepts both ATProto DIDs and +// local_xxx IDs (the underlying checkin query keys on the string id). +func (h *Handlers) ongoingEventURI(ctx context.Context, id string) string { + evURI, ok, err := checkin.CurrentForLocal(ctx, h.DB, id) + if err != nil || !ok { + return "" + } + if ev, err := event.Get(ctx, h.DB, evURI); err == nil && ev.IsOngoing(time.Now()) { + return evURI + } + return "" +} + // lookupPDSForDID returns the PDS host for the given DID, using whatever we // can find without a network roundtrip. v1: if we have a session row for the // DID, use its HostURL; otherwise fall back to bsky.social. diff --git a/features/events/handlers.go b/features/events/handlers.go index d57b50d..63b5f54 100644 --- a/features/events/handlers.go +++ b/features/events/handlers.go @@ -7,6 +7,7 @@ import ( "net/http" "sort" "strconv" + "strings" "time" "github.com/bluesky-social/indigo/atproto/identity" @@ -193,6 +194,15 @@ func (h *Handlers) Detail(w http.ResponseWriter, r *http.Request) { for _, c := range viewerConns { connSet[c.With.String()] = true } + // Connections involving local accounts never reach the viewer's PDS, so + // fold in the viewer's local_connections targets too. Without this, a + // local attendee the viewer has already met would wrongly appear under + // "unmet". + if localTargets, err := connection.ListLocalTargets(r.Context(), h.DB, viewerDID.String()); err == nil { + for _, t := range localTargets { + connSet[t] = true + } + } // Build full attendee list (unfiltered) for leaderboard + enrichment. type enrichedAttendee struct { @@ -211,6 +221,24 @@ func (h *Handlers) Detail(w http.ResponseWriter, r *http.Request) { connCounts := make(map[string]*connCount) for _, didStr := range attendeeDIDs { + // Local-account attendees have no PDS. Their "local_…" id fails + // syntax.ParseDID, so they must be handled before the parse below — + // otherwise they'd be silently dropped from every attendee list. + // Skip the PDS-dependent enrichment (Bluesky profile, connection + // counting) and pull their name/handle from the users table instead. + if strings.HasPrefix(didStr, "local_") { + name, handle := users.NameAndHandle(r.Context(), h.DB, didStr) + allAttendees = append(allAttendees, enrichedAttendee{ + attendee: pages.Attendee{ + DID: didStr, + Connected: connSet[didStr] || didStr == viewerDID.String(), + Handle: handle, + DisplayName: name, + }, + }) + continue + } + did, err := syntax.ParseDID(didStr) if err != nil { continue @@ -345,9 +373,13 @@ func (h *Handlers) Detail(w http.ResponseWriter, r *http.Request) { if statsThreshold < 1 && milestoneBase > 0 { statsThreshold = 1 } - leaderboardUnlocked := stats.UniqueConnectors >= leaderboardThreshold - interestUnlocked := stats.UniqueConnectors >= interestThreshold - statsUnlocked := stats.UniqueConnectors >= statsThreshold + // Unlocks are gated on how many people have signed in (unique attendees), + // not how many connections have been made. This matches the progress bar, + // which also measures actual check-ins against the attendee thresholds. + actualAttendees := len(attendeeDIDs) + leaderboardUnlocked := actualAttendees >= leaderboardThreshold + interestUnlocked := actualAttendees >= interestThreshold + statsUnlocked := actualAttendees >= statsThreshold // Interest matching: find attendees with shared interests (if unlocked). var interestMatches []pages.InterestMatch @@ -733,12 +765,13 @@ func (h *Handlers) Live(w http.ResponseWriter, r *http.Request) { if mMatching < 1 && milestoneBase > 0 { mMatching = 1 } + // Milestones are reached based on attendees signed in, not connections. rLeaderboard := "" rMatching := "" - if stats.UniqueConnectors >= mLeaderboard { + if totalAttendees >= mLeaderboard { rLeaderboard = " reached" } - if stats.UniqueConnectors >= mMatching { + if totalAttendees >= mMatching { rMatching = " reached" } progressHTML := fmt.Sprintf( diff --git a/features/events/pages/detail.templ b/features/events/pages/detail.templ index f262f06..d3de92a 100644 --- a/features/events/pages/detail.templ +++ b/features/events/pages/detail.templ @@ -28,10 +28,10 @@ type EventDetailView struct { Page int // current page (1-based) TotalPages int // total number of pages Leaderboard []LeaderboardRow - LeaderboardUnlocked bool // true when UniqueConnectors >= 5% of TotalAttendees + LeaderboardUnlocked bool // true when ActualCheckins >= 5% of TotalAttendees InterestMatches []InterestMatch - InterestUnlocked bool // true when UniqueConnectors >= 10% of TotalAttendees - StatsUnlocked bool // true when UniqueConnectors >= 20% of TotalAttendees + InterestUnlocked bool // true when ActualCheckins >= 10% of TotalAttendees + StatsUnlocked bool // true when ActualCheckins >= 20% of TotalAttendees // Past-event summary (nil if not yet computed or event is still live). Summary *EventSummary // Personal stats for the viewer at this event. @@ -152,6 +152,8 @@ templ EventDetail(v EventDetailView) { + @layouts.CameraScanButton() + if len(v.Links) > 0 { @@ -204,7 +206,7 @@ templ EventDetail(v EventDetailView) {
◇
Interest matching
-
{ remaining(milestoneMatching(v.TotalAttendees), v.UniqueConnectors) } more to unlock
+
{ remaining(milestoneMatching(v.TotalAttendees), v.ActualCheckins) } more to unlock
locked
@@ -223,7 +225,7 @@ templ EventDetail(v EventDetailView) {
◇
Event statistics
-
{ remaining(milestoneStats(v.TotalAttendees), v.UniqueConnectors) } more to unlock
+
{ remaining(milestoneStats(v.TotalAttendees), v.ActualCheckins) } more to unlock
locked
diff --git a/features/events/pages/detail_templ.go b/features/events/pages/detail_templ.go index 965fe2b..645df7f 100644 --- a/features/events/pages/detail_templ.go +++ b/features/events/pages/detail_templ.go @@ -36,10 +36,10 @@ type EventDetailView struct { Page int // current page (1-based) TotalPages int // total number of pages Leaderboard []LeaderboardRow - LeaderboardUnlocked bool // true when UniqueConnectors >= 5% of TotalAttendees + LeaderboardUnlocked bool // true when ActualCheckins >= 5% of TotalAttendees InterestMatches []InterestMatch - InterestUnlocked bool // true when UniqueConnectors >= 10% of TotalAttendees - StatsUnlocked bool // true when UniqueConnectors >= 20% of TotalAttendees + InterestUnlocked bool // true when ActualCheckins >= 10% of TotalAttendees + StatsUnlocked bool // true when ActualCheckins >= 20% of TotalAttendees // Past-event summary (nil if not yet computed or event is still live). Summary *EventSummary // Personal stats for the viewer at this event. @@ -350,6 +350,10 @@ func EventDetail(v EventDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + templ_7745c5c3_Err = layouts.CameraScanButton().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } if len(v.Links) > 0 { templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") if templ_7745c5c3_Err != nil { @@ -363,7 +367,7 @@ func EventDetail(v EventDetailView) templ.Component { var templ_7745c5c3_Var15 templ.SafeURL templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL(l.URL)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 158, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 160, Col: 57} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { @@ -376,7 +380,7 @@ func EventDetail(v EventDetailView) templ.Component { var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(eventLinkText(l)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 159, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 161, Col: 57} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { @@ -417,9 +421,9 @@ func EventDetail(v EventDetailView) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var17 string - templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(remaining(milestoneLeaderboard(v.TotalAttendees), v.UniqueConnectors)) + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(remaining(milestoneLeaderboard(v.TotalAttendees), v.ActualCheckins)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 188, Col: 105} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 190, Col: 103} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { @@ -441,9 +445,9 @@ func EventDetail(v EventDetailView) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var18 string - templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(remaining(milestoneMatching(v.TotalAttendees), v.UniqueConnectors)) + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(remaining(milestoneMatching(v.TotalAttendees), v.ActualCheckins)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 207, Col: 102} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 209, Col: 100} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) if templ_7745c5c3_Err != nil { @@ -465,9 +469,9 @@ func EventDetail(v EventDetailView) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var19 string - templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(remaining(milestoneStats(v.TotalAttendees), v.UniqueConnectors)) + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(remaining(milestoneStats(v.TotalAttendees), v.ActualCheckins)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 226, Col: 99} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 228, Col: 97} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) if templ_7745c5c3_Err != nil { @@ -530,7 +534,7 @@ func EventDetail(v EventDetailView) templ.Component { var templ_7745c5c3_Var21 templ.SafeURL templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/events/" + v.Token)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 259, Col: 53} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 261, Col: 53} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) if templ_7745c5c3_Err != nil { @@ -565,7 +569,7 @@ func EventDetail(v EventDetailView) templ.Component { var templ_7745c5c3_Var24 templ.SafeURL templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/events/" + v.Token + "?filter=mine")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 260, Col: 70} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 262, Col: 70} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) if templ_7745c5c3_Err != nil { @@ -600,7 +604,7 @@ func EventDetail(v EventDetailView) templ.Component { var templ_7745c5c3_Var27 templ.SafeURL templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/events/" + v.Token + "?filter=unmet")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 261, Col: 71} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 263, Col: 71} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) if templ_7745c5c3_Err != nil { @@ -651,7 +655,7 @@ func EventDetail(v EventDetailView) templ.Component { var templ_7745c5c3_Var29 templ.SafeURL templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(attendeesPageURL(v.Token, v.Filter, v.Page-1))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 273, Col: 80} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 275, Col: 80} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) if templ_7745c5c3_Err != nil { @@ -669,7 +673,7 @@ func EventDetail(v EventDetailView) templ.Component { var templ_7745c5c3_Var30 string templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(v.Page)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 275, Col: 64} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 277, Col: 64} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) if templ_7745c5c3_Err != nil { @@ -682,7 +686,7 @@ func EventDetail(v EventDetailView) templ.Component { var templ_7745c5c3_Var31 string templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(v.TotalPages)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 275, Col: 90} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 277, Col: 90} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) if templ_7745c5c3_Err != nil { @@ -700,7 +704,7 @@ func EventDetail(v EventDetailView) templ.Component { var templ_7745c5c3_Var32 templ.SafeURL templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(attendeesPageURL(v.Token, v.Filter, v.Page+1))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 277, Col: 80} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 279, Col: 80} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) if templ_7745c5c3_Err != nil { @@ -768,7 +772,7 @@ func eventSummarySection(v EventDetailView) templ.Component { var templ_7745c5c3_Var34 string templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(v.ViewerConnections)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 306, Col: 61} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 308, Col: 61} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) if templ_7745c5c3_Err != nil { @@ -791,7 +795,7 @@ func eventSummarySection(v EventDetailView) templ.Component { var templ_7745c5c3_Var35 templ.SafeURL templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/connections/" + f.DID)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 315, Col: 53} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 317, Col: 53} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) if templ_7745c5c3_Err != nil { @@ -805,7 +809,7 @@ func eventSummarySection(v EventDetailView) templ.Component { var templ_7745c5c3_Var36 string templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(f.DisplayName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 318, Col: 23} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 320, Col: 23} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) if templ_7745c5c3_Err != nil { @@ -815,7 +819,7 @@ func eventSummarySection(v EventDetailView) templ.Component { var templ_7745c5c3_Var37 string templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs("@" + f.Handle) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 320, Col: 24} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 322, Col: 24} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) if templ_7745c5c3_Err != nil { @@ -825,7 +829,7 @@ func eventSummarySection(v EventDetailView) templ.Component { var templ_7745c5c3_Var38 string templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(shortDID(f.DID)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 322, Col: 25} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 324, Col: 25} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) if templ_7745c5c3_Err != nil { @@ -844,7 +848,7 @@ func eventSummarySection(v EventDetailView) templ.Component { var templ_7745c5c3_Var39 string templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(f.Notes) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 326, Col: 51} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 328, Col: 51} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) if templ_7745c5c3_Err != nil { @@ -873,7 +877,7 @@ func eventSummarySection(v EventDetailView) templ.Component { var templ_7745c5c3_Var40 string templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(v.Summary.TotalConnections)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 338, Col: 69} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 340, Col: 69} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) if templ_7745c5c3_Err != nil { @@ -886,7 +890,7 @@ func eventSummarySection(v EventDetailView) templ.Component { var templ_7745c5c3_Var41 string templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(v.Summary.TotalCheckins)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 342, Col: 66} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 344, Col: 66} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) if templ_7745c5c3_Err != nil { @@ -899,7 +903,7 @@ func eventSummarySection(v EventDetailView) templ.Component { var templ_7745c5c3_Var42 string templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(v.Summary.NewSignups)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 346, Col: 63} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 348, Col: 63} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) if templ_7745c5c3_Err != nil { @@ -917,7 +921,7 @@ func eventSummarySection(v EventDetailView) templ.Component { var templ_7745c5c3_Var43 string templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(v.Summary.PeakHour) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 353, Col: 52} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 355, Col: 52} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) if templ_7745c5c3_Err != nil { @@ -945,7 +949,7 @@ func eventSummarySection(v EventDetailView) templ.Component { var templ_7745c5c3_Var44 string templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(h.Hour) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 362, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 364, Col: 45} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) if templ_7745c5c3_Err != nil { @@ -958,7 +962,7 @@ func eventSummarySection(v EventDetailView) templ.Component { var templ_7745c5c3_Var45 string templ_7745c5c3_Var45, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("width:" + barPct(h.Count, maxHourlyCount(v.Summary.ConnectionsByHour)) + "%") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 363, Col: 119} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 365, Col: 119} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) if templ_7745c5c3_Err != nil { @@ -971,7 +975,7 @@ func eventSummarySection(v EventDetailView) templ.Component { var templ_7745c5c3_Var46 string templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(h.Count)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 364, Col: 51} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 366, Col: 51} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) if templ_7745c5c3_Err != nil { @@ -1004,7 +1008,7 @@ func eventSummarySection(v EventDetailView) templ.Component { var templ_7745c5c3_Var47 string templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(ic.Interest) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 376, Col: 50} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 378, Col: 50} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) if templ_7745c5c3_Err != nil { @@ -1017,7 +1021,7 @@ func eventSummarySection(v EventDetailView) templ.Component { var templ_7745c5c3_Var48 string templ_7745c5c3_Var48, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("width:" + barPct(ic.Count, maxInterestCount(v.Summary.InterestDistribution)) + "%") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 377, Col: 132} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 379, Col: 132} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48)) if templ_7745c5c3_Err != nil { @@ -1030,7 +1034,7 @@ func eventSummarySection(v EventDetailView) templ.Component { var templ_7745c5c3_Var49 string templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(ic.Count)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 378, Col: 52} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 380, Col: 52} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) if templ_7745c5c3_Err != nil { @@ -1088,7 +1092,7 @@ func attendeeCard(a Attendee, token string) templ.Component { var templ_7745c5c3_Var51 templ.SafeURL templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/connections/" + a.DID)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 393, Col: 49} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 395, Col: 49} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51)) if templ_7745c5c3_Err != nil { @@ -1101,7 +1105,7 @@ func attendeeCard(a Attendee, token string) templ.Component { var templ_7745c5c3_Var52 string templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.ResolveAttributeValue(a.DisplayName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 393, Col: 95} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 395, Col: 95} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var52) if templ_7745c5c3_Err != nil { @@ -1137,7 +1141,7 @@ func attendeeCard(a Attendee, token string) templ.Component { var templ_7745c5c3_Var55 string templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.ResolveAttributeValue(a.AvatarURL) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 397, Col: 21} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 399, Col: 21} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var55) if templ_7745c5c3_Err != nil { @@ -1150,7 +1154,7 @@ func attendeeCard(a Attendee, token string) templ.Component { var templ_7745c5c3_Var56 string templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.ResolveAttributeValue(a.DisplayName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 398, Col: 23} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 400, Col: 23} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var56) if templ_7745c5c3_Err != nil { @@ -1192,7 +1196,7 @@ func attendeeCard(a Attendee, token string) templ.Component { var templ_7745c5c3_Var59 string templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(a.DisplayName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 406, Col: 19} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 408, Col: 19} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59)) if templ_7745c5c3_Err != nil { @@ -1202,7 +1206,7 @@ func attendeeCard(a Attendee, token string) templ.Component { var templ_7745c5c3_Var60 string templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs("@" + a.Handle) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 408, Col: 20} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 410, Col: 20} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60)) if templ_7745c5c3_Err != nil { @@ -1212,7 +1216,7 @@ func attendeeCard(a Attendee, token string) templ.Component { var templ_7745c5c3_Var61 string templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(shortDID(a.DID)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 410, Col: 21} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 412, Col: 21} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61)) if templ_7745c5c3_Err != nil { @@ -1277,7 +1281,7 @@ func leaderboardRow(row LeaderboardRow) templ.Component { var templ_7745c5c3_Var65 string templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(row.Rank)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 418, Col: 87} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 420, Col: 87} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65)) if templ_7745c5c3_Err != nil { @@ -1313,7 +1317,7 @@ func leaderboardRow(row LeaderboardRow) templ.Component { var templ_7745c5c3_Var68 string templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.ResolveAttributeValue(row.AvatarURL) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 420, Col: 101} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 422, Col: 101} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var68) if templ_7745c5c3_Err != nil { @@ -1326,7 +1330,7 @@ func leaderboardRow(row LeaderboardRow) templ.Component { var templ_7745c5c3_Var69 string templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(row.DisplayName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 420, Col: 125} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 422, Col: 125} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69) if templ_7745c5c3_Err != nil { @@ -1368,7 +1372,7 @@ func leaderboardRow(row LeaderboardRow) templ.Component { var templ_7745c5c3_Var72 string templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(row.DisplayName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 426, Col: 21} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 428, Col: 21} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72)) if templ_7745c5c3_Err != nil { @@ -1378,7 +1382,7 @@ func leaderboardRow(row LeaderboardRow) templ.Component { var templ_7745c5c3_Var73 string templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinStringErrs("@" + row.Handle) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 428, Col: 22} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 430, Col: 22} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73)) if templ_7745c5c3_Err != nil { @@ -1388,7 +1392,7 @@ func leaderboardRow(row LeaderboardRow) templ.Component { var templ_7745c5c3_Var74 string templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinStringErrs(shortDID(row.DID)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 430, Col: 23} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 432, Col: 23} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74)) if templ_7745c5c3_Err != nil { @@ -1402,7 +1406,7 @@ func leaderboardRow(row LeaderboardRow) templ.Component { var templ_7745c5c3_Var75 string templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(row.ConnectionCount)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 433, Col: 61} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 435, Col: 61} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75)) if templ_7745c5c3_Err != nil { @@ -1467,7 +1471,7 @@ func interestMatchCard(m InterestMatch) templ.Component { var templ_7745c5c3_Var79 string templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.AvatarURL) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 441, Col: 101} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 443, Col: 101} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var79) if templ_7745c5c3_Err != nil { @@ -1480,7 +1484,7 @@ func interestMatchCard(m InterestMatch) templ.Component { var templ_7745c5c3_Var80 string templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.DisplayName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 441, Col: 123} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 443, Col: 123} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80) if templ_7745c5c3_Err != nil { @@ -1522,7 +1526,7 @@ func interestMatchCard(m InterestMatch) templ.Component { var templ_7745c5c3_Var83 string templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.JoinStringErrs(m.DisplayName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 447, Col: 20} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 449, Col: 20} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var83)) if templ_7745c5c3_Err != nil { @@ -1532,7 +1536,7 @@ func interestMatchCard(m InterestMatch) templ.Component { var templ_7745c5c3_Var84 string templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.JoinStringErrs("@" + m.Handle) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 449, Col: 21} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 451, Col: 21} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var84)) if templ_7745c5c3_Err != nil { @@ -1542,7 +1546,7 @@ func interestMatchCard(m InterestMatch) templ.Component { var templ_7745c5c3_Var85 string templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinStringErrs(shortDID(m.DID)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 451, Col: 22} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 453, Col: 22} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85)) if templ_7745c5c3_Err != nil { @@ -1571,7 +1575,7 @@ func interestMatchCard(m InterestMatch) templ.Component { var templ_7745c5c3_Var86 string templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(interest) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 460, Col: 55} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 462, Col: 55} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86)) if templ_7745c5c3_Err != nil { @@ -1618,7 +1622,7 @@ func eventDetailProgress(checkins int, totalAttendees int) templ.Component { var templ_7745c5c3_Var88 string templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.ResolveAttributeValue("Progress: " + itoa(checkins) + " attendees checked in") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 467, Col: 121} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 469, Col: 121} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var88) if templ_7745c5c3_Err != nil { @@ -1636,7 +1640,7 @@ func eventDetailProgress(checkins int, totalAttendees int) templ.Component { var templ_7745c5c3_Var89 string templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(checkins)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 471, Col: 48} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 473, Col: 48} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89)) if templ_7745c5c3_Err != nil { @@ -1654,7 +1658,7 @@ func eventDetailProgress(checkins int, totalAttendees int) templ.Component { var templ_7745c5c3_Var90 string templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(checkins)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 473, Col: 48} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 475, Col: 48} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var90)) if templ_7745c5c3_Err != nil { @@ -1667,7 +1671,7 @@ func eventDetailProgress(checkins int, totalAttendees int) templ.Component { var templ_7745c5c3_Var91 string templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(totalAttendees)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 473, Col: 90} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 475, Col: 90} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var91)) if templ_7745c5c3_Err != nil { @@ -1685,7 +1689,7 @@ func eventDetailProgress(checkins int, totalAttendees int) templ.Component { var templ_7745c5c3_Var92 string templ_7745c5c3_Var92, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("width:" + connectPct(checkins, totalAttendees) + "%") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 477, Col: 95} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 479, Col: 95} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92)) if templ_7745c5c3_Err != nil { @@ -1742,7 +1746,7 @@ func eventDetailProgress(checkins int, totalAttendees int) templ.Component { var templ_7745c5c3_Var97 string templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(milestoneLeaderboard(totalAttendees))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 482, Col: 48} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 484, Col: 48} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var97)) if templ_7745c5c3_Err != nil { @@ -1777,7 +1781,7 @@ func eventDetailProgress(checkins int, totalAttendees int) templ.Component { var templ_7745c5c3_Var100 string templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(milestoneMatching(totalAttendees))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 485, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 487, Col: 45} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var100)) if templ_7745c5c3_Err != nil { @@ -1812,7 +1816,7 @@ func eventDetailProgress(checkins int, totalAttendees int) templ.Component { var templ_7745c5c3_Var103 string templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(milestoneStats(totalAttendees))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 488, Col: 42} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 490, Col: 42} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103)) if templ_7745c5c3_Err != nil { @@ -1825,7 +1829,7 @@ func eventDetailProgress(checkins int, totalAttendees int) templ.Component { var templ_7745c5c3_Var104 string templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(totalAttendees)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 490, Col: 52} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 492, Col: 52} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104)) if templ_7745c5c3_Err != nil { diff --git a/features/profile/pages/profile.templ b/features/profile/pages/profile.templ index 8ce6d1c..dadc47c 100644 --- a/features/profile/pages/profile.templ +++ b/features/profile/pages/profile.templ @@ -114,7 +114,7 @@ templ Profile(v ProfileView) { }
-

▸ tap to flip

+

▸ tap image to show QR code

@layouts.CameraScanButton() diff --git a/features/profile/pages/profile_templ.go b/features/profile/pages/profile_templ.go index 244d3ba..1519ea9 100644 --- a/features/profile/pages/profile_templ.go +++ b/features/profile/pages/profile_templ.go @@ -238,7 +238,7 @@ func Profile(v ProfileView) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "

▸ tap to flip

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "

▸ tap image to show QR code

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/features/pwa/handlers.go b/features/pwa/handlers.go new file mode 100644 index 0000000..62091b9 --- /dev/null +++ b/features/pwa/handlers.go @@ -0,0 +1,41 @@ +// Package pwa serves the progressive-web-app endpoints: the service worker +// (at a stable root URL) and the offline fallback page. It's scope-minimal and +// has no dependencies beyond the embedded static assets, so SetupRoutes takes +// just the router. +package pwa + +import ( + "log/slog" + "net/http" + + "atmoquest/features/pwa/pages" + "atmoquest/web/resources" +) + +// ServeServiceWorker serves /sw.js. The service worker must be served from the +// origin root (its scope is limited to its own path) at a stable, +// non-fingerprinted URL, so it bypasses the hashfs file server and is read +// raw from the static assets. Service-Worker-Allowed lets it claim "/" scope. +func ServeServiceWorker(w http.ResponseWriter, r *http.Request) { + body, err := resources.ReadStatic("js/sw.js") + if err != nil { + slog.Error("pwa: read sw.js", "err", err) + http.Error(w, "service worker unavailable", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + w.Header().Set("Service-Worker-Allowed", "/") + // Keep the registration script fresh so SW updates are picked up promptly; + // the SW's own cache logic handles asset caching. + w.Header().Set("Cache-Control", "no-cache") + _, _ = w.Write(body) +} + +// ServeOffline renders the offline fallback page. No session required — it's +// the page shown precisely when the app can't reach the server. +func ServeOffline(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := pages.Offline().Render(r.Context(), w); err != nil { + slog.Error("pwa: render offline", "err", err) + } +} diff --git a/features/pwa/handlers_test.go b/features/pwa/handlers_test.go new file mode 100644 index 0000000..4e9d5a1 --- /dev/null +++ b/features/pwa/handlers_test.go @@ -0,0 +1,91 @@ +package pwa + +import ( + "encoding/json" + "net/http/httptest" + "strings" + "testing" + + "atmoquest/web/resources" +) + +func TestServeServiceWorker(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/sw.js", nil) + + ServeServiceWorker(rec, req) + + if rec.Code != 200 { + t.Fatalf("status = %d, want 200", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/javascript") { + t.Errorf("Content-Type = %q, want application/javascript", ct) + } + if got := rec.Header().Get("Service-Worker-Allowed"); got != "/" { + t.Errorf("Service-Worker-Allowed = %q, want /", got) + } + body := rec.Body.String() + if !strings.Contains(body, "CACHE_VERSION") || !strings.Contains(body, "addEventListener") { + t.Errorf("body does not look like the service worker source: %.80q", body) + } +} + +func TestServeOffline(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/offline", nil) + + ServeOffline(rec, req) + + if rec.Code != 200 { + t.Fatalf("status = %d, want 200 (offline page must not redirect/auth)", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { + t.Errorf("Content-Type = %q, want text/html", ct) + } + if !strings.Contains(rec.Body.String(), "you're offline") { + t.Errorf("offline page missing expected copy") + } +} + +func TestManifestValid(t *testing.T) { + raw, err := resources.ReadStatic("assets/site.webmanifest") + if err != nil { + t.Fatalf("read manifest: %v", err) + } + var m struct { + Name string `json:"name"` + StartURL string `json:"start_url"` + Scope string `json:"scope"` + Display string `json:"display"` + Icons []struct { + Sizes string `json:"sizes"` + } `json:"icons"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("manifest is not valid JSON: %v", err) + } + if m.Name == "" { + t.Error("manifest missing name") + } + if m.StartURL != "/" { + t.Errorf("start_url = %q, want /", m.StartURL) + } + if m.Scope != "/" { + t.Errorf("scope = %q, want /", m.Scope) + } + if m.Display != "standalone" { + t.Errorf("display = %q, want standalone", m.Display) + } + var has192, has512 bool + for _, ic := range m.Icons { + switch ic.Sizes { + case "192x192": + has192 = true + case "512x512": + has512 = true + } + } + if !has192 || !has512 { + t.Errorf("manifest missing 192 or 512 icon (192=%v 512=%v)", has192, has512) + } +} diff --git a/features/pwa/pages/offline.templ b/features/pwa/pages/offline.templ new file mode 100644 index 0000000..9e54e36 --- /dev/null +++ b/features/pwa/pages/offline.templ @@ -0,0 +1,61 @@ +package pages + +// Offline is the service worker's navigation fallback. It is intentionally +// self-contained — inline styles, no external CSS/JS, no session, no SSE — so +// it renders correctly even when the network (and the rest of the app shell) +// is unavailable. +templ Offline() { + + + + + offline · atmo.quest + + + + +
+ +

you're offline

+

+ atmo.quest can't reach the network right now. pages you've already + visited may still work — otherwise, reconnect and try again. +

+
+ + ← home +
+
+ + +} diff --git a/features/pwa/pages/offline_templ.go b/features/pwa/pages/offline_templ.go new file mode 100644 index 0000000..388e80b --- /dev/null +++ b/features/pwa/pages/offline_templ.go @@ -0,0 +1,44 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +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" + +// Offline is the service worker's navigation fallback. It is intentionally +// self-contained — inline styles, no external CSS/JS, no session, no SSE — so +// it renders correctly even when the network (and the rest of the app shell) +// is unavailable. +func Offline() 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "offline · atmo.quest
⚡

you're offline

atmo.quest can't reach the network right now. pages you've already visited may still work — otherwise, reconnect and try again.

← home
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/features/pwa/routes.go b/features/pwa/routes.go new file mode 100644 index 0000000..8e39afc --- /dev/null +++ b/features/pwa/routes.go @@ -0,0 +1,9 @@ +package pwa + +import "github.com/go-chi/chi/v5" + +// SetupRoutes registers the PWA endpoints at the origin root. +func SetupRoutes(router chi.Router) { + router.Get("/sw.js", ServeServiceWorker) + router.Get("/offline", ServeOffline) +} diff --git a/features/tap/consumer.go b/features/tap/consumer.go new file mode 100644 index 0000000..b7ea7ac --- /dev/null +++ b/features/tap/consumer.go @@ -0,0 +1,303 @@ +// Package tap consumes events from a self-hosted Bluesky Tap service to make +// reciprocal connection creation robust and near-real-time. +// +// Background: when person A connects with person B, B's reciprocal +// quest.atmo.connection record can only be written with B's PDS credentials. +// Historically that happened lazily, when B next logged in and the +// pending_connections queue was drained. This consumer subscribes to Tap in +// collection-signal mode for quest.atmo.connection and reacts the moment any +// such record is created anywhere on the network — including connections this +// server instance didn't mediate (e.g. made on another device): +// +// - if B already has a stored OAuth session, write B's reciprocal now; +// - otherwise fall back to the existing pending_connections queue so it's +// still created on B's next login. +// +// Tap handles the relay subscription, cryptographic verification, backfill, +// cursor management, and at-least-once delivery, so this consumer only has to +// decode JSON, resolve a session, and call connection.WriteReciprocal. +// +// Wire protocol (indigo cmd/tap): connect to WS /channel; each message is a +// JSON object {id, type, record:{...}}; in the default websocket-ack mode the +// client acks each handled event with {"type":"ack","id":}. +package tap + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/bluesky-social/indigo/atproto/auth/oauth" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/gorilla/websocket" + + "atmoquest/internal/connection" +) + +// connectionNSID is the collection Tap is signalled on and the only one this +// consumer acts upon. +const connectionNSID = "quest.atmo.connection" + +// Consumer subscribes to a Tap WebSocket channel and replicates reciprocal +// connections. Construct it with NewConsumer and run it with Run. +type Consumer struct { + db *sql.DB + oauth *oauth.ClientApp + queue *connection.Queue + endpoint string + token string + // onWrite fires after a reciprocal is written (or found to already exist) + // so the target also gets checked in to any ongoing event the connection + // references. Wire this to auth.AutoCheckinDrainHook so the side-effects + // match the login-drain path. May be nil. + onWrite connection.DrainHook + logger *slog.Logger +} + +// NewConsumer wires a Tap consumer. endpoint is the Tap WebSocket URL +// (e.g. ws://127.0.0.1:2480/channel); token is an optional bearer token. +func NewConsumer(db *sql.DB, oauthApp *oauth.ClientApp, queue *connection.Queue, endpoint, token string, onWrite connection.DrainHook, logger *slog.Logger) *Consumer { + if logger == nil { + logger = slog.Default() + } + return &Consumer{ + db: db, + oauth: oauthApp, + queue: queue, + endpoint: endpoint, + token: token, + onWrite: onWrite, + logger: logger, + } +} + +// Run connects to Tap and processes events until ctx is cancelled, reconnecting +// with capped exponential backoff on any connection error. It is intended to be +// launched in its own goroutine. +func (c *Consumer) Run(ctx context.Context) { + const ( + minBackoff = 1 * time.Second + maxBackoff = 30 * time.Second + ) + backoff := minBackoff + c.logger.Info("tap consumer: starting", "endpoint", c.endpoint) + for { + if ctx.Err() != nil { + return + } + err := c.runOnce(ctx) + if ctx.Err() != nil { + return + } + if err != nil { + c.logger.Warn("tap consumer: connection ended, will reconnect", "backoff", backoff, "err", err) + } + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + } + if err == nil { + backoff = minBackoff + } else { + backoff *= 2 + if backoff > maxBackoff { + backoff = maxBackoff + } + } + } +} + +// runOnce dials Tap and pumps events for the life of a single connection. +func (c *Consumer) runOnce(ctx context.Context) error { + var header http.Header + if c.token != "" { + header = http.Header{"Authorization": {"Bearer " + c.token}} + } + + dialCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + conn, resp, err := websocket.DefaultDialer.DialContext(dialCtx, c.endpoint, header) + if err != nil { + if resp != nil { + return fmt.Errorf("dial tap (%s): %w", resp.Status, err) + } + return fmt.Errorf("dial tap: %w", err) + } + defer conn.Close() + c.logger.Info("tap consumer: connected", "endpoint", c.endpoint) + + // Close the socket when the context is cancelled so the blocking + // ReadMessage below returns and the goroutine can exit. + go func() { + <-ctx.Done() + _ = conn.Close() + }() + + for { + _, data, err := conn.ReadMessage() + if err != nil { + return fmt.Errorf("read: %w", err) + } + var evt wsEvent + if err := json.Unmarshal(data, &evt); err != nil { + c.logger.Debug("tap consumer: undecodable message", "err", err) + continue + } + // Handle the event, then ack so Tap advances its cursor. If handling + // fails we deliberately skip the ack so Tap re-delivers later. + if err := c.handleEvent(ctx, evt); err != nil { + c.logger.Warn("tap consumer: handle event", "id", evt.ID, "err", err) + continue + } + if err := conn.WriteJSON(wsAck{Type: "ack", ID: evt.ID}); err != nil { + return fmt.Errorf("ack: %w", err) + } + } +} + +// handleEvent dispatches a single Tap event. Only create operations on +// quest.atmo.connection records are acted upon; everything else is a no-op +// (and gets acked) so the cursor keeps advancing. +func (c *Consumer) handleEvent(ctx context.Context, evt wsEvent) error { + rec := evt.Record + if rec == nil || evt.Type != "record" { + return nil + } + if rec.Collection != connectionNSID || rec.Action != "create" { + return nil + } + + initiator, target, eventURI, ok := parseConnectionEvent(rec) + if !ok { + // Malformed record (missing/invalid did or with) — nothing we can do. + return nil + } + + item := connection.PendingItem{ + TargetDID: target, + InitiatorDID: initiator, + EventURI: eventURI, + } + + // Fast path: the target already has a stored session — write now. + sess, pdsHost, err := c.resumeTargetSession(ctx, target) + if err == nil && sess != nil { + written, werr := connection.WriteReciprocal(ctx, sess, c.db, item, pdsHost) + if werr != nil { + // Transient write failure: fall back to the durable queue so it's + // retried on the target's next login rather than lost. + c.logger.Warn("tap consumer: reciprocal write failed, enqueuing", "target", target.String(), "err", werr) + return c.enqueue(ctx, item) + } + if written { + c.logger.Info("tap consumer: wrote reciprocal", "target", target.String(), "initiator", initiator.String(), "event", eventURI) + } + if c.onWrite != nil { + c.onWrite(ctx, sess, item) + } + return nil + } + + // No session for the target. Only enqueue if we know about them, so we + // don't accumulate dead rows for the entire network's connections. + if !c.knownUser(ctx, target) { + return nil + } + return c.enqueue(ctx, item) +} + +// enqueue records the reciprocal in pending_connections for the next login drain. +func (c *Consumer) enqueue(ctx context.Context, item connection.PendingItem) error { + if err := c.queue.Enqueue(ctx, item.TargetDID, item.InitiatorDID, item.EventURI); err != nil { + return fmt.Errorf("enqueue reciprocal: %w", err) + } + c.logger.Debug("tap consumer: enqueued reciprocal", "target", item.TargetDID.String(), "initiator", item.InitiatorDID.String()) + return nil +} + +// resumeTargetSession looks up the most recent stored OAuth session for did and +// resumes it. Returns (nil, "", nil) when the target has no stored session. +func (c *Consumer) resumeTargetSession(ctx context.Context, did syntax.DID) (*oauth.ClientSession, string, error) { + var sid, host string + err := c.db.QueryRowContext(ctx, ` + SELECT session_id, COALESCE(data ->> 'host_url', '') + FROM oauth_sessions + WHERE did = ? + ORDER BY updated_at DESC + LIMIT 1 + `, did.String()).Scan(&sid, &host) + if errors.Is(err, sql.ErrNoRows) { + return nil, "", nil + } + if err != nil { + return nil, "", fmt.Errorf("lookup session: %w", err) + } + sess, err := c.oauth.ResumeSession(ctx, did, sid) + if err != nil { + return nil, "", fmt.Errorf("resume session: %w", err) + } + if host == "" { + host = sess.Data.HostURL + } + return sess, host, nil +} + +// knownUser reports whether did has a row in the users table — i.e. someone +// who has interacted with this app and might still log in. +func (c *Consumer) knownUser(ctx context.Context, did syntax.DID) bool { + var x string + err := c.db.QueryRowContext(ctx, `SELECT did FROM users WHERE did = ? LIMIT 1`, did.String()).Scan(&x) + return err == nil +} + +// parseConnectionEvent extracts the initiator (record author), target (the +// `with` field), and optional event URI from a connection record event. +// Returns ok=false when required fields are missing or self-referential. +func parseConnectionEvent(rec *recordEvt) (initiator, target syntax.DID, eventURI string, ok bool) { + initiator, err := syntax.ParseDID(rec.Did) + if err != nil { + return "", "", "", false + } + withStr, _ := rec.Record["with"].(string) + target, err = syntax.ParseDID(withStr) + if err != nil { + return "", "", "", false + } + if initiator == target { + return "", "", "", false + } + eventURI, _ = rec.Record["event"].(string) + return initiator, target, eventURI, true +} + +// wsEvent mirrors the JSON Tap emits on its /channel WebSocket +// (indigo cmd/tap MarshallableEvt). Only the record variant is decoded. +type wsEvent struct { + ID uint `json:"id"` + Type string `json:"type"` + Record *recordEvt `json:"record,omitempty"` +} + +// recordEvt mirrors indigo cmd/tap RecordEvt. +type recordEvt struct { + Live bool `json:"live"` + Did string `json:"did"` + Rev string `json:"rev"` + Collection string `json:"collection"` + Rkey string `json:"rkey"` + Action string `json:"action"` + Record map[string]any `json:"record,omitempty"` + Cid string `json:"cid,omitempty"` +} + +// wsAck is the acknowledgement message sent back per handled event. +type wsAck struct { + Type string `json:"type"` + ID uint `json:"id"` +} diff --git a/features/tap/consumer_test.go b/features/tap/consumer_test.go new file mode 100644 index 0000000..878a85b --- /dev/null +++ b/features/tap/consumer_test.go @@ -0,0 +1,138 @@ +package tap + +import ( + "context" + "encoding/json" + "testing" +) + +func TestParseConnectionEvent(t *testing.T) { + const ( + didA = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" + didB = "did:plc:bbbbbbbbbbbbbbbbbbbbbbbb" + evt = "at://did:plc:org/quest.atmo.event/3kabc" + ) + + tests := []struct { + name string + rec *recordEvt + wantOK bool + wantInit string + wantTarget string + wantEventURI string + }{ + { + name: "valid without event", + rec: &recordEvt{Did: didA, Record: map[string]any{"with": didB}}, + wantOK: true, + wantInit: didA, + wantTarget: didB, + }, + { + name: "valid with event", + rec: &recordEvt{Did: didA, Record: map[string]any{"with": didB, "event": evt}}, + wantOK: true, + wantInit: didA, + wantTarget: didB, + wantEventURI: evt, + }, + { + name: "missing with", + rec: &recordEvt{Did: didA, Record: map[string]any{}}, + wantOK: false, + }, + { + name: "self connection", + rec: &recordEvt{Did: didA, Record: map[string]any{"with": didA}}, + wantOK: false, + }, + { + name: "invalid author did", + rec: &recordEvt{Did: "not-a-did", Record: map[string]any{"with": didB}}, + wantOK: false, + }, + { + name: "with is not a string", + rec: &recordEvt{Did: didA, Record: map[string]any{"with": 123}}, + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + init, target, eventURI, ok := parseConnectionEvent(tt.rec) + if ok != tt.wantOK { + t.Fatalf("ok = %v, want %v", ok, tt.wantOK) + } + if !ok { + return + } + if init.String() != tt.wantInit { + t.Errorf("initiator = %q, want %q", init, tt.wantInit) + } + if target.String() != tt.wantTarget { + t.Errorf("target = %q, want %q", target, tt.wantTarget) + } + if eventURI != tt.wantEventURI { + t.Errorf("eventURI = %q, want %q", eventURI, tt.wantEventURI) + } + }) + } +} + +// TestHandleEvent_IgnoresIrrelevant verifies that events we don't care about +// are no-ops that never touch the DB or session resolution (db is nil here, so +// any DB access would panic). This guards the cheap pre-filter in handleEvent. +func TestHandleEvent_IgnoresIrrelevant(t *testing.T) { + c := &Consumer{} // nil db/oauth/queue — must not be reached + + cases := []wsEvent{ + {ID: 1, Type: "identity"}, + {ID: 2, Type: "record", Record: &recordEvt{Collection: "app.bsky.feed.post", Action: "create"}}, + {ID: 3, Type: "record", Record: &recordEvt{Collection: connectionNSID, Action: "delete"}}, + {ID: 4, Type: "record", Record: nil}, + } + for _, evt := range cases { + if err := c.handleEvent(context.Background(), evt); err != nil { + t.Errorf("handleEvent(%+v) = %v, want nil", evt, err) + } + } +} + +// TestWireDecode confirms the struct tags match Tap's MarshallableEvt JSON so a +// real event off the wire decodes into the fields handleEvent reads. +func TestWireDecode(t *testing.T) { + raw := `{ + "id": 42, + "type": "record", + "record": { + "live": true, + "did": "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa", + "rev": "3kxyz", + "collection": "quest.atmo.connection", + "rkey": "3kabc", + "action": "create", + "record": {"$type": "quest.atmo.connection", "with": "did:plc:bbbbbbbbbbbbbbbbbbbbbbbb", "event": "at://x/quest.atmo.event/1"}, + "cid": "bafy" + } + }` + var evt wsEvent + if err := json.Unmarshal([]byte(raw), &evt); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if evt.ID != 42 || evt.Type != "record" || evt.Record == nil { + t.Fatalf("envelope mismatch: %+v", evt) + } + if evt.Record.Collection != connectionNSID || evt.Record.Action != "create" { + t.Fatalf("record header mismatch: %+v", evt.Record) + } + init, target, eventURI, ok := parseConnectionEvent(evt.Record) + if !ok { + t.Fatal("parseConnectionEvent ok = false") + } + if init.String() != "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" || + target.String() != "did:plc:bbbbbbbbbbbbbbbbbbbbbbbb" || + eventURI != "at://x/quest.atmo.event/1" { + t.Fatalf("parsed fields mismatch: %s %s %s", init, target, eventURI) + } +} diff --git a/go.mod b/go.mod index 8da4af2..ab49aa6 100644 --- a/go.mod +++ b/go.mod @@ -77,6 +77,7 @@ require ( github.com/google/go-querystring v1.1.0 // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/gorilla/securecookie v1.1.2 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/go.sum b/go.sum index 3391a2d..30a5a01 100644 --- a/go.sum +++ b/go.sum @@ -231,6 +231,8 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hairyhenderson/go-codeowners v0.5.0 h1:dpQB+hVHiRc2VVvc2BHxkuM+tmu9Qej/as3apqUbsWc= github.com/hairyhenderson/go-codeowners v0.5.0/go.mod h1:R3uW1OQXEj2Gu6/OvZ7bt6hr0qdkLvUWPiqNaWnexpo= github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= diff --git a/internal/checkin/import.go b/internal/checkin/import.go new file mode 100644 index 0000000..c7efc85 --- /dev/null +++ b/internal/checkin/import.go @@ -0,0 +1,60 @@ +package checkin + +import ( + "context" + "database/sql" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +// ImportFromPDS caches a set of quest.atmo.checkin records (as returned by +// ListFromPDS) into the local checkins table for did, so the user reads as +// checked in locally. +// +// Check-ins whose referenced event isn't cached locally are skipped: the +// checkins.event_uri foreign key requires the event row to exist, and we only +// know about events that have been imported. Idempotent — rows that already +// exist (by record_uri) are left untouched. +// +// Returns how many rows were newly imported and how many were skipped (event +// not present locally, already cached, or malformed). +func ImportFromPDS(ctx context.Context, db *sql.DB, did syntax.DID, entries []PDSEntry) (imported, skipped int, err error) { + for _, e := range entries { + if e.RecordURI == "" || e.EventURI == "" { + skipped++ + continue + } + + // The FK requires the event to be cached locally first. + var exists int + scanErr := db.QueryRowContext(ctx, `SELECT 1 FROM events WHERE uri = ?`, e.EventURI).Scan(&exists) + if scanErr == sql.ErrNoRows { + skipped++ + continue + } + if scanErr != nil { + return imported, skipped, scanErr + } + + at := e.CheckedInAt + if at.IsZero() { + at = time.Now().UTC() + } + + res, execErr := db.ExecContext(ctx, ` + INSERT INTO checkins (record_uri, did, event_uri, checked_in_at, cached_at) + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(record_uri) DO NOTHING + `, e.RecordURI, did.String(), e.EventURI, at.UTC()) + if execErr != nil { + return imported, skipped, execErr + } + if n, _ := res.RowsAffected(); n > 0 { + imported++ + } else { + skipped++ // already cached + } + } + return imported, skipped, nil +} diff --git a/internal/checkin/import_test.go b/internal/checkin/import_test.go new file mode 100644 index 0000000..e909fee --- /dev/null +++ b/internal/checkin/import_test.go @@ -0,0 +1,64 @@ +package checkin + +import ( + "testing" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +func TestImportFromPDS_ImportsCheckinsForKnownEvents(t *testing.T) { + ctx, db := newTestDB(t) + now := time.Now() + evURI := "at://did:plc:eventorganizer/quest.atmo.event/3lkabc" + seedEvent(t, db, evURI, now.Add(-2*time.Hour), now.Add(6*time.Hour)) + + did := syntax.DID("did:plc:eventorganizer") + entries := []PDSEntry{ + {RecordURI: "at://did:plc:eventorganizer/quest.atmo.checkin/aaa", EventURI: evURI, CheckedInAt: now}, + // References an event we didn't import — must be skipped (FK requirement). + {RecordURI: "at://did:plc:eventorganizer/quest.atmo.checkin/bbb", EventURI: "at://did:plc:other/quest.atmo.event/zzz", CheckedInAt: now}, + } + + imported, skipped, err := ImportFromPDS(ctx, db, did, entries) + if err != nil { + t.Fatalf("ImportFromPDS: %v", err) + } + if imported != 1 { + t.Errorf("imported = %d, want 1", imported) + } + if skipped != 1 { + t.Errorf("skipped = %d, want 1 (event not imported)", skipped) + } + + // The user should now read as checked into the ongoing event. + gotURI, ok, err := Current(ctx, db, did) + if err != nil || !ok { + t.Fatalf("Current: ok=%v err=%v", ok, err) + } + if gotURI != evURI { + t.Errorf("Current event = %q, want %q", gotURI, evURI) + } +} + +func TestImportFromPDS_Idempotent(t *testing.T) { + ctx, db := newTestDB(t) + now := time.Now() + evURI := "at://did:plc:eventorganizer/quest.atmo.event/3lkabc" + seedEvent(t, db, evURI, now.Add(-2*time.Hour), now.Add(6*time.Hour)) + + did := syntax.DID("did:plc:eventorganizer") + entries := []PDSEntry{{RecordURI: "at://x/quest.atmo.checkin/aaa", EventURI: evURI, CheckedInAt: now}} + + if imported, _, err := ImportFromPDS(ctx, db, did, entries); err != nil || imported != 1 { + t.Fatalf("first import: imported=%d err=%v", imported, err) + } + + imported, skipped, err := ImportFromPDS(ctx, db, did, entries) + if err != nil { + t.Fatalf("second import: %v", err) + } + if imported != 0 || skipped != 1 { + t.Errorf("re-import: imported=%d skipped=%d, want 0/1", imported, skipped) + } +} diff --git a/internal/connection/drain.go b/internal/connection/drain.go index 208e752..e6f7b70 100644 --- a/internal/connection/drain.go +++ b/internal/connection/drain.go @@ -2,6 +2,7 @@ package connection import ( "context" + "database/sql" "log/slog" "github.com/bluesky-social/indigo/atproto/auth/oauth" @@ -49,34 +50,7 @@ func Drain(ctx context.Context, q *Queue, sess *oauth.ClientSession, pdsHost str return res, err } for _, item := range items { - // Dedup: skip if the user already has this connection on their PDS. - if pdsHost != "" && HasConnection(ctx, pdsHost, target, item.InitiatorDID, item.EventURI) { - if logger != nil { - logger.Debug("connection drain: duplicate skipped", - "target", target.String(), - "initiator", item.InitiatorDID.String(), - "event", item.EventURI, - ) - } - // Write bookmark for this known connection - if q.db != nil { - viewerDID := target.String() - _, _ = q.db.ExecContext(ctx, ` - INSERT INTO connection_notes (viewer_did, target_did, notes, follow_up, updated_at) - VALUES (?, ?, '', 0, CURRENT_TIMESTAMP) - ON CONFLICT(viewer_did, target_did) DO NOTHING - `, viewerDID, item.InitiatorDID.String()) - } - // Delete the queue row — no need to retry. - _ = q.Delete(ctx, item.ID) - res.Skipped++ - // Still fire the hook so auto-checkin happens even for skipped connections. - if onDrain != nil { - onDrain(ctx, sess, item) - } - continue - } - _, _, err := Put(ctx, sess, q.db, Record{With: item.InitiatorDID, EventURI: item.EventURI}) + written, err := WriteReciprocal(ctx, sess, q.db, item, pdsHost) if err != nil { if logger != nil { logger.Warn("connection drain: write failed", @@ -101,7 +75,20 @@ func Drain(ctx context.Context, q *Queue, sess *oauth.ClientSession, pdsHost str res.Skipped++ continue } - res.Written++ + if written { + res.Written++ + } else { + if logger != nil { + logger.Debug("connection drain: duplicate skipped", + "target", target.String(), + "initiator", item.InitiatorDID.String(), + "event", item.EventURI, + ) + } + res.Skipped++ + } + // Fire the hook on both fresh writes and dedup-skips so auto-checkin + // happens regardless of who scanned whose QR. if onDrain != nil { onDrain(ctx, sess, item) } @@ -109,6 +96,43 @@ func Drain(ctx context.Context, q *Queue, sess *oauth.ClientSession, pdsHost str return res, nil } +// WriteReciprocal writes the reciprocal connection record described by item to +// sess's PDS. The record lists item.InitiatorDID as the connection and carries +// item.EventURI for event linkage. It is idempotent: if the record already +// exists on the PDS (checked against pdsHost) it writes a bookmark row and +// returns (false, nil) without creating a duplicate. +// +// Returns (true, nil) when a new record was written, (false, nil) when skipped +// as a duplicate, and (false, err) on a write failure. Side-effects like +// auto-check-in are the caller's responsibility (see DrainHook) — this keeps +// the connection package decoupled from checkin/badge/event. +// +// Used by both Drain (login-time queue flush) and the Tap consumer (real-time +// reciprocity), so the write/dedup behavior stays identical across paths. +func WriteReciprocal(ctx context.Context, sess *oauth.ClientSession, db *sql.DB, item PendingItem, pdsHost string) (written bool, err error) { + if sess == nil { + return false, errNoSession + } + target := sess.Data.AccountDID + + // Dedup: skip if the user already has this connection on their PDS. + if pdsHost != "" && HasConnection(ctx, pdsHost, target, item.InitiatorDID, item.EventURI) { + if db != nil { + _, _ = db.ExecContext(ctx, ` + INSERT INTO connection_notes (viewer_did, target_did, notes, follow_up, updated_at) + VALUES (?, ?, '', 0, CURRENT_TIMESTAMP) + ON CONFLICT(viewer_did, target_did) DO NOTHING + `, target.String(), item.InitiatorDID.String()) + } + return false, nil + } + + if _, _, err := Put(ctx, sess, db, Record{With: item.InitiatorDID, EventURI: item.EventURI}); err != nil { + return false, err + } + return true, nil +} + // errNoSession is returned by Drain when invoked without a session. Kept // package-private — callers should ensure they have a session before calling. var errNoSession = errSentinel("connection drain: nil session") diff --git a/internal/connection/local.go b/internal/connection/local.go index 7050f20..5af1062 100644 --- a/internal/connection/local.go +++ b/internal/connection/local.go @@ -66,3 +66,35 @@ func WriteLocal(ctx context.Context, db *sql.DB, return nil } + +// ListLocalTargets returns the target IDs (an ATProto DID or a local_ id) of +// every local_connections row the given viewer participates in. viewerID may +// itself be an ATProto DID or a local_ id. +// +// These connections never reach the viewer's PDS, so callers that build a +// "who have I connected with" set from PDS records alone must fold these in — +// e.g. the event detail page, so a local attendee the viewer has already met +// isn't shown under "unmet". +func ListLocalTargets(ctx context.Context, db *sql.DB, viewerID string) ([]string, error) { + rows, err := db.QueryContext(ctx, ` + SELECT COALESCE(target_did, target_local_id) + FROM local_connections + WHERE viewer_did = ? OR viewer_local_id = ? + `, viewerID, viewerID) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []string + for rows.Next() { + var id sql.NullString + if err := rows.Scan(&id); err != nil { + return nil, err + } + if id.Valid && id.String != "" { + out = append(out, id.String) + } + } + return out, rows.Err() +} diff --git a/internal/connection/local_test.go b/internal/connection/local_test.go new file mode 100644 index 0000000..26dd709 --- /dev/null +++ b/internal/connection/local_test.go @@ -0,0 +1,89 @@ +package connection + +import ( + "context" + "path/filepath" + "sort" + "testing" + + atdb "atmoquest/internal/db" + "database/sql" +) + +func newLocalTestDB(t *testing.T) *sql.DB { + t.Helper() + dir := t.TempDir() + dsn := "file:" + filepath.Join(dir, "local.db") + "?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)" + conn, err := atdb.Open(dsn) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + if err := atdb.Migrate(conn); err != nil { + t.Fatalf("migrate: %v", err) + } + return conn +} + +// An ATProto viewer who connected with a local attendee should have that +// local attendee returned, since the connection never reaches their PDS. +func TestListLocalTargets_ATProtoViewerToLocalTarget(t *testing.T) { + db := newLocalTestDB(t) + ctx := context.Background() + viewer := "did:plc:viewer000000000000000000" + localTarget := "local_attendee-1" + + if err := WriteLocal(ctx, db, viewer, "", "", localTarget, "at://event/1"); err != nil { + t.Fatalf("WriteLocal: %v", err) + } + + got, err := ListLocalTargets(ctx, db, viewer) + if err != nil { + t.Fatalf("ListLocalTargets: %v", err) + } + if len(got) != 1 || got[0] != localTarget { + t.Fatalf("got %v, want [%s]", got, localTarget) + } +} + +// A local viewer who connected with both an ATProto user and another local +// user should get both target IDs back. +func TestListLocalTargets_LocalViewerMixedTargets(t *testing.T) { + db := newLocalTestDB(t) + ctx := context.Background() + viewer := "local_viewer-1" + atprotoTarget := "did:plc:target0000000000000000000" + localTarget := "local_target-2" + + if err := WriteLocal(ctx, db, "", viewer, atprotoTarget, "", "at://event/1"); err != nil { + t.Fatalf("WriteLocal atproto target: %v", err) + } + if err := WriteLocal(ctx, db, "", viewer, "", localTarget, "at://event/1"); err != nil { + t.Fatalf("WriteLocal local target: %v", err) + } + + got, err := ListLocalTargets(ctx, db, viewer) + if err != nil { + t.Fatalf("ListLocalTargets: %v", err) + } + sort.Strings(got) + want := []string{atprotoTarget, localTarget} + sort.Strings(want) + if len(got) != 2 || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("got %v, want %v", got, want) + } +} + +// An unrelated viewer should get nothing back. +func TestListLocalTargets_NoConnections(t *testing.T) { + db := newLocalTestDB(t) + ctx := context.Background() + + got, err := ListLocalTargets(ctx, db, "did:plc:stranger0000000000000000") + if err != nil { + t.Fatalf("ListLocalTargets: %v", err) + } + if len(got) != 0 { + t.Fatalf("got %v, want empty", got) + } +} diff --git a/internal/event/import.go b/internal/event/import.go new file mode 100644 index 0000000..5dd8abf --- /dev/null +++ b/internal/event/import.go @@ -0,0 +1,153 @@ +package event + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/bluesky-social/indigo/atproto/atclient" + "github.com/bluesky-social/indigo/atproto/syntax" +) + +const nsidListRecords = "com.atproto.repo.listRecords" + +// listRecordsResponse is the wire shape of com.atproto.repo.listRecords for +// quest.atmo.event. Reuses eventValue (defined in fetch.go) for the record body. +type listRecordsResponse struct { + Records []listRecordsEntry `json:"records"` + Cursor string `json:"cursor,omitempty"` +} + +type listRecordsEntry struct { + URI string `json:"uri"` + Value eventValue `json:"value"` +} + +// ListFromPDS enumerates every quest.atmo.event record in a repo via the +// public com.atproto.repo.listRecords endpoint (no auth required), paginating +// until the full set is returned. Malformed records (missing a name) are +// skipped. The returned Records carry did as their OrganizerDID. +func ListFromPDS(ctx context.Context, pdsHost string, did syntax.DID) ([]Record, error) { + c := atclient.NewAPIClient(pdsHost) + + var all []Record + 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("event.ListFromPDS %s: %w", did, err) + } + + for _, r := range resp.Records { + if r.Value.Name == "" { + continue // skip malformed records without a name + } + start, _ := time.Parse(time.RFC3339, r.Value.StartTime) + end, _ := time.Parse(time.RFC3339, r.Value.EndTime) + rec := Record{ + URI: r.URI, + Name: r.Value.Name, + StartTime: start, + EndTime: end, + Location: r.Value.Location, + OrganizerDID: did, + ExpectedAttendees: r.Value.ExpectedAttendees, + } + if r.Value.Geofence != nil { + rec.Geofence = &Geofence{ + Lat: r.Value.Geofence.Lat, + Lng: r.Value.Geofence.Lng, + RadiusMeters: r.Value.Geofence.RadiusMeters, + } + } + all = append(all, rec) + } + + if resp.Cursor == "" || len(resp.Records) == 0 { + break + } + cursor = resp.Cursor + } + + return all, nil +} + +// InsertCache adds a fetched event to the local cache if it isn't already +// present, generating a fresh QR token so the event is reachable at /e/{token}. +// +// Unlike Cache, it never overwrites an existing row — re-running an import +// leaves already-imported events (and their stable QR tokens / stats) untouched. +// It writes the admin-only columns (expected_attendees, created_by_did, qr_token) +// that Cache omits, attributing the event to its organizer. +// +// Returns the row's QR token and whether a new row was inserted. +func InsertCache(ctx context.Context, db *sql.DB, r Record) (string, bool, error) { + if r.URI == "" { + return "", false, fmt.Errorf("event: empty URI") + } + if r.Name == "" { + return "", false, fmt.Errorf("event: empty name") + } + if r.EndTime.Before(r.StartTime) { + return "", false, fmt.Errorf("event: end_time before start_time") + } + + qr, err := newQRToken() + if err != nil { + return "", false, fmt.Errorf("event: qr token: %w", err) + } + + var lat, lng *float64 + var radius *int + if r.Geofence != nil { + lat = &r.Geofence.Lat + lng = &r.Geofence.Lng + radius = &r.Geofence.RadiusMeters + } + + links, err := encodeLinks(r.Links) + if err != nil { + return "", false, err + } + + res, err := db.ExecContext(ctx, ` + INSERT INTO events ( + uri, name, start_time, end_time, location, + geofence_lat, geofence_lng, geofence_radius, + organizer_did, cached_at, + expected_attendees, created_by_did, qr_token, links + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?, ?, ?, ?) + ON CONFLICT(uri) DO NOTHING + `, + r.URI, r.Name, r.StartTime.UTC(), r.EndTime.UTC(), r.Location, + lat, lng, radius, + r.OrganizerDID.String(), + r.ExpectedAttendees, r.OrganizerDID.String(), qr, links, + ) + if err != nil { + return "", false, fmt.Errorf("event: insert cache: %w", err) + } + + if n, _ := res.RowsAffected(); n > 0 { + return qr, true, nil + } + + // Row already existed — return its persisted token, not the unused one + // we just generated. + var existing string + if err := db.QueryRowContext(ctx, `SELECT qr_token FROM events WHERE uri = ?`, r.URI).Scan(&existing); err != nil { + return "", false, fmt.Errorf("event: read existing qr_token: %w", err) + } + return existing, false, nil +} diff --git a/internal/event/import_test.go b/internal/event/import_test.go new file mode 100644 index 0000000..7e68991 --- /dev/null +++ b/internal/event/import_test.go @@ -0,0 +1,128 @@ +package event + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +func TestInsertCache_InsertsWithToken(t *testing.T) { + ctx, db := newTestDB(t) + rec := sampleEvent() + rec.ExpectedAttendees = 250 + + token, inserted, err := InsertCache(ctx, db, rec) + if err != nil { + t.Fatalf("InsertCache: %v", err) + } + if !inserted { + t.Fatal("expected inserted=true for a new event") + } + if token == "" { + t.Fatal("expected a non-empty qr_token to be generated") + } + + // The event must be reachable by its generated QR token. + got, err := LookupByQRToken(ctx, db, token) + if err != nil { + t.Fatalf("LookupByQRToken: %v", err) + } + if got.URI != rec.URI { + t.Errorf("URI = %q, want %q", got.URI, rec.URI) + } + if got.ExpectedAttendees != 250 { + t.Errorf("ExpectedAttendees = %d, want 250", got.ExpectedAttendees) + } + + // created_by_did should be the organizer so the admin UI attributes it. + var createdBy string + if err := db.QueryRowContext(ctx, `SELECT created_by_did FROM events WHERE uri = ?`, rec.URI).Scan(&createdBy); err != nil { + t.Fatalf("read created_by_did: %v", err) + } + if createdBy != testOrgDID { + t.Errorf("created_by_did = %q, want %q", createdBy, testOrgDID) + } +} + +func TestInsertCache_SkipsExistingWithoutClobberingToken(t *testing.T) { + ctx, db := newTestDB(t) + rec := sampleEvent() + + first, inserted, err := InsertCache(ctx, db, rec) + if err != nil || !inserted { + t.Fatalf("first InsertCache: token=%q inserted=%v err=%v", first, inserted, err) + } + + // Re-importing the same event must not insert again, and must return the + // original token (stable public URL). + second, inserted, err := InsertCache(ctx, db, rec) + if err != nil { + t.Fatalf("second InsertCache: %v", err) + } + if inserted { + t.Error("expected inserted=false on a duplicate import") + } + if second != first { + t.Errorf("token changed on re-import: %q -> %q", first, second) + } +} + +func TestListFromPDS_DecodesEvents(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/xrpc/com.atproto.repo.listRecords" { + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("collection"); got != NSID { + t.Errorf("collection = %q, want %q", got, NSID) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "records": []map[string]any{ + { + "uri": "at://did:plc:eventorganizer/quest.atmo.event/3lkabc", + "value": map[string]any{ + "$type": NSID, + "name": "CascadiaJS 2026", + "startTime": "2026-05-31T09:00:00Z", + "endTime": "2026-05-31T17:00:00Z", + "location": "Portland, OR", + "expectedAttendees": 250, + }, + }, + { + // Malformed record (no name) should be skipped. + "uri": "at://did:plc:eventorganizer/quest.atmo.event/3lkxyz", + "value": map[string]any{"$type": NSID}, + }, + }, + "cursor": "", + }) + })) + defer srv.Close() + + did := syntax.DID(testOrgDID) + got, err := ListFromPDS(t.Context(), srv.URL, did) + if err != nil { + t.Fatalf("ListFromPDS: %v", err) + } + if len(got) != 1 { + t.Fatalf("got %d events, want 1 (malformed should be skipped)", len(got)) + } + ev := got[0] + if ev.Name != "CascadiaJS 2026" { + t.Errorf("Name = %q", ev.Name) + } + if ev.ExpectedAttendees != 250 { + t.Errorf("ExpectedAttendees = %d, want 250", ev.ExpectedAttendees) + } + if !ev.StartTime.Equal(time.Date(2026, 5, 31, 9, 0, 0, 0, time.UTC)) { + t.Errorf("StartTime = %v", ev.StartTime) + } + if ev.OrganizerDID != did { + t.Errorf("OrganizerDID = %q, want %q", ev.OrganizerDID, did) + } +} diff --git a/internal/users/users.go b/internal/users/users.go index b5bb378..0db23ab 100644 --- a/internal/users/users.go +++ b/internal/users/users.go @@ -291,6 +291,17 @@ func SetPrivacy(ctx context.Context, db *sql.DB, did syntax.DID, column string, return err } +// NameAndHandle returns the stored display_name and handle for an id, which +// may be an ATProto DID or a local_ id. Returns empty strings when the id has +// no users row. Used to render local-account attendees, who have no PDS +// profile to fetch a name from. +func NameAndHandle(ctx context.Context, db *sql.DB, id string) (displayName, handle string) { + _ = db.QueryRowContext(ctx, ` + SELECT display_name, handle FROM users WHERE did = ? + `, id).Scan(&displayName, &handle) + return displayName, handle +} + // IsHiddenFromAttendees returns true if the DID should be hidden from // event attendee lists. Returns false for unknown DIDs. func IsHiddenFromAttendees(ctx context.Context, db *sql.DB, did string) bool { diff --git a/internal/users/users_test.go b/internal/users/users_test.go index c4ca4d8..f968c82 100644 --- a/internal/users/users_test.go +++ b/internal/users/users_test.go @@ -33,6 +33,31 @@ func newTestDB(t *testing.T) (context.Context, *sql.DB) { return context.Background(), conn } +func TestNameAndHandle_LocalUser(t *testing.T) { + ctx, db := newTestDB(t) + localID := "local_abc-123" + if _, err := db.ExecContext(ctx, ` + INSERT INTO users (did, handle, display_name) VALUES (?, ?, ?) + `, localID, "", "Local Larry"); err != nil { + t.Fatalf("insert: %v", err) + } + name, handle := NameAndHandle(ctx, db, localID) + if name != "Local Larry" { + t.Errorf("name = %q; want Local Larry", name) + } + if handle != "" { + t.Errorf("handle = %q; want empty", handle) + } +} + +func TestNameAndHandle_UnknownID(t *testing.T) { + ctx, db := newTestDB(t) + name, handle := NameAndHandle(ctx, db, "local_nobody") + if name != "" || handle != "" { + t.Errorf("got (%q, %q); want empty strings", name, handle) + } +} + func TestTouch_InsertNew(t *testing.T) { ctx, db := newTestDB(t) did := syntax.DID(testDIDA) diff --git a/router/router.go b/router/router.go index 1d30941..f678d32 100644 --- a/router/router.go +++ b/router/router.go @@ -28,7 +28,9 @@ import ( "atmoquest/features/events" "atmoquest/features/index" "atmoquest/features/profile" + "atmoquest/features/pwa" "atmoquest/features/settings" + "atmoquest/features/tap" "atmoquest/internal/admincrypto" "atmoquest/internal/apitoken" "atmoquest/internal/connection" @@ -62,6 +64,19 @@ func SetupRoutes( // debounced to once per 30 seconds per user. router.Use(auth.DrainMiddleware(authH, connQueue, conn, 30*time.Second)) + // Real-time reciprocity: when a self-hosted Tap endpoint is configured, + // subscribe to quest.atmo.connection creates and write/queue reciprocals + // as they happen. No-op (and no goroutine) when TAP_WS_ENDPOINT is unset, + // so dev/tests fall back to the login-drain path above. + if config.Global.TapWSEndpoint != "" { + tapConsumer := tap.NewConsumer( + conn, oauthApp, connQueue, + config.Global.TapWSEndpoint, config.Global.TapAuthToken, + auth.AutoCheckinDrainHook(conn), slog.Default(), + ) + go tapConsumer.Run(ctx) + } + if config.Global.Environment == config.Dev { setupReload(router) setupDevDebug(router, authH, conn) @@ -78,6 +93,7 @@ func SetupRoutes( admin.SetupRoutes(router, conn, authH, signer) settings.SetupRoutes(router, conn, authH) about.SetupRoutes(router, authH) + pwa.SetupRoutes(router) demo.SetupRoutes(router) index.SetupRoutes(router, conn, authH) diff --git a/web/resources/static/assets/site.webmanifest b/web/resources/static/assets/site.webmanifest index 66f4aad..10eb0f4 100644 --- a/web/resources/static/assets/site.webmanifest +++ b/web/resources/static/assets/site.webmanifest @@ -1,6 +1,10 @@ { + "id": "/", "name": "atmo.quest", "short_name": "atmo.quest", + "description": "An event-companion app on ATProto. Scan a QR, write a private note, leave a conference with a real follow-up list.", + "start_url": "/", + "scope": "/", "icons": [ { "src": "/static/assets/android-chrome-192x192.png", @@ -11,6 +15,12 @@ "src": "/static/assets/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" + }, + { + "src": "/static/assets/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" } ], "theme_color": "#1e1e2e", diff --git a/web/resources/static/js/sw.js b/web/resources/static/js/sw.js new file mode 100644 index 0000000..ebbda92 --- /dev/null +++ b/web/resources/static/js/sw.js @@ -0,0 +1,119 @@ +// atmo.quest service worker. +// +// Registered only in production (see base.templ). Strategy: +// - static, content-fingerprinted assets (/static/*) -> cache-first +// - navigations to safe content routes -> network-first, +// falling back to the cached page, then to /offline +// - everything else -> passes through; +// failed navigations fall back to /offline +// +// It deliberately does NOT touch: non-GET requests, cross-origin requests, +// Server-Sent Events (Datastar live streams), or the OAuth/sign-in routes — +// intercepting those would break live updates and atproto auth. + +const CACHE_VERSION = "v1"; +const STATIC_CACHE = `static-${CACHE_VERSION}`; +const PAGES_CACHE = `pages-${CACHE_VERSION}`; +const OFFLINE_URL = "/offline"; + +// Navigations to these path prefixes are cached for offline viewing. +const CACHEABLE_PAGE_PREFIXES = ["/events", "/profile", "/connections"]; + +function isCacheablePage(url) { + if (url.pathname === "/") return true; + return CACHEABLE_PAGE_PREFIXES.some( + (p) => url.pathname === p || url.pathname.startsWith(p + "/"), + ); +} + +// install: precache the offline fallback, then take over immediately. +self.addEventListener("install", (event) => { + event.waitUntil( + caches + .open(STATIC_CACHE) + .then((cache) => cache.add(OFFLINE_URL)) + .then(() => self.skipWaiting()), + ); +}); + +// activate: drop caches from previous versions, then claim open clients. +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys + .filter((k) => k !== STATIC_CACHE && k !== PAGES_CACHE) + .map((k) => caches.delete(k)), + ), + ) + .then(() => self.clients.claim()), + ); +}); + +self.addEventListener("fetch", (event) => { + const req = event.request; + + // Only same-origin GET requests are handled; everything else passes through. + if (req.method !== "GET") return; + const url = new URL(req.url); + if (url.origin !== self.location.origin) return; + + // Never intercept Server-Sent Events (Datastar live streams) or the + // OAuth / sign-in flows. + if (req.headers.get("accept") === "text/event-stream") return; + if (url.pathname.startsWith("/oauth/") || url.pathname === "/signin") { + // Signing out invalidates any cached authed pages. + if (url.pathname === "/oauth/logout") { + event.waitUntil(caches.delete(PAGES_CACHE)); + } + return; + } + + // Fingerprinted static assets: cache-first (a changed asset gets a new URL). + if (url.pathname.startsWith("/static/")) { + event.respondWith(cacheFirst(req)); + return; + } + + // Page navigations to safe routes: network-first with cache + offline + // fallback. Network-first means online users always get fresh HTML. + if (req.mode === "navigate" && isCacheablePage(url)) { + event.respondWith(networkFirst(req)); + return; + } + + // Any other navigation that fails offline gets the offline page. + if (req.mode === "navigate") { + event.respondWith( + fetch(req).catch(() => caches.match(OFFLINE_URL)), + ); + } +}); + +async function cacheFirst(req) { + const cached = await caches.match(req); + if (cached) return cached; + const res = await fetch(req); + if (res && res.ok) { + const cache = await caches.open(STATIC_CACHE); + cache.put(req, res.clone()); + } + return res; +} + +async function networkFirst(req) { + try { + const res = await fetch(req); + if (res && res.ok) { + const cache = await caches.open(PAGES_CACHE); + cache.put(req, res.clone()); + } + return res; + } catch (err) { + const cached = await caches.match(req); + if (cached) return cached; + return caches.match(OFFLINE_URL); + } +} diff --git a/web/resources/static_dev.go b/web/resources/static_dev.go index 00b9444..bec44a2 100644 --- a/web/resources/static_dev.go +++ b/web/resources/static_dev.go @@ -6,6 +6,7 @@ import ( "log/slog" "net/http" "os" + "path/filepath" ) func Handler() http.Handler { @@ -19,3 +20,10 @@ func Handler() http.Handler { func StaticPath(path string) string { return "/static/" + path } + +// ReadStatic reads a static file's raw bytes by its unhashed path (e.g. +// "js/sw.js"), from disk in dev. Mirrors the prod variant so callers serving +// stable, non-fingerprinted asset URLs work the same in both builds. +func ReadStatic(path string) ([]byte, error) { + return os.ReadFile(filepath.Join(StaticDirectoryPath, path)) +} diff --git a/web/resources/static_prod.go b/web/resources/static_prod.go index b6550bd..ca99134 100644 --- a/web/resources/static_prod.go +++ b/web/resources/static_prod.go @@ -24,3 +24,10 @@ func Handler() http.Handler { func StaticPath(path string) string { return "/" + StaticSys.HashName("static/"+path) } + +// ReadStatic reads a static file's raw bytes by its unhashed path (e.g. +// "js/sw.js"). Used to serve a few assets at stable, non-fingerprinted URLs — +// the service worker must keep a constant registration URL. +func ReadStatic(path string) ([]byte, error) { + return StaticDirectory.ReadFile("static/" + path) +} -- 2.51.2