# Hold Discovery This document describes how AppView discovers available holds and presents them to users for selection. ## TL;DR **Problem:** Users currently enter hold URLs manually in a text field. They don't know what holds exist or which ones they can access. **Solution:** 1. Subscribe to Jetstream for `io.atcr.hold.captain` and `io.atcr.hold.crew` collections 2. Cache discovered holds and crew memberships in SQLite 3. Replace the text input with a dropdown showing available holds grouped by access level **Key Changes:** - New table: `hold_crew_members` (hold_did, member_did, rkey, permissions, ...) - Jetstream collections: `io.atcr.hold.captain`, `io.atcr.hold.crew` - Settings UI: Text input → ` Leave empty to use AppView default storage ``` **Problems with the current approach:** 1. **Users must know hold URLs** - Requires users to manually find and copy hold endpoint URLs 2. **No validation** - Users can enter invalid or inaccessible URLs 3. **No discovery** - Users don't know what holds are available to them 4. **Poor UX** - Text input is error-prone and unfriendly 5. **No membership visibility** - Users can't see which holds they're crew on ### Proposed Change: Dropdown with Discovered Holds Replace the text input with a ` {{if .OwnedHolds}} {{range .OwnedHolds}} {{end}} {{end}} {{if .CrewHolds}} {{range .CrewHolds}} {{end}} {{end}} {{if .EligibleHolds}} {{range .EligibleHolds}} {{end}} {{end}} {{if .PublicHolds}} {{range .PublicHolds}} {{end}} {{end}} Your images will be stored on the selected hold
``` ### Dropdown Option Groups The dropdown organizes holds into logical groups based on user's relationship: | Group | Description | Access Level | |-------|-------------|--------------| | **Your Holds** | Holds where user is the captain (owner) | Full control | | **Crew Member** | Holds where user has explicit crew membership | Based on permissions | | **Open Registration** | Holds with `allowAllCrew=true` | Can self-register | | **Public Holds** | Holds with `public=true` | Anyone can use | ### Visual Indicators Each option should show relevant context: ``` ┌─ Storage Hold: ─────────────────────────────────────┐ │ ▼ hold01.atcr.io (us-east) │ ├─────────────────────────────────────────────────────┤ │ AppView Default (hold01.atcr.io) │ │ ───────────────────────────────────── │ │ Your Holds │ │ my-hold.fly.dev (us-west) │ │ ───────────────────────────────────── │ │ Crew Member │ │ team-hold.company.com (eu-central) │ │ shared-hold.org (asia-pacific) [read-only] │ │ ───────────────────────────────────── │ │ Open Registration │ │ community-hold.dev (us-east) │ │ ───────────────────────────────────── │ │ Public Holds │ │ public-hold.example.com (global) │ └─────────────────────────────────────────────────────┘ ``` ### Form Submission Change The form now submits `hold_did` (a DID) instead of `hold_endpoint` (a URL): **Before:** ``` POST /api/profile/default-hold Content-Type: application/x-www-form-urlencoded hold_endpoint=https://hold01.atcr.io ``` **After:** ``` POST /api/profile/default-hold Content-Type: application/x-www-form-urlencoded hold_did=did:web:hold01.atcr.io ``` The `UpdateDefaultHoldHandler` needs to be updated to accept DIDs: ```go // pkg/appview/handlers/settings.go func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { user := middleware.GetUser(r) if user == nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } // Accept DID (new) or endpoint (legacy/fallback) holdDID := r.FormValue("hold_did") if holdDID == "" { // Fallback for legacy form submissions holdDID = r.FormValue("hold_endpoint") } // Validate the hold DID if provided if holdDID != "" { // Check it's in our discovered holds cache captain, err := h.DB.GetCaptainRecord(holdDID) if err != nil { http.Error(w, "Unknown hold: "+holdDID, http.StatusBadRequest) return } // Verify user has access to this hold available, err := db.GetAvailableHolds(h.DB, user.DID) if err != nil { http.Error(w, "Failed to check hold access", http.StatusInternalServerError) return } hasAccess := false for _, h := range available { if h.DID == holdDID { hasAccess = true break } } if !hasAccess { http.Error(w, "You don't have access to this hold", http.StatusForbidden) return } } // ... rest of profile update logic } ``` ### Settings Handler Update the settings handler to include available holds: ```go // pkg/appview/handlers/settings.go func (h *Handler) SettingsPage(w http.ResponseWriter, r *http.Request) { ctx := r.Context() userDID := auth.GetDID(ctx) // Get user's current profile profile, err := h.storage.GetProfile(ctx, userDID) if err != nil { // Handle error } // Get available holds for dropdown availableHolds, err := db.GetAvailableHolds(h.db, userDID) if err != nil { // Handle error } data := SettingsPageData{ Profile: profile, AvailableHolds: availableHolds, CurrentHoldDID: profile.DefaultHold, } h.renderTemplate(w, "settings.html", data) } ``` ### Settings Template ```html

Default Hold

Select where your container images will be stored by default.

``` ### Template Data Preparation ```go // pkg/appview/handlers/settings.go type SettingsPageData struct { Profile *atproto.SailorProfile CurrentHoldDID string OwnedHolds []HoldDisplay CrewHolds []HoldDisplay EligibleHolds []HoldDisplay PublicHolds []HoldDisplay } type HoldDisplay struct { DID string DisplayName string // Derived from DID or endpoint Region string Provider string Permissions []string } func (h *Handler) prepareSettingsData(userDID string, holds []db.AvailableHold, currentHold string) SettingsPageData { data := SettingsPageData{ CurrentHoldDID: currentHold, } for _, hold := range holds { display := HoldDisplay{ DID: hold.DID, DisplayName: deriveDisplayName(hold.DID, hold.Endpoint), Region: hold.Region, Provider: hold.Provider, Permissions: hold.Permissions, } switch hold.Membership { case "owner": data.OwnedHolds = append(data.OwnedHolds, display) case "crew": data.CrewHolds = append(data.CrewHolds, display) case "eligible": data.EligibleHolds = append(data.EligibleHolds, display) case "public": data.PublicHolds = append(data.PublicHolds, display) } } return data } func deriveDisplayName(did, endpoint string) string { // For did:web, extract the domain if strings.HasPrefix(did, "did:web:") { return strings.TrimPrefix(did, "did:web:") } // For did:plc, use the endpoint hostname if available if endpoint != "" { if u, err := url.Parse(endpoint); err == nil { return u.Host } } // Fallback to truncated DID if len(did) > 20 { return did[:20] + "..." } return did } ``` ### CSS Styles Add styles for the hold dropdown and details panel: ```css /* pkg/appview/templates/pages/settings.html - add to