tinyrelayfile
tinyrelay/tinyclient/templates.go/

tinyclient/templates.go raw
1package tinyclient
2
3import (
4 "crypto/sha256"
5 "embed"
6 "encoding/hex"
7 "encoding/json"
8 "fmt"
9 "html/template"
10 "net/url"
11 "sort"
12 "strings"
13 "sync"
14 "time"
15)
16
17// templateFS holds every page template. page.html owns the shell and the
18// shared partials; the other files each define one group of tabs.
19//
20//go:embed page.html public.html manage.html browse.html repo.html collaboration.html wiki.html rooms.html social.html chat.html chat-activity.html
21var templateFS embed.FS
22
23// styleCSS is inlined into every page so the UI needs no extra request and
24// works unchanged under a tenant path prefix. It styles ids, elements and the
25// custom element names only; templates never carry class attributes.
26//
27//go:embed style.css
28var styleCSS string
29
30// bridgeJS is the signer bridge and componentsJS defines the custom
31// elements. Both are inlined at the end of the body in that order.
32//
33//go:embed bridge.js
34var bridgeJS string
35
36//go:embed tiny.js
37var tinyJS string
38
39//go:embed components.js
40var componentsJS string
41
42//go:embed rooms.js
43var roomsJS string
44
45//go:embed chat-presence.js
46var chatPresenceJS string
47
48//go:embed chat-activity.js
49var chatActivityJS string
50
51//go:embed grant-request.js
52var grantRequestJS string
53
54//go:embed chat.js
55var chatJS string
56
57//go:embed chat-files.js
58var chatFilesJS string
59
60//go:embed direct-messages.js
61var directMessagesJS string
62
63// nostr-name.js is the vendored <nostr-name> element with the relay-local
64// profile loader; rebuild it with npm run build:nostr-name.
65//
66//go:embed nostr-name.js
67var nostrNameJS string
68
69//go:embed blossom-encryption.js
70var blossomEncryptionJS string
71
72//go:embed blossom-manifests.js
73var blossomManifestsJS string
74
75//go:embed file-messages.js
76var fileMessagesJS string
77
78//go:embed private-services.js
79var privateServicesJS string
80
81//go:embed blossom-upload.js
82var blossomUploadJS string
83
84//go:embed file-workspace.js
85var fileWorkspaceJS string
86
87//go:embed file-catalog.js
88var fileCatalogJS string
89
90// Installable app assets. The manifest is a template: NAME and BASE are
91// replaced per request so tenant prefixes and relay names stay correct.
92//
93//go:embed manifest.webmanifest
94var manifestTemplate string
95
96//go:embed icon-192.png
97var icon192PNG []byte
98
99//go:embed icon-512.png
100var icon512PNG []byte
101
102//go:embed icon-maskable-512.png
103var iconMaskablePNG []byte
104
105//go:embed apple-touch-icon.png
106var appleTouchIconPNG []byte
107
108//go:embed badge-96.png
109var badgePNG []byte
110
111//go:embed screenshot-narrow.png
112var screenshotNarrowPNG []byte
113
114//go:embed screenshot-wide.png
115var screenshotWidePNG []byte
116
117//go:embed sw.js
118var serviceWorkerJS []byte
119
120//go:embed icon.svg
121var iconSVG []byte
122
123//go:embed icon-mono.svg
124var iconMonoSVG []byte
125
126// navItem is one rail entry. Tab matches PageData.Tab for the active state;
127// Group separates related destinations with a small gap.
128type navItem struct {
129 Label, Href, Tab string
130 Group int
131}
132
133// relayNav groups discovery, conversations, shared work and administration.
134var relayNav = []navItem{
135 {"/home", "/", "home", 0},
136 {"/search", "/search", "search", 0},
137 {"/social", "/social", "social", 1},
138 {"/chat", "/chat", "chat", 1},
139 {"/repos", "/repos", "repos", 2},
140 {"/wiki", "/wiki", "wiki", 2},
141 {"/files", "/files", "files", 2},
142 {"/sites", "/sites", "sites", 2},
143 {"/approvals", "/approvals", "approvals", 3},
144 {"/manage", "/manage/people", "manage", 3},
145}
146
147var manageNav = []navItem{{"/people", "/manage/people", "people", 0}, {"/agents", "/manage/agents", "agents", 0}, {"/moderation", "/manage/moderation", "moderation", 1}, {"/rules", "/manage/rules", "rules", 1}, {"/identity", "/manage/identity", "identity", 2}, {"/connect", "/manage/connect", "connect", 2}, {"/owner", "/manage/owner", "owner", 2}, {"/sync", "/manage/sync", "sync", 3}, {"/data", "/manage/data", "data", 3}, {"/views", "/manage/views", "views", 3}, {"/health", "/manage/health", "health", 4}}
148
149// navGroups splits a rail list into its groups, in order.
150func navGroups(items []navItem) [][]navItem {
151 var groups [][]navItem
152 for _, item := range items {
153 if len(groups) == 0 || groups[len(groups)-1][0].Group != item.Group {
154 groups = append(groups, nil)
155 }
156 groups[len(groups)-1] = append(groups[len(groups)-1], item)
157 }
158 return groups
159}
160
161// railKind picks the sitemap for a tab: one repository, the rooms,
162// management, or the relay.
163func railKind(tab string) string {
164 if tab == "repo" {
165 return "repo"
166 }
167 if tab == "chat" || tab == "direct" || tab == "rooms" || tab == "room" || tab == "thread" {
168 return "rooms"
169 }
170 for _, item := range manageNav {
171 if item.Tab == tab {
172 return "manage"
173 }
174 }
175 return "relay"
176}
177
178// promptPath renders the footer prompt: the path after the relay name, with
179// repository pages spelled out as repos/<name>/<view>.
180func promptPath(path string, query url.Values) string {
181 path = strings.Trim(path, "/")
182 switch {
183 case path == "":
184 return "home"
185 case path == "repo" && query.Get("repo") != "":
186 return "repos/" + query.Get("repo") + "/" + repoView(query)
187 case path == "file" && (query.Get("sha") != "" || query.Get("hash") != ""):
188 return "files/" + shortID(query.Get("sha")+query.Get("hash"))
189 }
190 if room := roomRoute("/" + path); room.tab == "thread" {
191 return "rooms/" + room.id + "/thread/" + shortID(room.event)
192 }
193 if strings.HasPrefix(path, "wiki/") {
194 return "wiki/" + wikiPageName("/"+path)
195 }
196 return path
197}
198
199// pageAddress is the address the crumb copies: the relay's public URL plus
200// the page's canonical path, so a link copied on a tailnet or LAN address
201// still opens for anyone. Wiki names are normalized the way the relay
202// resolves them; repositories and files keep the query that names them.
203func pageAddress(base, path string, query url.Values) string {
204 path = "/" + strings.Trim(path, "/")
205 if path == "/" {
206 path = ""
207 }
208 if strings.HasPrefix(path, "/wiki/") {
209 path = "/wiki/" + url.PathEscape(wikiPageName(path))
210 }
211 keep := url.Values{}
212 for _, key := range []string{"owner", "repo", "view", "path", "ref", "sha", "hash", "q", "id", "author", "version", "merge", "address", "kind"} {
213 if value := query.Get(key); value != "" {
214 keep.Set(key, value)
215 }
216 }
217 address := strings.TrimSuffix(base, "/") + path
218 if len(keep) > 0 {
219 address += "?" + keep.Encode()
220 }
221 return address
222}
223
224// wsURL turns the relay's public http(s) URL into its websocket form.
225func wsURL(base string) string {
226 switch {
227 case strings.HasPrefix(base, "https://"):
228 return "wss://" + strings.TrimPrefix(base, "https://")
229 case strings.HasPrefix(base, "http://"):
230 return "ws://" + strings.TrimPrefix(base, "http://")
231 }
232 return base
233}
234
235// short renders a Unix timestamp as a compact UTC stamp for list rows.
236func short(value any) string {
237 seconds := unixSeconds(value)
238 if seconds <= 0 {
239 return ""
240 }
241 return time.Unix(seconds, 0).UTC().Format("Jan 2 15:04")
242}
243
244func repoView(query url.Values) string {
245 if view := query.Get("view"); view != "" {
246 return view
247 }
248 return "tree"
249}
250
251// scripts are served at /scripts/<name> and cached by the service worker.
252// One version stamp covers them all, so a change to any file refreshes every
253// cached copy together.
254var scripts = map[string]string{"bridge.js": bridgeJS, "tiny.js": tinyJS, "components.js": componentsJS, "rooms.js": roomsJS, "chat.js": chatJS, "chat-presence.js": chatPresenceJS, "chat-activity.js": chatActivityJS, "grant-request.js": grantRequestJS, "chat-files.js": chatFilesJS, "direct-messages.js": directMessagesJS, "nostr-name.js": nostrNameJS, "blossom-encryption.js": blossomEncryptionJS, "blossom-manifests.js": blossomManifestsJS, "blossom-upload.js": blossomUploadJS, "file-messages.js": fileMessagesJS, "private-services.js": privateServicesJS, "file-workspace.js": fileWorkspaceJS, "file-catalog.js": fileCatalogJS}
255
256var scriptsVersion = func() string {
257 names := make([]string, 0, len(scripts))
258 for name := range scripts {
259 names = append(names, name)
260 }
261 sort.Strings(names)
262 digest := sha256.New()
263 for _, name := range names {
264 digest.Write([]byte(name))
265 digest.Write([]byte{0})
266 digest.Write([]byte(scripts[name]))
267 }
268 return hex.EncodeToString(digest.Sum(nil))[:12]
269}()
270
271var (
272 templateBaseOnce sync.Once
273 templateBase *template.Template
274 templateBaseErr error
275)
276
277// parseTemplates clones the immutable parsed template set and installs the
278// request backend's rendering helpers. Parsing is expensive, while the
279// helpers must remain bound to the current App and cannot be shared safely.
280func parseTemplates(a *App) (*template.Template, error) {
281 templateBaseOnce.Do(func() { templateBase, templateBaseErr = parseTemplateBase() })
282 if templateBaseErr != nil {
283 return nil, templateBaseErr
284 }
285 tmpl, err := templateBase.Clone()
286 if err != nil {
287 return nil, fmt.Errorf("clone web templates: %w", err)
288 }
289 tmpl = tmpl.Funcs(template.FuncMap{
290 "socialBody": a.socialBody,
291 "markdown": a.markdown,
292 "chatMarkdown": a.chatMarkdown,
293 "wikiHTML": a.wikiHTML,
294 })
295 return tmpl, nil
296}
297
298func parseTemplateBase() (*template.Template, error) {
299 funcs := template.FuncMap{
300 "stylesheet": func() template.CSS { return template.CSS(styleCSS) },
301 "scriptURL": func(name string) (string, error) {
302 if _, ok := scripts[name]; !ok {
303 return "", fmt.Errorf("unknown script %q", name)
304 }
305 return "/scripts/" + name + "?v=" + scriptsVersion, nil
306 },
307 "scriptsVersion": func() string { return scriptsVersion },
308 "json": func(value any) string {
309 encoded, err := json.Marshal(value)
310 if err != nil {
311 return "null"
312 }
313 return string(encoded)
314 },
315 "join": strings.Join,
316 "urlquery": url.QueryEscape,
317 "asMap": valueMap,
318 "str": plainString,
319 "datetime": datetime,
320 "when": when,
321 "fileSize": fileSize,
322 "socialThreadPage": socialThreadPage,
323 "socialReactionCount": socialReactionCount,
324 "socialReacted": socialReacted,
325 "socialContext": socialContext,
326 "socialQuery": socialQuery,
327 "socialURL": socialURL,
328 "socialThreadURL": socialURL,
329 "socialBody": func(any) template.HTML { return "" },
330 "socialPreview": socialPreview,
331 "socialMediaURL": socialMediaURL,
332 "avatarURL": avatarURL,
333 "roomAvatarURL": roomAvatarURL,
334 "mobileHeader": mobileHeader,
335 "markdown": func(string) template.HTML { return "" },
336 "chatMarkdown": func(string) template.HTML { return "" },
337 "hasPrefix": strings.HasPrefix,
338 "npub": identityNpub,
339 "wsURL": wsURL,
340 "short": short,
341 "prompt": promptPath,
342 "pageAddress": pageAddress,
343 "signinURL": signinURL,
344 "railKind": railKind,
345 "repoView": repoView,
346 "relayItems": func() []navItem { return relayNav },
347 "manageItems": func() []navItem { return manageNav },
348 "navGroups": navGroups,
349 "add": func(a, b int) int { return a + b },
350 "strs": func(values ...string) []string { return values },
351 "repoCommitURL": repoCommitURL,
352 "hasNextOffset": hasNextOffset,
353 "repoURL": repoURL,
354 "repoBreadcrumbs": repoBreadcrumbs,
355 "browsePageURL": browsePageURL,
356 "shortID": shortID,
357 "repoDate": repoDate,
358 "sourceHTML": sourceHTML,
359 "diffHTML": diffHTML,
360 "collabItems": collaborationItems,
361 "collabReplies": collaborationReplies,
362 "collabLabels": collaborationLabels,
363 "collabProposal": collaborationProposal,
364 "reviewDiff": reviewDiffView,
365 "reviewAnchor": reviewAnchorQuery,
366 "reviewLabel": reviewAnchorLabel,
367 "agentState": agentState,
368 "agentSince": agentSince,
369 "agentScope": agentScope,
370 "agentLabel": agentLabel,
371 "agentCounts": agentCounts,
372 "pendingRequests": pendingRequests,
373 "decidedRequests": decidedRequests,
374 "callbacksFor": callbacksFor,
375 "callbackState": callbackState,
376 "callbackKinds": callbackKinds,
377 "callbackCounts": callbackCounts,
378 "scopeList": scopeList,
379 "dateAfter": dateAfter,
380 "agentByKey": agentByKey,
381 "repoLines": repoLines,
382 "siteLines": siteLines,
383 "dateOf": dateOf,
384 "approvalViews": approvalViews,
385 "approvalCounts": approvalCounts,
386 "approvalDevices": approvalDevices,
387 "wikiHTML": func(any) template.HTML { return "" },
388 "viewState": customViewState,
389 "viewKinds": customViewKinds,
390 "viewLanguages": customViewLanguages,
391 "wikiView": wikiPageView,
392 "wikiProposal": wikiProposal,
393 "wikiURL": wikiURL,
394 "roomItems": roomItems,
395 "roomRoot": roomRoot,
396 "roomMembers": roomMembers,
397 "roomContent": roomContent,
398 "age": age,
399 "clock": clock,
400 "roomAdmin": roomAdmin,
401
402 "roomAttachmentContent": roomAttachmentContent,
403 "roomAttachmentMarkup": roomAttachmentMarkup,
404 }
405 tmpl, err := template.New("webui").Funcs(funcs).ParseFS(templateFS, "*.html")
406 if err != nil {
407 return nil, fmt.Errorf("parse web templates: %w", err)
408 }
409 return tmpl, nil
410}
411
412func valueMap(value any) map[string]any {
413 if result, ok := value.(map[string]any); ok {
414 return result
415 }
416 encoded, _ := json.Marshal(value)
417 var result map[string]any
418 _ = json.Unmarshal(encoded, &result)
419 return result
420}
421
422// plainString prints a decoded JSON value for display: nil becomes empty,
423// whole floats print without a fraction, and everything else uses fmt.
424func plainString(value any) string {
425 switch value := value.(type) {
426 case nil:
427 return ""
428 case string:
429 return value
430 case float64:
431 if value == float64(int64(value)) {
432 return fmt.Sprintf("%d", int64(value))
433 }
434 return fmt.Sprintf("%v", value)
435 default:
436 return fmt.Sprint(value)
437 }
438}
439
440// datetime renders a Unix timestamp as UTC text suitable for a time element.
441func datetime(value any) string {
442 seconds := unixSeconds(value)
443 if seconds <= 0 {
444 return ""
445 }
446 return time.Unix(seconds, 0).UTC().Format("2006-01-02T15:04:05Z")
447}
448
449// when renders a Unix timestamp as short UTC text for people to read.
450func when(value any) string {
451 seconds := unixSeconds(value)
452 if seconds <= 0 {
453 return ""
454 }
455 return time.Unix(seconds, 0).UTC().Format("2006-01-02 15:04 UTC")
456}
457
458func unixSeconds(value any) int64 {
459 switch value := value.(type) {
460 case float64:
461 return int64(value)
462 case int64:
463 return value
464 case int:
465 return int64(value)
466 }
467 return 0
468}
469
470// tableRows renders escaped table head and body markup for fragments and
471// streams that swap into an existing table element.
472func tableRows(headers []string, rows [][]string) string {
473 var out strings.Builder
474 out.WriteString(`<thead><tr>`)
475 for _, header := range headers {
476 out.WriteString(`<th scope="col">` + template.HTMLEscapeString(header) + `</th>`)
477 }
478 out.WriteString(`</tr></thead><tbody>`)
479 if len(rows) == 0 {
480 out.WriteString(`<tr><td colspan="` + fmt.Sprint(len(headers)) + `">No entries.</td></tr>`)
481 }
482 for _, row := range rows {
483 out.WriteString(`<tr>`)
484 for _, cell := range row {
485 out.WriteString(`<td>` + template.HTMLEscapeString(cell) + `</td>`)
486 }
487 out.WriteString(`</tr>`)
488 }
489 out.WriteString(`</tbody>`)
490 return out.String()
491}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs