package calls import ( "errors" "net/http" "net/url" "strings" "time" "unicode/utf8" "github.com/go-chi/chi/v5" ) const maxTitleLen = 200 // titleTooLong is the one length rule a title is ever measured // against, shared by the create form and the API (api.go's // apiCreateMeeting): 200 characters, counted as runes so the two // surfaces agree on what "200 characters" means for a title that is // not plain ASCII — a byte count would let the API refuse a title the // screen's own copy ("Keep the title under 200 characters.") promises // is fine. func titleTooLong(title string) bool { return utf8.RuneCountInString(title) > maxTitleLen } // linkURL is built from configuration, never from the Host header. func (a *app) linkURL(token string) string { return a.cfg.Origin + "/j/" + token } // parseStart reads the optional start time from the form in the // instance zone (config.go). Returns a message when it is malformed. func (a *app) parseStart(form url.Values) (*time.Time, string) { raw := strings.TrimSpace(form.Get("starts_at")) if raw == "" { return nil, "" } t, err := time.ParseInLocation("2006-01-02T15:04", raw, a.cfg.zone) if err != nil { return nil, "Enter the start as a date and time, or leave it blank to start now." } u := t.UTC() return &u, "" } // fmtTime is the instance zone's one date+time rendering, shared by // every screen that shows a stored moment (a meeting's start, an // agent's minted-at) rather than each keeping its own copy of the // layout string. func (a *app) fmtTime(t time.Time) string { return t.In(a.cfg.zone).Format("Mon 2 Jan 2006, 15:04") + " " + a.cfg.Zone } func (a *app) fmtStart(t *time.Time) string { if t == nil { return "" } return a.fmtTime(*t) } func (a *app) index(w http.ResponseWriter, r *http.Request) { p, ok := currentPerson(r) if !ok || !a.isMember(p) { http.Redirect(w, r, "/welcome", http.StatusSeeOther) return } a.renderIndex(w, r, "", "") } func (a *app) renderIndex(w http.ResponseWriter, r *http.Request, titleErr, startErr string) { views, err := a.store.listMeetings(r.Context()) if err != nil { a.logger.Error("calls: list meetings", "err", err) a.ErrorPage(w, r, http.StatusInternalServerError, "") return } type row struct { Sqid, Title, Starts string Ended, LinkPresent bool ByAgent bool CreatedByPrincipal string } var upcoming, ended []row for _, v := range views { rw := row{ Sqid: v.Sqid, Title: v.Title, Starts: a.fmtStart(v.StartsAt), Ended: v.EndedAt != nil, LinkPresent: v.LinkPresent, ByAgent: v.ByAgent, CreatedByPrincipal: v.CreatedByPrincipal, } if rw.Ended { ended = append(ended, rw) } else { upcoming = append(upcoming, rw) } } render(w, "index", a.page(r, frame{}, map[string]any{ "Upcoming": upcoming, "Ended": ended, "Zone": a.cfg.Zone, "TitleError": titleErr, "StartError": startErr, "HomeEnabled": a.showHomeConnection(r), })) } func (a *app) createMeeting(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() title := strings.TrimSpace(r.FormValue("title")) var titleErr string switch { case title == "": titleErr = "Give the meeting a title." case titleTooLong(title): titleErr = "Keep the title under 200 characters." } start, startErr := a.parseStart(r.Form) if titleErr != "" || startErr != "" { w.WriteHeader(http.StatusUnprocessableEntity) a.renderIndex(w, r, titleErr, startErr) return } p, _ := currentPerson(r) m, token, err := a.store.createMeeting(r.Context(), title, start, actorFor(p)) if err != nil { a.logger.Error("calls: create meeting", "err", err) a.ErrorPage(w, r, http.StatusInternalServerError, "") return } a.renderLinkOnce(w, r, m, token, false) } // autoTitle is a title Calls writes for a meeting nobody named, and the // zone its time is in. type autoTitle struct { Text string Zone string } // callTitle is the title a one-click "Start a call" meeting gets: // "Call, 10 Sep 09:15", in the starter's own time zone when the browser // or the app said which (zone.js, the apps' Start meeting), so it reads // as the time they started it, and otherwise in the instance's. now is // passed in so a test can fix it. // // The zone is not in the words. From f86d4da (2026-10-01, the shared // chrome) until 2026-10-02 a title in the instance zone ended with its // name — "Call, 2 Oct 15:40 Europe/Dublin" — so that a reader elsewhere // was not shown a bare time in a zone they could not see. That was the // title every meeting started from the Mac and iPhone apps got, because // they sent no zone, and Paul's screenshot of the Mac call is what it // looked like. The apps now send theirs, the name goes to TitleZone, and // the title reads as a title. func callTitle(now time.Time, local *time.Location, instance *time.Location) autoTitle { loc := instance if local != nil { loc = local } return autoTitle{Text: "Call, " + now.In(loc).Format("2 Jan 15:04"), Zone: loc.String()} } // callZone is the browser's IANA zone from the Start a call form, or nil // for an empty, unknown or implausible one. func callZone(name string) *time.Location { if name == "" || len(name) > 64 || name == "Local" { return nil } loc, err := time.LoadLocation(name) if err != nil { return nil } return loc } // startCall is POST /meetings/start: the list's one-click button. It // creates a meeting with no start time — a call starts now — through // the same store.createMeeting every scheduled meeting goes through, // and answers with the same link-once page. func (a *app) startCall(w http.ResponseWriter, r *http.Request) { p, _ := currentPerson(r) title := callTitle(a.store.now(), callZone(r.PostFormValue("zone")), a.cfg.zone) m, token, err := a.store.createCall(r.Context(), title, actorFor(p)) if err != nil { a.logger.Error("calls: start call", "err", err) a.ErrorPage(w, r, http.StatusInternalServerError, "") return } a.renderLinkOnce(w, r, m, token, false) } // renderLinkOnce answers with a page, not a redirect: a token in a // query string lands in the edge's access logs (Sheets' ruling). func (a *app) renderLinkOnce(w http.ResponseWriter, r *http.Request, m Meeting, token string, replaced bool) { w.Header().Set("Cache-Control", "no-store") title := a.store.title(m) render(w, "link_once", a.page(r, onMeeting("Your meeting link", title), map[string]any{ "Sqid": m.Sqid, "Title": title, "Starts": a.fmtStart(m.StartsAt), "Link": a.linkURL(token), "Replaced": replaced, })) } func (a *app) shareMeeting(w http.ResponseWriter, r *http.Request) { m, ok := a.meetingForMember(w, r) if !ok { return } p, _ := currentPerson(r) token, err := a.store.shareLink(r.Context(), m.ID, actorFor(p)) if errors.Is(err, errMeetingEnded) { a.ErrorPage(w, r, http.StatusNotFound, "") return } if err != nil { a.logger.Error("calls: share meeting", "err", err) a.ErrorPage(w, r, http.StatusInternalServerError, "") return } w.Header().Set("Cache-Control", "no-store") // strict-origin rather than no-referrer: the page shows the account // menu, and a chained sign-out is admitted by id.* on the Referer's // origin (spec §9.5). The path, which names the meeting, is never sent. w.Header().Set("Referrer-Policy", "strict-origin") link := a.linkURL(token) if r.Header.Get("Accept") == "application/json" { streamJSON(w, http.StatusOK, map[string]any{"url": link}) return } title := a.store.title(m) render(w, "share", a.page(r, onMeeting("Share meeting", title), map[string]any{"Title": title, "Link": link})) } // meetingForMember loads the meeting in the URL or answers 404. func (a *app) meetingForMember(w http.ResponseWriter, r *http.Request) (Meeting, bool) { m, ok, err := a.store.meetingBySqid(r.Context(), chi.URLParam(r, "sqid")) if err != nil { a.logger.Error("calls: load meeting", "err", err) a.ErrorPage(w, r, http.StatusInternalServerError, "") return Meeting{}, false } if !ok { a.ErrorPage(w, r, http.StatusNotFound, "") return Meeting{}, false } return m, true } func (a *app) confirmReplaceLink(w http.ResponseWriter, r *http.Request) { m, ok := a.meetingForMember(w, r) if !ok { return } render(w, "confirm", a.page(r, onMeeting("Replace the link?", a.store.title(m)), map[string]any{ "Title": "Replace the link?", "Body": "Everyone holding a current link for \"" + a.store.title(m) + "\" will get a not-found page. You can share the replacement from the meeting.", "Action": "/meetings/" + m.Sqid + "/links", "Label": "Replace the link", "CancelHref": "/", "Sqid": m.Sqid, })) } func (a *app) replaceLink(w http.ResponseWriter, r *http.Request) { m, ok := a.meetingForMember(w, r) if !ok { return } if m.EndedAt != nil { a.ErrorPage(w, r, http.StatusNotFound, "") return } p, _ := currentPerson(r) token, err := a.store.replaceLink(r.Context(), m.ID, actorFor(p)) // The screen's own answer for an ended meeting is the not-found // page, above; a meeting that ended between that read and the write // gets the same page rather than a 500, since the store now settles // the question inside its transaction (review S5-2). if errors.Is(err, errMeetingEnded) { a.ErrorPage(w, r, http.StatusNotFound, "") return } if err != nil { a.logger.Error("calls: replace link", "err", err) a.ErrorPage(w, r, http.StatusInternalServerError, "") return } a.renderLinkOnce(w, r, m, token, true) } func (a *app) confirmEnd(w http.ResponseWriter, r *http.Request) { m, ok := a.meetingForMember(w, r) if !ok { return } render(w, "confirm", a.page(r, onMeeting("End meeting for everyone?", a.store.title(m)), map[string]any{ "Title": "End meeting for everyone?", "Body": "\"" + a.store.title(m) + "\" will close for everyone and its link will stop working.", "Action": "/meetings/" + m.Sqid + "/end", "Label": "End meeting for everyone", "CancelHref": "/", "Sqid": m.Sqid, })) } func (a *app) endMeeting(w http.ResponseWriter, r *http.Request) { m, ok := a.meetingForMember(w, r) if !ok { return } p, _ := currentPerson(r) // The screen's answer is the same either way: ending a meeting that // is already ended is the list page, not an error — a member who // submits the confirm page twice has got what they asked for. The // bool endMeeting now returns is the API's 409 (review S5-2), which // this side has never had and does not gain here. if _, err := a.store.endMeeting(r.Context(), m.ID, actorFor(p)); err != nil { a.logger.Error("calls: end meeting", "err", err) a.ErrorPage(w, r, http.StatusInternalServerError, "") return } // The row is the authority; the hub is told so every open stream // gets lost{ended} now rather than at the next tick (spec §2.4). a.hub.End(m.Sqid) http.Redirect(w, r, "/", http.StatusSeeOther) }