holding page: standing-gated, scriptless, says the room is next
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
4 files changed,
+82
−2
internal/meet/app.go+1 −0internal/meet/handlers_join.go+32 −0internal/meet/templates/join.html+10 −2internal/meettest/holding_test.go+39 −0
diff --git a/internal/meet/app.go b/internal/meet/app.go| index 3ec24e8..a08d8a2 100644 |
| --- a/internal/meet/app.go |
| +++ b/internal/meet/app.go |
| @@ -85,6 +85,7 @@ func build(d *db.DB, cfg Config) (*http.ServeMux, *app, error) { |
| r.Use(a.requirePerson) |
| r.Get("/continue", a.continueSignedIn) |
| r.Get("/welcome", a.welcome) |
| + r.Get("/m/{sqid}/join", a.holding) |
| r.Group(func(r chi.Router) { |
| r.Use(a.requireMember) |
| r.Get("/", a.index) |
diff --git a/internal/meet/handlers_join.go b/internal/meet/handlers_join.go| index 80bcb2c..e0b960a 100644 |
| --- a/internal/meet/handlers_join.go |
| +++ b/internal/meet/handlers_join.go |
| @@ -41,3 +41,35 @@ func (a *app) followLink(w http.ResponseWriter, r *http.Request) { |
| } |
| http.Redirect(w, r, "/m/"+m.Sqid+"/join", http.StatusSeeOther) |
| } |
| + |
| +// holding is GET /m/{sqid}/join, authorized by standing (spec §2.5). In |
| +// this slice it renders the server-side half of the holding screen and |
| +// says the room is the next slice. |
| +func (a *app) holding(w http.ResponseWriter, r *http.Request) { |
| + m, ok, err := a.store.meetingBySqid(r.Context(), chi.URLParam(r, "sqid")) |
| + if err != nil { |
| + a.logger.Error("meet: load meeting", "err", err) |
| + ErrorPage(w, r, http.StatusInternalServerError, "") |
| + return |
| + } |
| + if !ok || m.EndedAt != nil { |
| + ErrorPage(w, r, http.StatusNotFound, "") |
| + return |
| + } |
| + p, _ := currentPerson(r) |
| + st, err := a.standingFor(r.Context(), p, m) |
| + if err != nil { |
| + a.logger.Error("meet: standing", "err", err) |
| + ErrorPage(w, r, http.StatusInternalServerError, "") |
| + return |
| + } |
| + if st == StandingNone { |
| + ErrorPage(w, r, http.StatusNotFound, "") |
| + return |
| + } |
| + render(w, "join", map[string]any{ |
| + "Sqid": m.Sqid, "Title": a.store.title(m), "Starts": a.fmtStart(m.StartsAt), |
| + "Name": p.Name, "Standing": st.String(), "IsMember": st == StandingMember, |
| + "Floor": !a.cfg.RelayConfigured, |
| + }) |
| +} |
diff --git a/internal/meet/templates/join.html b/internal/meet/templates/join.html| index 8326cd1..ad0872e 100644 |
| --- a/internal/meet/templates/join.html |
| +++ b/internal/meet/templates/join.html |
| @@ -1,2 +1,10 @@ |
| -{{define "title"}}Meet{{end}} |
| -{{define "content"}}{{end}} |
| +{{define "title"}}{{.Title}}{{end}} |
| +{{define "content"}} |
| +{{if .IsMember}}{{template "back-nav" dict "Href" "/" "Label" "Meetings"}}{{end}} |
| +{{template "page-header" dict "Title" .Title}} |
| +{{if .Starts}}<p>Starts {{.Starts}}.</p>{{end}} |
| +<p>You are {{.Name}}, joining as a {{.Standing}}.</p> |
| +{{if .Floor}}<p rst-notice>This team's Meet has no relay yet. Calls usually connect only between people on the same network.</p>{{end}} |
| +<p>The call itself is the next slice of this app. When it lands, this page will show your camera preview, device pickers and a join button, and needs JavaScript for that part only; everything else here works without it.</p> |
| +{{if .IsMember}}<p><a href="/meetings/{{.Sqid}}/end">End the meeting</a></p>{{end}} |
| +{{end}} |
diff --git a/internal/meettest/holding_test.go b/internal/meettest/holding_test.go| new file mode 100644 |
| index 0000000..99aa4e9 |
| --- /dev/null |
| +++ b/internal/meettest/holding_test.go |
| @@ -0,0 +1,39 @@ |
| +package meettest |
| + |
| +import ( |
| + "net/http" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestHoldingPageShowsTitleStandingAndFloor(t *testing.T) { |
| + x, sqid, _ := memberWithMeeting(t) |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + rec := c.get("/m/" + sqid + "/join") |
| + body := rec.Body.String() |
| + for _, want := range []string{"Board meeting", "joining as a member", "no relay yet", "End the meeting"} { |
| + if !strings.Contains(body, want) { |
| + t.Errorf("holding page lacks %q", want) |
| + } |
| + } |
| +} |
| + |
| +func TestHoldingPageIs404ForNoStanding(t *testing.T) { |
| + x := newHarness(t) // no team: everyone signs in, nobody is a member |
| + c := x.browser(t) |
| + c.signIn("nobody@example.com") |
| + if rec := c.get("/m/2345678abc/join"); rec.Code != http.StatusNotFound { |
| + t.Fatalf("no standing: %d", rec.Code) |
| + } |
| +} |
| + |
| +func TestHoldingPageIs404ForEndedMeetings(t *testing.T) { |
| + x, sqid, _ := memberWithMeeting(t) |
| + m := x.browser(t) |
| + m.signIn("paul@example.com") |
| + m.post("/meetings/"+sqid+"/end", nil) |
| + if rec := m.get("/m/" + sqid + "/join"); rec.Code != http.StatusNotFound { |
| + t.Fatalf("ended: %d", rec.Code) |
| + } |
| +} |