| 1 | package calls |
| 2 | |
| 3 | // The shared chrome (estilo chrome, round 2): every page's bar, account |
| 4 | // menu and <title> are built here, once, so no page can drift from |
| 5 | // another. Before this the shell was rastrillo's topbar at 81px: a raw |
| 6 | // address, a blue underlined "Settings" and a Sign out button that |
| 7 | // wrapped under it, a separate one-item Help dropdown, no nav and no |
| 8 | // current-page marker, and a <title> that named the app on one page out |
| 9 | // of fifteen. |
| 10 | // |
| 11 | // Spec: estilo spec/shared-chrome, docs/superpowers/specs/ |
| 12 | // 2026-09-23-shared-chrome.md, §5 (bar), §6 (switcher), §8 (account), |
| 13 | // §10 (titles). Calls adopts after Docs, Sheets and Slides. |
| 14 | // |
| 15 | // The call room keeps no bar: since 2026-09-28 a call is one window in |
| 16 | // the viewport (room.html). Its title menu carries the same items, in |
| 17 | // the same order and words, as the account menu built here (roomMenu). |
| 18 | |
| 19 | import ( |
| 20 | "net/http" |
| 21 | "net/url" |
| 22 | |
| 23 | "amadan.net/oficina/estilo.git/chrome" |
| 24 | estilomenu "amadan.net/oficina/estilo.git/menu" |
| 25 | "amadan.net/rastrillo/rastrillo/sessions" |
| 26 | ) |
| 27 | |
| 28 | // appName is the brand and the last part of every <title>. |
| 29 | const appName = "Calls" |
| 30 | |
| 31 | // Frame is what layout.html reads from every page: the bar, the parts of |
| 32 | // the <title> before the app's name, and the Appearance preference for |
| 33 | // <html data-theme>. |
| 34 | type Frame struct { |
| 35 | Chrome chrome.Bar |
| 36 | TitleParts []string |
| 37 | Theme string |
| 38 | } |
| 39 | |
| 40 | // frame describes one page's chrome. The zero value is a page whose |
| 41 | // <title> is just "Calls" (the meeting list, the app root). |
| 42 | type frame struct { |
| 43 | // parts precede "Calls" in <title>, most specific first. The first |
| 44 | // is the page's h1 (spec §10). |
| 45 | parts []string |
| 46 | // context is the open meeting's title, shown in the bar. |
| 47 | context string |
| 48 | // signinPage is one of the sign-in, sent and signed-out pages, or a |
| 49 | // native app hand-off page: the corner is empty and the brand is |
| 50 | // plain text, since everything it could link to is behind the session |
| 51 | // gate (and a hand-off page keeps no-referrer, which a chained |
| 52 | // sign-out could not pass through). |
| 53 | signinPage bool |
| 54 | // public is a page reached through a capability (the invitation |
| 55 | // link /j/{token}): "Sign in" carries no return path, because the |
| 56 | // path is the capability and must not be copied into another URL. |
| 57 | public bool |
| 58 | } |
| 59 | |
| 60 | // titled is the frame for a page whose <title> is these parts. |
| 61 | func titled(parts ...string) frame { return frame{parts: parts} } |
| 62 | |
| 63 | // onMeeting is the frame for one of a meeting's pages: the meeting's |
| 64 | // title is the bar's context, and it follows page in the <title>. With |
| 65 | // page empty the meeting is the page (the holding screen, the room). |
| 66 | func onMeeting(page, meeting string) frame { |
| 67 | if page == "" { |
| 68 | return frame{parts: []string{meeting}, context: meeting} |
| 69 | } |
| 70 | return frame{parts: []string{page, meeting}, context: meeting} |
| 71 | } |
| 72 | |
| 73 | // viewer is who the chrome is for: nobody, a guest, or a signed-in |
| 74 | // person, and how they signed in. |
| 75 | type viewer struct { |
| 76 | person Person |
| 77 | method string |
| 78 | ok bool |
| 79 | } |
| 80 | |
| 81 | // signedIn is a person who signed in, as opposed to a per-meeting guest. |
| 82 | func (v viewer) signedIn() bool { return v.ok && !v.person.Anonymous } |
| 83 | |
| 84 | // viewerOf resolves whoever the request is for without requiring |
| 85 | // anybody. Behind requireParticipant that is the person already in the |
| 86 | // context. Anywhere else (an error page, Open source, the invitation |
| 87 | // page) the session is read optionally and without side effects: an |
| 88 | // error page must never create a persons row or end a session, so a |
| 89 | // session that no longer resolves is simply signed out here, and the |
| 90 | // next guarded page is where it is dealt with. |
| 91 | func (a *app) viewerOf(r *http.Request) viewer { |
| 92 | if p, ok := currentPerson(r); ok { |
| 93 | s, _ := sessions.Current(r) |
| 94 | return viewer{person: p, method: s.Method, ok: true} |
| 95 | } |
| 96 | id, ok := a.auth.SessionFrom(r) |
| 97 | if !ok { |
| 98 | return viewer{} |
| 99 | } |
| 100 | p, err := a.lookupPerson(r, sessions.Session{Subject: id.Address, Method: string(id.Method)}) |
| 101 | if err != nil { |
| 102 | return viewer{} |
| 103 | } |
| 104 | return viewer{person: p, method: string(id.Method), ok: true} |
| 105 | } |
| 106 | |
| 107 | // lookupPerson is personForSession without its one write: a first |
| 108 | // sign-in's persons row is created by the guarded pages, never by chrome. |
| 109 | func (a *app) lookupPerson(r *http.Request, s sessions.Session) (Person, error) { |
| 110 | switch s.Method { |
| 111 | case "magiclink", "keymail": |
| 112 | return a.store.existingHomePerson(r.Context(), s.Subject) |
| 113 | } |
| 114 | return a.personForSession(r.Context(), s) |
| 115 | } |
| 116 | |
| 117 | // page builds render's data for a page: data plus "Frame", the chrome, |
| 118 | // and "Viewer", which the room's own menu reads. |
| 119 | func (a *app) page(r *http.Request, f frame, data map[string]any) map[string]any { |
| 120 | if data == nil { |
| 121 | data = map[string]any{} |
| 122 | } |
| 123 | var v viewer |
| 124 | if !f.signinPage { |
| 125 | v = a.viewerOf(r) |
| 126 | } |
| 127 | if v.signedIn() { |
| 128 | data["Viewer"] = Viewer{Email: v.person.Email, Member: a.isMember(v.person)} |
| 129 | } |
| 130 | data["Frame"] = a.frameFor(r, v, f) |
| 131 | return data |
| 132 | } |
| 133 | |
| 134 | // Viewer is the signed-in person, for the room's title menu: who they |
| 135 | // are, and whether the team's pages (Meetings, Settings) are theirs. |
| 136 | type Viewer struct { |
| 137 | Email string |
| 138 | Member bool |
| 139 | } |
| 140 | |
| 141 | // frameFor builds a page's Frame for viewer v. |
| 142 | func (a *app) frameFor(r *http.Request, v viewer, f frame) Frame { |
| 143 | bar := chrome.Bar{ |
| 144 | App: chrome.App{ |
| 145 | Name: appName, |
| 146 | Slug: "calls", |
| 147 | Home: "/", |
| 148 | Icon: assets.Path("static/brand/icon.svg"), |
| 149 | }, |
| 150 | Context: f.context, |
| 151 | Labels: chrome.BarLabels{Skip: "Skip to content", Menu: "Menu"}, |
| 152 | } |
| 153 | switch { |
| 154 | case f.signinPage: |
| 155 | bar.App.Home = "" |
| 156 | case v.signedIn(): |
| 157 | bar.Account = a.account(r, v) |
| 158 | bar.Switcher = a.switcher() |
| 159 | if a.isMember(v.person) { |
| 160 | bar.Nav = a.nav(r) |
| 161 | bar.NavLabel = "Calls" |
| 162 | } |
| 163 | case f.public: |
| 164 | // A guest, or nobody, on the invitation page. |
| 165 | bar.App.Home = "" |
| 166 | bar.SignIn = &chrome.Link{Href: signinPath, Label: "Sign in"} |
| 167 | default: |
| 168 | // Nobody, or a guest (spec §8.4: guests get the same corner). |
| 169 | bar.SignIn = &chrome.Link{Href: signinPath + "?return_to=" + url.QueryEscape(returnTarget(r)), Label: "Sign in"} |
| 170 | } |
| 171 | return Frame{ |
| 172 | Chrome: bar, |
| 173 | TitleParts: f.parts, |
| 174 | Theme: chrome.ThemeAttr(chrome.Appearance(r)), |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | // nav is the team's two pages, with the one being shown marked. |
| 179 | func (a *app) nav(r *http.Request) []chrome.Link { |
| 180 | return []chrome.Link{ |
| 181 | {Href: "/", Label: "Meetings", Current: r.URL.Path == "/"}, |
| 182 | {Href: "/settings", Label: "Settings", Current: r.URL.Path == "/settings"}, |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | // returnTarget is where a person should come back to: this page, if it |
| 187 | // is a page (a GET), otherwise the meeting list. |
| 188 | func returnTarget(r *http.Request) string { |
| 189 | if r.Method != http.MethodGet && r.Method != http.MethodHead { |
| 190 | return "/" |
| 191 | } |
| 192 | return r.URL.RequestURI() |
| 193 | } |
| 194 | |
| 195 | // account is the account menu (spec §8.2): who is signed in, "Connect |
| 196 | // your Oficina account" when that means something, Appearance, Help |
| 197 | // (Open source, last), and the one Sign out in the app. Settings is in |
| 198 | // the bar's nav rather than here: it is the team's meeting defaults and |
| 199 | // agents, not this person's account, and a second copy of the link would |
| 200 | // be a second label to keep in step. |
| 201 | func (a *app) account(r *http.Request, v viewer) *chrome.Account { |
| 202 | acc := &chrome.Account{ |
| 203 | TriggerLabel: "Account: " + v.person.Email, |
| 204 | Initials: chrome.Initials("", v.person.Email), |
| 205 | Address: v.person.Email, |
| 206 | Labels: chrome.AccountLabels{SignedInAs: "Signed in as", Appearance: "Appearance", Help: "Help"}, |
| 207 | Appearance: &chrome.AppearanceMenu{ |
| 208 | Action: "/appearance", |
| 209 | Return: returnTarget(r), |
| 210 | Options: chrome.AppearanceOptions(chrome.Appearance(r), "Light", "Dark", "System"), |
| 211 | }, |
| 212 | Help: []estilomenu.Item{{Href: "/help/open-source", Label: "Open source"}}, |
| 213 | SignOut: chrome.SignOut{Action: "/signout", Label: "Sign out"}, |
| 214 | } |
| 215 | if a.offersHomeLink(v) { |
| 216 | acc.Items = []estilomenu.Item{{Href: "/account/home", Label: "Connect your Oficina account"}} |
| 217 | } |
| 218 | return acc |
| 219 | } |
| 220 | |
| 221 | // offersHomeLink is whether the account menu offers to connect the |
| 222 | // person's Oficina account: only with Home configured, only to a member |
| 223 | // (the page is behind requireMember), and never to somebody who signed |
| 224 | // in through Home, whose account already is the connection. |
| 225 | func (a *app) offersHomeLink(v viewer) bool { |
| 226 | return a.home != nil && v.signedIn() && a.isMember(v.person) && (v.method == "magiclink" || v.method == "keymail") |
| 227 | } |
| 228 | |
| 229 | // switcher is the app switcher, or nil when no hub directory is |
| 230 | // configured. It shows "All apps" alone and no tiles (Paul's §13 answer |
| 231 | // 2), as Docs, Sheets and Slides do. |
| 232 | func (a *app) switcher() *chrome.Switcher { |
| 233 | if a.cfg.home == nil || a.cfg.home.Directory == "" { |
| 234 | return nil |
| 235 | } |
| 236 | return &chrome.Switcher{ |
| 237 | Label: "Oficina apps", |
| 238 | CurrentLabel: "(this app)", |
| 239 | AllApps: estilomenu.Item{Href: a.cfg.home.Directory + "/", Label: "All apps"}, |
| 240 | } |
| 241 | } |
| 242 | |
| 243 | // roomMenu is the call window's title menu: the account menu's items in |
| 244 | // its order and its words, for a page that has no bar. Standing is the |
| 245 | // one line only the room has. |
| 246 | type roomMenu struct { |
| 247 | Identity string |
| 248 | Nav []chrome.Link |
| 249 | Help []estilomenu.Item |
| 250 | SignOut *chrome.SignOut |
| 251 | } |
| 252 | |
| 253 | func (a *app) roomMenuFor(fr Frame) roomMenu { |
| 254 | m := roomMenu{Nav: fr.Chrome.Nav} |
| 255 | if acc := fr.Chrome.Account; acc != nil { |
| 256 | m.Identity = acc.Address |
| 257 | m.Help = acc.Help |
| 258 | m.SignOut = &acc.SignOut |
| 259 | } else { |
| 260 | m.Help = []estilomenu.Item{{Href: "/help/open-source", Label: "Open source"}} |
| 261 | } |
| 262 | return m |
| 263 | } |
| 264 | |