oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1package 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
19import (
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>.
29const 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>.
34type 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).
42type 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.
61func 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).
66func 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.
75type 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.
82func (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.
91func (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.
109func (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.
119func (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.
136type Viewer struct {
137 Email string
138 Member bool
139}
140
141// frameFor builds a page's Frame for viewer v.
142func (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.
179func (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.
188func 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.
201func (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.
225func (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.
232func (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.
246type roomMenu struct {
247 Identity string
248 Nav []chrome.Link
249 Help []estilomenu.Item
250 SignOut *chrome.SignOut
251}
252
253func (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