tinyrelaycommit

Commit d870709cf7d1

browse files at this commit | history

1diff --git a/internal/webui/page.html b/internal/webui/page.html
2index 89084c5..ba01645 100644
3--- a/internal/webui/page.html
4+++ b/internal/webui/page.html
5@@ -56,6 +56,9 @@
6 {{else if eq .Tab "wikipage"}}{{template "wikipage" .}}
7 {{else if eq .Tab "file"}}{{template "file" .}}
8 {{else if eq .Tab "approvals"}}{{template "approvals" .}}
9+{{else if eq .Tab "rooms"}}{{template "rooms" .}}
10+{{else if eq .Tab "room"}}{{template "room" .}}
11+{{else if eq .Tab "thread"}}{{template "thread" .}}
12 {{else if eq .Tab "event"}}{{template "event" .}}
13 {{else if eq .Tab "view"}}{{template "view" .}}
14 {{else if eq .Tab "invite"}}{{template "invite" .}}
15@@ -85,15 +88,20 @@
16 <nav>{{$v := repoView .Query}}<a href="{{repoURL .Query "home" ""}}"{{if eq $v "home"}} aria-current="page"{{end}}>/home</a><a href="{{repoURL .Query "tree" ""}}"{{if or (eq $v "tree") (eq $v "file")}} aria-current="page"{{end}}>/code</a><a href="{{repoURL .Query "history" ""}}"{{if or (eq $v "history") (eq $v "commit")}} aria-current="page"{{end}}>/history</a><a href="{{repoURL .Query "issues" ""}}"{{if or (eq $v "issues") (eq $v "issue")}} aria-current="page"{{end}}>/issues</a><a href="{{repoURL .Query "prs" ""}}"{{if or (eq $v "prs") (eq $v "pr")}} aria-current="page"{{end}}>/prs</a><a href="{{repoURL .Query "activity" ""}}"{{if eq $v "activity"}} aria-current="page"{{end}}>/activity</a></nav>
17 {{else if eq $kind "manage"}}<header><span><a href="/">{{.Slug}}</a>/<b>manage</b></span></header>
18 {{range $group := navGroups manageItems}}<nav>{{range $item := $group}}<a href="{{$item.Href}}"{{if eq $item.Tab $.Tab}} aria-current="page"{{end}}>{{$item.Label}}</a>{{end}}</nav>{{end}}
19+{{else if eq $kind "rooms"}}<header><span><a href="/">{{.Slug}}</a>/<a href="/rooms"><b>rooms</b></a></span></header>
20+<h4>Rooms</h4>
21+<nav id="room-list">{{range .Rooms}}{{$r := asMap .}}<a href="/rooms/{{str (index $r "id")}}"{{if eq (str (index $r "id")) $.View}} aria-current="page"{{end}}>{{or (str (index $r "name")) (str (index $r "id"))}}{{with age (index $r "last_message_at")}} <small>{{.}}</small>{{end}}</a>{{else}}<a href="/rooms"{{if eq $.Tab "rooms"}} aria-current="page"{{end}}>/rooms</a>{{end}}</nav>
22+<p><a href="/rooms#new-room">+ new room</a></p>
23 {{else}}<header><span><a id="mark" href="/" title="{{.Slug}}"><i>t</i><i>i</i><i>n</i><i>y</i></a></span></header>
24 {{range $group := navGroups relayItems}}<nav>{{range $item := $group}}<a href="{{$item.Href}}"{{if eq $item.Tab $.Tab}} aria-current="page"{{end}}>{{$item.Label}}</a>{{end}}</nav>{{end}}
25-{{end}}<p id="rail-tools">{{if eq $kind "repo"}}<a href="/repos">&larr; all repositories</a>{{else if eq $kind "manage"}}<a href="/">&larr; relay</a>{{end}}<button id="theme" type="button" aria-label="Switch theme">theme</button></p>{{end}}
26+{{end}}<p id="rail-tools">{{if eq $kind "repo"}}<a href="/repos">&larr; all repositories</a>{{else if or (eq $kind "manage") (eq $kind "rooms")}}<a href="/">&larr; relay</a>{{end}}<button id="theme" type="button" aria-label="Switch theme">theme</button></p>{{end}}
27
28 {{/* The panel holds facts and filters for the current page. */}}
29 {{define "panel"}}{{$kind := railKind .Tab}}{{if .Private}}{{template "panel-signin" .}}
30 {{else if eq .Tab "repo"}}{{template "panel-repo" .}}
31 {{else if eq .Tab "agents"}}{{template "panel-agents" .}}
32 {{else if eq $kind "manage"}}{{template "panel-manage" .}}
33+{{else if eq $kind "rooms"}}{{template "panel-rooms" .}}
34 {{else if or (eq .Tab "inbox") (eq .Tab "outbox") (eq .Tab "search") (eq .Tab "articles")}}{{template "panel-feed" .}}
35 {{else if eq .Tab "repos"}}{{template "panel-repos" .}}
36 {{else if or (eq .Tab "files") (eq .Tab "file")}}{{template "panel-files" .}}
37@@ -135,6 +143,16 @@
38 <section><h4>Machine access</h4><table><tr><th>mcp</th><td><code>/mcp</code> 2026-07-28</td></tr><tr><th>auth</th><td>NIP-98 per request</td></tr><tr><th>rest</th><td><code>/events</code> <code>/query</code></td></tr></table></section>
39 <section><h4>Kill switch</h4><rpc-form method="pauseallagents" refresh><button>Pause all agents</button></rpc-form><rpc-form method="resumeallagents" refresh><button>Resume all agents</button></rpc-form><p><small>Pausing keeps grants and rejects writes until resumed.</small></p></section>
40 <section><h4>Session</h4><table><tr><th>you</th><td>{{if .Actor}}{{template "nostrKey" .Actor}}{{else}}guest{{end}}</td></tr><tr><th>role</th><td>{{if .Owner}}owner{{else if .Actor}}member{{else}}none{{end}}</td></tr></table></section>{{end}}
41+{{define "panel-rooms"}}{{if eq .Tab "rooms"}}<section><h4>Rooms</h4><table><tr><th>rooms</th><td>{{len .Rooms}}</td></tr><tr><th>protocol</th><td>NIP-29</td></tr></table></section>
42+<section><h4>Actions</h4><ul>{{if .Actor}}<li><a href="/rooms#new-room">new room</a></li>{{else}}<li><a href="/signin">sign in</a></li>{{end}}</ul></section>
43+{{else if .Error}}<section><h4>Room</h4><table><tr><th>id</th><td><code>{{.View}}</code></td></tr></table></section>
44+{{else}}{{$data := asMap .Event}}{{$room := asMap (index $data "room")}}{{$id := str (index $room "id")}}{{$role := str (index $room "role")}}<section><h4>Room</h4><table><tr><th>id</th><td><code>{{$id}}</code></td></tr><tr><th>access</th><td>{{if eq (str (index $room "access")) "members"}}members only{{else}}open{{end}}</td></tr><tr><th>created</th><td>{{short (index $room "created_at")}}</td></tr>{{if $role}}<tr><th>you</th><td>{{$role}}</td></tr>{{end}}</table></section>
45+{{if eq .Tab "thread"}}<section><h4>Actions</h4><ul><li><a href="/rooms/{{$id}}">open room</a></li></ul></section>
46+{{else}}<section><h4>Members</h4><ul id="members">{{range roomMembers .Event}}{{$m := asMap .}}<li{{if index $m "agent"}} data-agent{{end}}>{{template "nostrKey" (str (index $m "pubkey"))}} <small>{{str (index $m "role")}}{{if index $m "agent"}} | agent{{end}}</small></li>{{else}}<li>{{str (index $room "members")}} members</li>{{end}}</ul></section>
47+{{if .Actor}}<section><h4>Actions</h4>{{if roomAdmin $role}}<details><summary>invite member</summary><room-action room="{{$id}}" kind="9000"><label>pubkey <input name="pubkey" placeholder="npub or 64 hex" required></label><label>role <select name="role"><option value="member">member</option><option value="admin">admin</option>{{if eq $role "owner"}}<option value="owner">owner</option>{{end}}</select></label><button>Add member</button></room-action></details>
48+<details><summary>room settings</summary><room-action room="{{$id}}" kind="9002"><label>name <input name="name" value="{{str (index $room "name")}}" required maxlength="200"></label><label>about <input name="about" value="{{str (index $room "about")}}" maxlength="2000"></label><label>picture <input name="picture" value="{{str (index $room "picture")}}" placeholder="https://"></label><label>access <select name="access"><option value="open"{{if ne (str (index $room "access")) "members"}} selected{{end}}>open</option><option value="members"{{if eq (str (index $room "access")) "members"}} selected{{end}}>members only</option></select></label><button>Save</button></room-action></details>
49+{{end}}{{if $role}}<room-action room="{{$id}}" kind="9022"><button>Leave room</button></room-action>{{else}}<room-action room="{{$id}}" kind="9021"><button>Join room</button></room-action>{{end}}<noscript><p>Room actions need JavaScript and a connected signer.</p></noscript></section>
50+{{end}}{{end}}{{end}}{{end}}
51
52 {{/* Shared partials. */}}
53
54diff --git a/internal/webui/rooms.go b/internal/webui/rooms.go
55new file mode 100644
56index 0000000..621ef0d
57--- /dev/null
58+++ b/internal/webui/rooms.go
59@@ -0,0 +1,295 @@
60+package webui
61+
62+import (
63+ "context"
64+ "encoding/json"
65+ "html/template"
66+ "regexp"
67+ "sort"
68+ "strings"
69+ "time"
70+)
71+
72+// roomPath is one rooms page: the list, one room, or one thread in a room.
73+type roomPath struct {
74+ tab string
75+ id string
76+ event string
77+}
78+
79+var (
80+ roomIDPattern = regexp.MustCompile(`^[a-z0-9_-]{1,64}$`)
81+ eventIDPattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
82+ roomLinkPattern = regexp.MustCompile(`https?://[^\s<>"']+|(?:web\+)?nostr:[a-z0-9]+`)
83+)
84+
85+// roomRoute maps /rooms, /rooms/<id> and /rooms/<id>/thread/<event> to a
86+// page. The stream path belongs to the daemon and is not a page.
87+func roomRoute(path string) roomPath {
88+ if path == "/rooms" {
89+ return roomPath{tab: "rooms"}
90+ }
91+ rest, ok := strings.CutPrefix(path, "/rooms/")
92+ if !ok {
93+ return roomPath{}
94+ }
95+ parts := strings.Split(rest, "/")
96+ if !roomIDPattern.MatchString(parts[0]) {
97+ return roomPath{}
98+ }
99+ switch {
100+ case len(parts) == 1:
101+ return roomPath{tab: "room", id: parts[0]}
102+ case len(parts) == 3 && parts[1] == "thread" && eventIDPattern.MatchString(parts[2]):
103+ return roomPath{tab: "thread", id: parts[0], event: parts[2]}
104+ }
105+ return roomPath{}
106+}
107+
108+// roomList reads the rooms the viewer may see for the rail. A failure
109+// leaves the rail empty rather than failing the page.
110+func (a *App) roomList(ctx context.Context, actor string) []any {
111+ raw, _ := json.Marshal(map[string]any{"limit": 100})
112+ result, err := a.backend.Query(ctx, "browserooms", []json.RawMessage{raw}, actor)
113+ if err != nil {
114+ return nil
115+ }
116+ return browseRows(result)
117+}
118+
119+// roomSlice normalizes a typed slice from the in-process backend into the
120+// generic rows a JSON client would receive.
121+func roomSlice(value any, field string) []any {
122+ return collaborationSlice(value, field)
123+}
124+
125+func roomMembers(value any) []any { return roomSlice(value, "members") }
126+
127+// memberIndex maps member pubkeys to their rows so a message can carry the
128+// author's role and agent marker.
129+func memberIndex(value any) map[string]map[string]any {
130+ index := map[string]map[string]any{}
131+ for _, row := range roomMembers(value) {
132+ member := valueMap(row)
133+ if pubkey := plainString(member["pubkey"]); pubkey != "" {
134+ index[pubkey] = member
135+ }
136+ }
137+ return index
138+}
139+
140+// replyCounts counts the thread replies on this page by their root id.
141+func replyCounts(value any) map[string]int {
142+ counts := map[string]int{}
143+ for _, row := range roomSlice(value, "messages") {
144+ e := valueMap(row)
145+ if plainString(e["kind"]) != "12" {
146+ continue
147+ }
148+ if roots := tagValues(row, "e"); len(roots) > 0 {
149+ counts[roots[0]]++
150+ }
151+ }
152+ return counts
153+}
154+
155+// reaction is one line of a message's reaction summary.
156+type reaction struct {
157+ Content string
158+ Count int
159+}
160+
161+// reactionSummary groups the page's reactions by the message they answer.
162+func reactionSummary(value any) map[string][]reaction {
163+ counts := map[string]map[string]int{}
164+ for _, row := range roomSlice(value, "messages") {
165+ e := valueMap(row)
166+ if plainString(e["kind"]) != "7" {
167+ continue
168+ }
169+ targets := tagValues(row, "e")
170+ if len(targets) == 0 {
171+ continue
172+ }
173+ target := targets[len(targets)-1]
174+ content := strings.TrimSpace(plainString(e["content"]))
175+ if content == "" || content == "+" {
176+ content = "+1"
177+ }
178+ if counts[target] == nil {
179+ counts[target] = map[string]int{}
180+ }
181+ counts[target][content]++
182+ }
183+ summary := map[string][]reaction{}
184+ for target, byContent := range counts {
185+ for content, count := range byContent {
186+ summary[target] = append(summary[target], reaction{Content: content, Count: count})
187+ }
188+ sort.Slice(summary[target], func(i, j int) bool {
189+ if summary[target][i].Count != summary[target][j].Count {
190+ return summary[target][i].Count > summary[target][j].Count
191+ }
192+ return summary[target][i].Content < summary[target][j].Content
193+ })
194+ }
195+ return summary
196+}
197+
198+// tagValues lists the second field of every tag with the given name.
199+func tagValues(value any, name string) []string {
200+ var out []string
201+ var tags [][]string
202+ if encoded, err := json.Marshal(valueMap(value)["tags"]); err == nil {
203+ _ = json.Unmarshal(encoded, &tags)
204+ }
205+ for _, tag := range tags {
206+ if len(tag) > 1 && tag[0] == name {
207+ out = append(out, tag[1])
208+ }
209+ }
210+ return out
211+}
212+
213+// roomContent renders message text as escaped HTML with http(s) URLs and
214+// nostr links turned into anchors. Nostr links open through /open so the
215+// relay resolves them to a page.
216+func roomContent(text string) template.HTML {
217+ var out strings.Builder
218+ last := 0
219+ for _, match := range roomLinkPattern.FindAllStringIndex(text, -1) {
220+ out.WriteString(template.HTMLEscapeString(text[last:match[0]]))
221+ link := text[match[0]:match[1]]
222+ trimmed := strings.TrimRight(link, ".,;:!?)")
223+ rest := link[len(trimmed):]
224+ href := trimmed
225+ if !strings.HasPrefix(trimmed, "http") {
226+ href = "/open?target=" + template.URLQueryEscaper(trimmed)
227+ }
228+ out.WriteString(`<a href="` + template.HTMLEscapeString(href) + `"`)
229+ if strings.HasPrefix(trimmed, "http") {
230+ out.WriteString(` rel="noopener"`)
231+ }
232+ out.WriteString(`>` + template.HTMLEscapeString(trimmed) + `</a>` + template.HTMLEscapeString(rest))
233+ last = match[1]
234+ }
235+ out.WriteString(template.HTMLEscapeString(text[last:]))
236+ return template.HTML(out.String())
237+}
238+
239+// age renders how long ago a Unix timestamp was, compactly, for the rail.
240+func age(value any) string {
241+ seconds := unixSeconds(value)
242+ if seconds <= 0 {
243+ return ""
244+ }
245+ elapsed := time.Since(time.Unix(seconds, 0))
246+ switch {
247+ case elapsed < time.Minute:
248+ return "now"
249+ case elapsed < time.Hour:
250+ return plainString(int(elapsed.Minutes())) + "m"
251+ case elapsed < 24*time.Hour:
252+ return plainString(int(elapsed.Hours())) + "h"
253+ default:
254+ return plainString(int(elapsed.Hours()/24)) + "d"
255+ }
256+}
257+
258+// clock renders a message time: the UTC clock time today, otherwise the day
259+// and time.
260+func clock(value any) string {
261+ seconds := unixSeconds(value)
262+ if seconds <= 0 {
263+ return ""
264+ }
265+ stamp := time.Unix(seconds, 0).UTC()
266+ if stamp.Format("2006-01-02") == time.Now().UTC().Format("2006-01-02") {
267+ return stamp.Format("15:04")
268+ }
269+ return stamp.Format("Jan 2 15:04")
270+}
271+
272+// roomItem is one message as the page shows it: the event's fields, the
273+// author's markers, the thread state and the reactions it has received.
274+type roomItem struct {
275+ ID, PubKey, Kind, Content, Room, Root, Role, Notice string
276+ CreatedAt int64
277+ Agent, InThread bool
278+ Mentions []string
279+ Replies int
280+ Reactions []reaction
281+}
282+
283+// roomItems turns a browse result's messages or replies into view items,
284+// oldest first. Reactions and edits become summaries on their targets.
285+func roomItems(value any, field string) []roomItem {
286+ data := valueMap(value)
287+ room := plainString(valueMap(data["room"])["id"])
288+ members := memberIndex(value)
289+ replies := replyCounts(value)
290+ reactions := reactionSummary(value)
291+ rows := roomSlice(value, field)
292+ items := make([]roomItem, 0, len(rows))
293+ for i := len(rows) - 1; i >= 0; i-- {
294+ item, ok := newRoomItem(rows[i], room, members)
295+ if !ok {
296+ continue
297+ }
298+ item.Replies = replies[item.ID]
299+ item.Reactions = reactions[item.ID]
300+ item.InThread = field == "replies"
301+ items = append(items, item)
302+ }
303+ return items
304+}
305+
306+// roomRoot is the thread page's root message, with the page's replies counted.
307+func roomRoot(value any) roomItem {
308+ data := valueMap(value)
309+ item, _ := newRoomItem(data["root"], plainString(valueMap(data["room"])["id"]), memberIndex(value))
310+ item.Replies = len(roomSlice(value, "replies"))
311+ item.InThread = true
312+ return item
313+}
314+
315+func newRoomItem(row any, room string, members map[string]map[string]any) (roomItem, bool) {
316+ e := valueMap(row)
317+ item := roomItem{ID: plainString(e["id"]), PubKey: plainString(e["pubkey"]), Kind: plainString(e["kind"]), Content: plainString(e["content"]), Room: room, CreatedAt: unixSeconds(e["created_at"])}
318+ switch item.Kind {
319+ case "7", "40003":
320+ return roomItem{}, false
321+ case "44100", "44101":
322+ who := tagValues(row, "p")
323+ if len(who) == 0 {
324+ return roomItem{}, false
325+ }
326+ item.PubKey = who[0]
327+ item.Notice = "joined the room"
328+ if item.Kind == "44101" {
329+ item.Notice = "left the room"
330+ }
331+ case "12":
332+ if roots := tagValues(row, "e"); len(roots) > 0 {
333+ item.Root = roots[0]
334+ }
335+ }
336+ if member := members[item.PubKey]; member != nil {
337+ item.Role = plainString(member["role"])
338+ item.Agent, _ = member["agent"].(bool)
339+ }
340+ if item.Notice == "" {
341+ for _, mention := range tagValues(row, "p") {
342+ if eventIDPattern.MatchString(mention) && mention != item.PubKey {
343+ item.Mentions = append(item.Mentions, mention)
344+ }
345+ }
346+ }
347+ return item, item.ID != ""
348+}
349+
350+// roomAdmin reports whether a room role may administer the room.
351+func roomAdmin(role any) bool {
352+ value := plainString(role)
353+ return value == "owner" || value == "admin"
354+}
355diff --git a/internal/webui/rooms.html b/internal/webui/rooms.html
356new file mode 100644
357index 0000000..c160d8e
358--- /dev/null
359+++ b/internal/webui/rooms.html
360@@ -0,0 +1,64 @@
361+{{/* Rooms: the list, one room's timeline with its compose bar, and one thread. */}}
362+{{define "rooms"}}<section id="rooms">
363+<h1>Rooms</h1>
364+{{if .Error}}{{template "alert" .Error}}
365+{{else if .Feed}}<table id="rows">
366+<thead><tr><th>room</th><th>access</th><th>members</th><th>last message</th></tr></thead>
367+<tbody>
368+{{range .Feed}}{{$r := asMap .}}<tr><td><a href="/rooms/{{str (index $r "id")}}">{{or (str (index $r "name")) (str (index $r "id"))}}</a></td><td>{{if eq (str (index $r "access")) "members"}}members only{{else}}open{{end}}{{with str (index $r "about")}} <small>{{.}}</small>{{end}}</td><td>{{str (index $r "members")}}</td><td>{{with index $r "last_message_at"}}<time datetime="{{datetime .}}">{{when .}}</time>{{else}}no messages{{end}}</td></tr>
369+{{end}}</tbody>
370+</table>
371+{{else}}<p>No rooms yet.</p>
372+{{end}}{{with (asMap .Event).next_cursor}}<p id="pages"><a href="{{browsePageURL "/rooms" $.Query "cursor" .}}">next page</a></p>
373+{{end}}{{if .Actor}}<h2 id="new-room">New room</h2>
374+<room-create><label>Name <input name="name" required maxlength="200" placeholder="build"></label><label>Access <select name="access"><option value="open">open</option><option value="members">members only</option></select></label><label>About <textarea name="about" maxlength="2000"></textarea></label><button>Create room</button></room-create>
375+<noscript><p>Creating a room needs JavaScript and a connected signer.</p></noscript>
376+{{else}}<p><a href="/signin">Sign in</a> to create a room.</p>
377+{{end}}</section>
378+{{end}}
379+
380+{{define "room"}}{{if .Error}}<section id="room">
381+<h1>#{{.View}}</h1>
382+{{template "alert" .Error}}
383+{{if not .Actor}}<p><a href="/signin">Sign in</a> to read this room.</p>{{end}}
384+</section>
385+{{else}}{{$data := asMap .Event}}{{$room := asMap (index $data "room")}}{{$id := str (index $room "id")}}<section id="room">
386+<h1>{{or (str (index $room "name")) $id}} <small>{{if eq (str (index $room "access")) "members"}}members only{{else}}open room{{end}}</small></h1>
387+{{with str (index $room "about")}}<p>{{.}}</p>{{end}}
388+{{with index $data "next_cursor"}}<p id="pages"><a href="/rooms/{{$id}}?cursor={{urlquery (str .)}}">load earlier</a></p>{{end}}
389+{{if .Query.Get "cursor"}}<p id="newest"><a href="/rooms/{{$id}}">newest messages</a></p>{{end}}
390+<div id="messages">{{range roomItems .Event "messages"}}
391+{{template "roomMessage" .}}{{else}}<p id="empty">No messages yet.</p>{{end}}
392+</div>
393+{{if not (.Query.Get "cursor")}}<room-live room="{{$id}}"></room-live>
394+{{if .Actor}}<room-compose room="{{$id}}" pubkey="{{.Actor}}"><textarea name="content" rows="2" placeholder="Message #{{$id}}. Mention with @npub or @hex." required></textarea><p><button>Send</button></p></room-compose>
395+<noscript><p>Sending needs JavaScript and a connected signer.</p></noscript>
396+{{else}}<p id="guest"><a href="/signin">Sign in</a> to send messages.</p>
397+{{end}}{{end}}</section>
398+{{end}}{{end}}
399+
400+{{define "thread"}}{{if .Error}}<section id="thread">
401+<h1>Thread</h1>
402+{{template "alert" .Error}}
403+</section>
404+{{else}}{{$data := asMap .Event}}{{$room := asMap (index $data "room")}}{{$id := str (index $room "id")}}{{$root := roomRoot .Event}}<section id="thread">
405+<p id="crumbs"><a href="/rooms/{{$id}}">{{or (str (index $room "name")) $id}}</a></p>
406+<h1>Thread <small>{{$root.Replies}} replies</small></h1>
407+<div id="root">{{template "roomMessage" $root}}</div>
408+<div id="messages">{{range roomItems .Event "replies"}}
409+{{template "roomMessage" .}}{{else}}<p id="empty">No replies yet.</p>{{end}}
410+</div>
411+{{with index $data "next_cursor"}}<p id="pages"><a href="/rooms/{{$id}}/thread/{{$root.ID}}?cursor={{urlquery (str .)}}">load earlier</a></p>{{end}}
412+{{if not (.Query.Get "cursor")}}<room-live room="{{$id}}" root="{{$root.ID}}"></room-live>
413+{{if .Actor}}<room-compose room="{{$id}}" pubkey="{{.Actor}}" kind="12" root="{{$root.ID}}" root-pubkey="{{$root.PubKey}}"><textarea name="content" rows="2" placeholder="Reply in this thread" required></textarea><p><button>Reply</button></p></room-compose>
414+<noscript><p>Replying needs JavaScript and a connected signer.</p></noscript>
415+{{else}}<p id="guest"><a href="/signin">Sign in</a> to reply.</p>
416+{{end}}{{end}}</section>
417+{{end}}{{end}}
418+
419+{{/* One message. room-live builds the same markup for messages that arrive on the stream. */}}
420+{{define "roomMessage"}}<room-message id="msg-{{.ID}}" data-id="{{.ID}}" data-kind="{{.Kind}}" data-pubkey="{{.PubKey}}"{{if .Agent}} data-agent{{end}}{{if .Notice}} data-notice{{end}}>
421+<header><b>{{template "nostrKey" .PubKey}}</b>{{if .Role}} | {{.Role}}{{end}}<small><time datetime="{{datetime .CreatedAt}}" title="{{when .CreatedAt}}">{{clock .CreatedAt}}</time></small></header>
422+<p>{{if .Notice}}{{.Notice}}{{else}}{{roomContent .Content}}{{end}}</p>
423+{{if or .Mentions (and (not .InThread) (or (eq .Kind "11") .Root)) .Reactions}}<footer>{{if .Mentions}}<span>to {{range .Mentions}}{{template "nostrKey" .}} {{end}}</span>{{end}}{{if not .InThread}}{{if eq .Kind "11"}}<a href="/rooms/{{.Room}}/thread/{{.ID}}">thread{{if .Replies}} | {{.Replies}} replies{{end}}</a>{{else if .Root}}<a href="/rooms/{{.Room}}/thread/{{.Root}}">in thread</a>{{end}}{{end}}{{range .Reactions}}<span data-reaction="{{.Content}}">{{.Content}} {{.Count}}</span>{{end}}</footer>{{end}}
424+</room-message>{{end}}
425diff --git a/internal/webui/rooms_test.go b/internal/webui/rooms_test.go
426new file mode 100644
427index 0000000..bd002fa
428--- /dev/null
429+++ b/internal/webui/rooms_test.go
430@@ -0,0 +1,245 @@
431+package webui
432+
433+import (
434+ "context"
435+ "encoding/json"
436+ "errors"
437+ "net/http"
438+ "net/http/httptest"
439+ "strings"
440+ "testing"
441+
442+ "github.com/FelineStateMachine/tinyrelay/internal/policy"
443+)
444+
445+var (
446+ roomOwner = strings.Repeat("a", 64)
447+ roomAgent = strings.Repeat("b", 64)
448+ roomGuest = strings.Repeat("c", 64)
449+ roomHello = strings.Repeat("1", 64)
450+ roomChat = strings.Repeat("2", 64)
451+ roomThread = strings.Repeat("3", 64)
452+ roomReply = strings.Repeat("4", 64)
453+ roomLike = strings.Repeat("5", 64)
454+)
455+
456+// roomsBackend answers the room browse calls for an open room and a
457+// members-only room. Guests see the open room only.
458+type roomsBackend struct {
459+ fakeBackend
460+ calls []string
461+}
462+
463+func roomEvent(id, pubkey string, kind int, createdAt int64, content string, tags ...[]string) map[string]any {
464+ return map[string]any{"id": id, "pubkey": pubkey, "kind": kind, "created_at": createdAt, "content": content, "tags": tags, "sig": ""}
465+}
466+
467+func roomRecord(id, name, access, role string) map[string]any {
468+ return map[string]any{"id": id, "name": name, "about": "Where " + name + " happens.", "picture": "", "access": access, "created_by": roomOwner, "created_at": 1757200000, "event_id": "", "members": 2, "last_message_at": 1757203600, "role": role}
469+}
470+
471+func (b *roomsBackend) Query(_ context.Context, method string, params []json.RawMessage, actor string) (any, error) {
472+ b.calls = append(b.calls, method+":"+actor)
473+ q := map[string]any{}
474+ if len(params) > 0 {
475+ _ = json.Unmarshal(params[0], &q)
476+ }
477+ role := ""
478+ if actor != "" {
479+ role = "owner"
480+ }
481+ id, _ := q["id"].(string)
482+ switch method {
483+ case "browserooms":
484+ items := []any{roomRecord("general", "General", "open", role)}
485+ if actor != "" {
486+ items = append(items, roomRecord("build", "Build", "members", role))
487+ }
488+ return map[string]any{"items": items, "next_cursor": ""}, nil
489+ case "browseroom", "browsethread":
490+ if id == "build" && actor == "" {
491+ return nil, errors.New("auth-required: this room is members-only")
492+ }
493+ if id != "general" && id != "build" {
494+ return nil, errors.New("not found: room")
495+ }
496+ default:
497+ return nil, nil
498+ }
499+ members := []any{map[string]any{"pubkey": roomOwner, "role": "owner", "added_at": 1757200000}, map[string]any{"pubkey": roomAgent, "role": "member", "added_at": 1757200100, "agent": true}}
500+ root := roomEvent(roomThread, roomAgent, 11, 1757203000, "Release notes draft", []string{"h", id})
501+ reply := roomEvent(roomReply, roomOwner, 12, 1757203300, "Looks good", []string{"h", id}, []string{"e", roomThread}, []string{"p", roomAgent})
502+ if method == "browsethread" {
503+ return map[string]any{"room": roomRecord(id, "General", "open", ""), "root": root, "replies": []any{reply}, "next_cursor": ""}, nil
504+ }
505+ messages := []any{
506+ roomEvent(roomLike, roomOwner, 7, 1757203600, "+", []string{"h", id}, []string{"e", roomChat}),
507+ reply,
508+ root,
509+ roomEvent(roomChat, roomOwner, 9, 1757202000, "@agent take a look <script>alert(1)</script>", []string{"h", id}, []string{"p", roomAgent}),
510+ roomEvent(roomHello, roomAgent, 9, 1757201000, "hello, see https://example.com/docs. and nostr:npub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc2", []string{"h", id}),
511+ }
512+ return map[string]any{"room": roomRecord(id, strings.ToUpper(id[:1])+id[1:], map[string]string{"general": "open", "build": "members"}[id], role), "members": members, "messages": messages, "next_cursor": "cursor-1"}, nil
513+}
514+
515+func roomsApp(t *testing.T, actor string) (*App, *roomsBackend) {
516+ t.Helper()
517+ backend := &roomsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(roomOwner)}}
518+ app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return actor, nil }})
519+ if err != nil {
520+ t.Fatal(err)
521+ }
522+ return app, backend
523+}
524+
525+func roomsPage(t *testing.T, app *App, path string) string {
526+ t.Helper()
527+ recorder := httptest.NewRecorder()
528+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
529+ if recorder.Code != http.StatusOK {
530+ t.Fatalf("%s: status=%d", path, recorder.Code)
531+ }
532+ body := recorder.Body.String()
533+ if strings.Contains(body, "class=") {
534+ t.Errorf("%s carries a class attribute", path)
535+ }
536+ content := body[strings.Index(body, `id="content"`):strings.Index(body, `id="aside"`)]
537+ if sections := strings.Count(content, "<section"); sections != 1 {
538+ t.Errorf("%s renders %d sections in the content column, want 1", path, sections)
539+ }
540+ return body
541+}
542+
543+func TestRoomsListRendersRoomsRailAndCreateForm(t *testing.T) {
544+ app, backend := roomsApp(t, roomOwner)
545+ body := roomsPage(t, app, "/rooms")
546+ for _, want := range []string{`<table id="rows">`, `<a href="/rooms/general">General</a>`, `<a href="/rooms/build">Build</a>`, `members only <small>Where Build happens.</small>`, `<room-create>`, `<a href="/rooms"><b>rooms</b></a>`, `<nav id="room-list"><a href="/rooms/general">General`, `<a href="/rooms#new-room">+ new room</a>`, `<p id="rail-tools"><a href="/">&larr; relay</a>`, `<h4>Rooms</h4>`, `<td>2</td>`} {
547+ if !strings.Contains(body, want) {
548+ t.Errorf("rooms list missing %q", want)
549+ }
550+ }
551+ if backend.calls[0] != "browserooms:"+roomOwner {
552+ t.Fatalf("calls = %v", backend.calls)
553+ }
554+ guestApp, _ := roomsApp(t, "")
555+ body = roomsPage(t, guestApp, "/rooms")
556+ if strings.Contains(body, "<room-create>") || !strings.Contains(body, `<a href="/signin">Sign in</a> to create a room.`) || strings.Contains(body, `href="/rooms/build"`) {
557+ t.Fatalf("guest rooms list: %s", body[strings.Index(body, `id="content"`):])
558+ }
559+ // The relay rail lists rooms first in the conversation group.
560+ home := roomsPage(t, app, "/")
561+ rooms, inbox, search := strings.Index(home, `href="/rooms"`), strings.Index(home, `href="/inbox"`), strings.Index(home, `href="/search"`)
562+ if rooms < 0 || inbox < 0 || !(search < rooms && rooms < inbox) {
563+ t.Fatalf("relay rail order: search=%d rooms=%d inbox=%d", search, rooms, inbox)
564+ }
565+}
566+
567+func TestRoomPageRendersMessagesOldestFirstWithMarkers(t *testing.T) {
568+ app, backend := roomsApp(t, roomOwner)
569+ body := roomsPage(t, app, "/rooms/general")
570+ for _, want := range []string{
571+ `<h1>General <small>open room</small></h1>`, `<p>Where General happens.</p>`,
572+ `<a href="/rooms/general?cursor=cursor-1">load earlier</a>`,
573+ `<room-message id="msg-` + roomHello + `" data-id="` + roomHello + `" data-kind="9" data-pubkey="` + roomAgent + `" data-agent>`,
574+ `<a href="https://example.com/docs" rel="noopener">https://example.com/docs</a>.`,
575+ `<a href="/open?target=nostr%3Anpub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc2">nostr:npub1`,
576+ `&lt;script&gt;alert(1)&lt;/script&gt;`,
577+ `<span>to <nostr-key hex="` + roomAgent + `"`,
578+ `<span data-reaction="&#43;1">&#43;1 1</span>`,
579+ `<a href="/rooms/general/thread/` + roomThread + `">thread | 1 replies</a>`,
580+ `<a href="/rooms/general/thread/` + roomThread + `">in thread</a>`,
581+ `<room-live room="general"></room-live>`,
582+ `<room-compose room="general" pubkey="` + roomOwner + `">`,
583+ `<noscript><p>Sending needs JavaScript and a connected signer.</p></noscript>`,
584+ `<ul id="members"><li><nostr-key hex="` + roomOwner + `"`, `<li data-agent><nostr-key hex="` + roomAgent + `"`, `<small>member | agent</small>`,
585+ `<room-action room="general" kind="9000">`, `<room-action room="general" kind="9002">`, `<room-action room="general" kind="9022">`,
586+ `<nav id="room-list"><a href="/rooms/general" aria-current="page">General`,
587+ `&raquo; rooms/general</span>`,
588+ } {
589+ if !strings.Contains(body, want) {
590+ t.Errorf("room page missing %q", want)
591+ }
592+ }
593+ hello, chat, thread, reply := strings.Index(body, `id="msg-`+roomHello), strings.Index(body, `id="msg-`+roomChat), strings.Index(body, `id="msg-`+roomThread), strings.Index(body, `id="msg-`+roomReply)
594+ if !(hello < chat && chat < thread && thread < reply) {
595+ t.Fatalf("messages are not oldest first: %d %d %d %d", hello, chat, thread, reply)
596+ }
597+ if strings.Contains(body, `data-kind="7"`) || strings.Contains(body, "<script>alert") {
598+ t.Fatal("reactions rendered as messages or content unescaped")
599+ }
600+ if len(backend.calls) != 2 || backend.calls[0] != "browseroom:"+roomOwner || backend.calls[1] != "browserooms:"+roomOwner {
601+ t.Fatalf("calls = %v", backend.calls)
602+ }
603+ guestApp, _ := roomsApp(t, "")
604+ body = roomsPage(t, guestApp, "/rooms/general")
605+ if strings.Contains(body, "<room-compose") || strings.Contains(body, "<room-action") || !strings.Contains(body, `<a href="/signin">Sign in</a> to send messages.`) || !strings.Contains(body, `id="msg-`+roomHello) {
606+ t.Fatalf("guest room page: %s", body[strings.Index(body, `id="content"`):])
607+ }
608+}
609+
610+func TestMembersOnlyRoomIsHiddenFromGuests(t *testing.T) {
611+ guestApp, _ := roomsApp(t, "")
612+ body := roomsPage(t, guestApp, "/rooms/build")
613+ if !strings.Contains(body, `<p role="alert">auth-required: this room is members-only</p>`) || strings.Contains(body, "<room-message") || strings.Contains(body, "<room-compose") || !strings.Contains(body, `<a href="/signin">Sign in</a> to read this room.`) {
614+ t.Fatalf("guest saw a members-only room: %s", body[strings.Index(body, `id="content"`):])
615+ }
616+ if !strings.Contains(body, `<h1>#build</h1>`) || strings.Contains(body, "Where Build happens") {
617+ t.Fatal("guest error page leaked room details")
618+ }
619+ app, _ := roomsApp(t, roomOwner)
620+ body = roomsPage(t, app, "/rooms/build")
621+ if !strings.Contains(body, `<h1>Build <small>members only</small></h1>`) || !strings.Contains(body, `id="msg-`+roomHello) {
622+ t.Fatal("owner did not see the members-only room")
623+ }
624+}
625+
626+func TestThreadPageRendersRootRepliesAndReplyCompose(t *testing.T) {
627+ app, _ := roomsApp(t, roomOwner)
628+ body := roomsPage(t, app, "/rooms/general/thread/"+roomThread)
629+ for _, want := range []string{
630+ `<p id="crumbs"><a href="/rooms/general">General</a></p>`, `<h1>Thread <small>1 replies</small></h1>`,
631+ `<div id="root"><room-message id="msg-` + roomThread + `"`, `<div id="messages">`, `id="msg-` + roomReply + `"`,
632+ `<room-live room="general" root="` + roomThread + `"></room-live>`,
633+ `<room-compose room="general" pubkey="` + roomOwner + `" kind="12" root="` + roomThread + `" root-pubkey="` + roomAgent + `">`,
634+ `&raquo; rooms/general/thread/` + shortID(roomThread) + `</span>`,
635+ } {
636+ if !strings.Contains(body, want) {
637+ t.Errorf("thread page missing %q", want)
638+ }
639+ }
640+ if strings.Contains(body, `">thread</a>`) || strings.Contains(body, ">in thread</a>") {
641+ t.Fatal("thread page links back to itself")
642+ }
643+}
644+
645+func TestRoomRoutesRejectMalformedPaths(t *testing.T) {
646+ for path, want := range map[string]roomPath{
647+ "/rooms": {tab: "rooms"},
648+ "/rooms/general": {tab: "room", id: "general"},
649+ "/rooms/General": {},
650+ "/rooms/a/b": {},
651+ "/rooms/a/thread/xyz": {},
652+ "/rooms/a/thread/" + roomThread: {tab: "thread", id: "a", event: roomThread},
653+ "/rooms/a/stream": {},
654+ } {
655+ if got := roomRoute(path); got != want {
656+ t.Errorf("roomRoute(%q) = %+v, want %+v", path, got, want)
657+ }
658+ }
659+ app, backend := roomsApp(t, roomOwner)
660+ recorder := httptest.NewRecorder()
661+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/rooms/Not%20A%20Room", nil))
662+ for _, call := range backend.calls {
663+ if strings.HasPrefix(call, "browseroom") {
664+ t.Fatalf("malformed room path reached the backend: %v", backend.calls)
665+ }
666+ }
667+}
668+
669+func TestRoomContentLinksAndEscapes(t *testing.T) {
670+ got := string(roomContent(`<b>hi</b> https://a.example/x?y=1) then nostr:note1abc, done`))
671+ want := `&lt;b&gt;hi&lt;/b&gt; <a href="https://a.example/x?y=1" rel="noopener">https://a.example/x?y=1</a>) then <a href="/open?target=nostr%3Anote1abc">nostr:note1abc</a>, done`
672+ if got != want {
673+ t.Fatalf("roomContent:\n got %s\nwant %s", got, want)
674+ }
675+}
676diff --git a/internal/webui/style.css b/internal/webui/style.css
677index e25561d..9785c97 100644
678--- a/internal/webui/style.css
679+++ b/internal/webui/style.css
680@@ -239,6 +239,37 @@ wiki-compose output{display:block;font:12px var(--mono);color:var(--muted);margi
681 /* Sign in. */
682 #signer-qr:not([hidden]){display:block;margin:8px 0;background:#fff;padding:8px;border:1px solid var(--line)}
683
684+/* Rooms: the rail lists rooms, messages read oldest first, and the compose bar stays at the bottom of the column. */
685+#rail h4{margin:16px 0 4px;padding:0 2ch 0 2.5ch;font:11px var(--mono);color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
686+#room-list a{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}#room-list a small{float:right;color:var(--muted);font-size:11px}
687+#rooms #rows td:first-child{width:220px}#rooms #rows td:nth-child(3){text-align:right;width:80px;font:12px var(--mono)}
688+#content>section#room,#content>section#thread{padding-bottom:0}
689+#room h1 small,#thread h1 small{font:12px var(--mono);color:var(--muted)}
690+#crumbs,#newest,#empty{font:12px var(--mono);color:var(--muted)}
691+#messages,#root{max-width:720px}
692+room-message{display:block;padding:8px 0;border-bottom:1px solid var(--line)}
693+room-message>header{font:12px var(--mono);color:var(--muted);margin-bottom:2px;user-select:none;-webkit-user-select:none}
694+room-message>header b{color:var(--ink);font-weight:500}room-message>header b nostr-key{cursor:copy}
695+room-message>header small{float:right}
696+room-message>p{margin:0;font:14px/1.45 var(--sans);white-space:pre-wrap;overflow-wrap:anywhere}
697+room-message>p a{color:var(--accent)}
698+room-message>footer{font:12px var(--mono);color:var(--muted);margin-top:4px}
699+room-message>footer>*+*::before{content:" | "}
700+room-message[data-agent]>header b::after{content:" agent";color:var(--bark);font-size:11px}
701+room-message[data-notice]>p{color:var(--muted);font-size:13px}
702+room-live{display:block;font:11px var(--mono);color:var(--muted);text-align:right}room-live:empty{display:none}
703+room-compose{display:block;position:sticky;bottom:0;margin:8px -22px 0;padding:10px 22px 8px;background:var(--paper);border-top:1px solid var(--line)}
704+room-compose textarea{min-height:3.2em;width:100%;font:14px var(--sans);max-width:720px;display:block}
705+room-compose p{margin:6px 0 0}room-compose button{margin-right:6px}
706+room-compose output,room-create output,room-action output{display:block;font:12px var(--mono);color:var(--muted);margin-top:4px;overflow-wrap:anywhere}
707+room-compose output[data-error],room-create output[data-error],room-action output[data-error]{color:var(--alert)}
708+room-create{display:block;max-width:720px;border-bottom:1px solid var(--line);padding:10px 0}
709+room-create label{display:inline-block;margin:0 10px 6px 0;vertical-align:top;font:13px var(--sans);color:var(--muted)}
710+room-create label input,room-create label select{display:block;margin-top:2px;font:13px var(--mono);min-width:200px}room-create label:has(textarea){display:block}
711+room-action{display:block}#panel room-action label{margin-top:6px}#panel room-action button{margin-top:8px}
712+#members li[data-agent] small{color:var(--bark)}
713+#panel #members li{font:12px var(--mono);line-height:22px}
714+
715 /* Phones: a top bar with a menu, the rail slides in, the panel stacks under the content. */
716 @media(max-width:720px){
717 html,body{overflow:auto}
718@@ -264,4 +295,6 @@ html,body{overflow:auto}
719 #rows{table-layout:fixed}#rows td{white-space:normal;overflow-wrap:anywhere;height:auto;padding:8px 6px;line-height:1.35}#rows td:first-child{width:90px}#rows td:last-child{width:80px}#rows:has(td[data-state]) td:nth-child(3),#rows:has(td[data-state]) th:nth-child(3){display:none}#rows:has(td[data-state]) td:last-child{width:80px}connect-list #rows td:nth-child(2),connect-list #rows th:nth-child(2){display:none}connect-list #rows td:nth-child(3),connect-list #rows td:last-child{width:auto}connect-list #rows td:last-child{white-space:normal}connect-list #rows td:last-child button{margin:2px 0}
720 #grid,#gridhead,h2:has(+#gridhead){display:none}
721 #wiki-list #rows th{white-space:normal}#wiki-list #rows td:nth-child(3),#wiki-list #rows th:nth-child(3){display:none}#wiki-list #rows td:nth-child(4){width:70px}
722+room-compose{bottom:calc(28px + env(safe-area-inset-bottom,0px));margin:8px -2.5ch 0;padding:10px 2.5ch 8px;z-index:2}
723+body:has(#room) #layout>tbody>tr>td#aside,body:has(#thread) #layout>tbody>tr>td#aside{display:none}
724 }
725diff --git a/internal/webui/templates.go b/internal/webui/templates.go
726index 661d629..c8a6c20 100644
727--- a/internal/webui/templates.go
728+++ b/internal/webui/templates.go
729@@ -16,7 +16,7 @@ import (
730 // templateFS holds every page template. page.html owns the shell and the
731 // shared partials; the other files each define one group of tabs.
732 //
733-//go:embed page.html public.html manage.html browse.html repo.html collaboration.html wiki.html
734+//go:embed page.html public.html manage.html browse.html repo.html collaboration.html wiki.html rooms.html
735 var templateFS embed.FS
736
737 // styleCSS is inlined into every page so the UI needs no extra request and
738@@ -102,7 +102,7 @@ type navItem struct {
739
740 // relayNav: entry points, then conversation, then collaborative artifacts,
741 // then what the relay publishes and stores, then what is yours.
742-var relayNav = []navItem{{"/home", "/", "home", 0}, {"/search", "/search", "search", 0}, {"/inbox", "/inbox", "inbox", 1}, {"/approvals", "/approvals", "approvals", 1}, {"/repos", "/repos", "repos", 2}, {"/wiki", "/wiki", "wiki", 2}, {"/files", "/files", "files", 3}, {"/articles", "/articles", "articles", 3}, {"/sites", "/sites", "sites", 3}, {"/outbox", "/outbox", "outbox", 4}, {"/manage", "/manage/people", "manage", 4}}
743+var relayNav = []navItem{{"/home", "/", "home", 0}, {"/search", "/search", "search", 0}, {"/rooms", "/rooms", "rooms", 1}, {"/inbox", "/inbox", "inbox", 1}, {"/approvals", "/approvals", "approvals", 1}, {"/repos", "/repos", "repos", 2}, {"/wiki", "/wiki", "wiki", 2}, {"/files", "/files", "files", 3}, {"/articles", "/articles", "articles", 3}, {"/sites", "/sites", "sites", 3}, {"/outbox", "/outbox", "outbox", 4}, {"/manage", "/manage/people", "manage", 4}}
744
745 // manageNav: who is here, what they may do, what the relay is, what it does
746 // over time, and how it is doing.
747@@ -120,11 +120,15 @@ func navGroups(items []navItem) [][]navItem {
748 return groups
749 }
750
751-// railKind picks the sitemap for a tab: one repository, management, or the relay.
752+// railKind picks the sitemap for a tab: one repository, the rooms,
753+// management, or the relay.
754 func railKind(tab string) string {
755 if tab == "repo" {
756 return "repo"
757 }
758+ if tab == "rooms" || tab == "room" || tab == "thread" {
759+ return "rooms"
760+ }
761 for _, item := range manageNav {
762 if item.Tab == tab {
763 return "manage"
764@@ -145,6 +149,9 @@ func promptPath(path string, query url.Values) string {
765 case path == "file" && (query.Get("sha") != "" || query.Get("hash") != ""):
766 return "files/" + shortID(query.Get("sha")+query.Get("hash"))
767 }
768+ if room := roomRoute("/" + path); room.tab == "thread" {
769+ return "rooms/" + room.id + "/thread/" + shortID(room.event)
770+ }
771 return path
772 }
773
774@@ -256,6 +263,13 @@ func parseTemplates() (*template.Template, error) {
775 "wikiHTML": wikiHTML,
776 "wikiView": wikiPageView,
777 "wikiURL": wikiURL,
778+ "roomItems": roomItems,
779+ "roomRoot": roomRoot,
780+ "roomMembers": roomMembers,
781+ "roomContent": roomContent,
782+ "age": age,
783+ "clock": clock,
784+ "roomAdmin": roomAdmin,
785 }
786 tmpl, err := template.New("webui").Funcs(funcs).ParseFS(templateFS, "*.html")
787 if err != nil {
788diff --git a/internal/webui/webui.go b/internal/webui/webui.go
789index 14324b6..74e4b1b 100644
790--- a/internal/webui/webui.go
791+++ b/internal/webui/webui.go
792@@ -90,6 +90,8 @@ type PageData struct {
793 // Connections lists the ways to open this relay in client apps that the
794 // viewer may see, with link placeholders already resolved.
795 Connections []any
796+ // Rooms lists the rooms the viewer may see; the rooms rail renders it.
797+ Rooms []any
798 }
799
800 func New(backend Backend, options Options) (*App, error) {
801@@ -160,7 +162,7 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
802 http.Redirect(writer, request, requestPrefix(request)+"/manage/health", http.StatusMovedPermanently)
803 return
804 }
805- if request.Method == http.MethodGet && (request.URL.Path == "/repos" || request.URL.Path == "/repo" || request.URL.Path == "/files" || request.URL.Path == "/file" || request.URL.Path == "/approvals" || request.URL.Path == "/wiki" || strings.HasPrefix(request.URL.Path, "/wiki/") || request.URL.Path == "/manage/health") {
806+ if request.Method == http.MethodGet && (request.URL.Path == "/repos" || request.URL.Path == "/repo" || request.URL.Path == "/files" || request.URL.Path == "/file" || request.URL.Path == "/approvals" || request.URL.Path == "/wiki" || strings.HasPrefix(request.URL.Path, "/wiki/") || request.URL.Path == "/manage/health" || roomRoute(request.URL.Path).tab != "") {
807 a.browse(writer, request)
808 return
809 }
810@@ -531,6 +533,15 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
811 case "/manage/health":
812 method = "browsestatus"
813 }
814+ room := roomRoute(path)
815+ switch room.tab {
816+ case "rooms":
817+ method = "browserooms"
818+ case "room":
819+ method = "browseroom"
820+ case "thread":
821+ method = "browsethread"
822+ }
823 if strings.HasPrefix(path, "/wiki/") {
824 method = "browsewikipage"
825 }
826@@ -594,6 +605,10 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
827 query = map[string]any{"d": pageQuery.Get("d"), "author": request.URL.Query().Get("author"), "version": request.URL.Query().Get("version")}
828 case "browsestatus":
829 query = map[string]any{}
830+ case "browserooms":
831+ query = map[string]any{"cursor": request.URL.Query().Get("cursor"), "limit": 100}
832+ case "browseroom", "browsethread":
833+ query = map[string]any{"id": room.id, "event": room.event, "cursor": request.URL.Query().Get("cursor"), "limit": 50}
834 }
835 raw, _ := json.Marshal(query)
836 params = append(params, raw)
837@@ -603,7 +618,11 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
838 }
839 result, err := a.backend.Query(request.Context(), method, params, actor)
840 if err != nil {
841- a.render(writer, request, PageData{Tab: browseTab(path), Error: err.Error(), Query: pageQuery})
842+ data := PageData{Tab: browseTab(path), Error: err.Error(), Query: pageQuery, View: room.id}
843+ if room.tab != "" {
844+ data.Rooms = a.roomList(request.Context(), actor)
845+ }
846+ a.render(writer, request, data)
847 return
848 }
849 if method == "browserepo" && pageQuery.Get("view") == "" {
850@@ -614,7 +633,7 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
851 // the first page.
852 result = a.includeApproval(request.Context(), actor, result, pageQuery.Get("id"))
853 }
854- data := PageData{Tab: browseTab(path), Feed: browseRows(result), Event: result, Query: pageQuery}
855+ data := PageData{Tab: browseTab(path), Feed: browseRows(result), Event: result, Query: pageQuery, View: room.id}
856 data.Title = browseTitle(path, pageQuery, result, a.backend.Slug())
857 if id := pageQuery.Get("merge"); method == "browsewikipage" && len(id) == 64 {
858 raw, _ := json.Marshal(map[string]any{"id": id})
859@@ -624,6 +643,12 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
860 data.Notice = mergeErr.Error()
861 }
862 }
863+ switch room.tab {
864+ case "rooms":
865+ data.Rooms = data.Feed
866+ case "room", "thread":
867+ data.Rooms = a.roomList(request.Context(), actor)
868+ }
869 if method == "browserepo" && pageQuery.Get("view") == "home" {
870 data.Readme = a.readme(request.Context(), actor, pageQuery)
871 }
872@@ -632,7 +657,7 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
873 }
874
875 func browseTitle(path string, query url.Values, result any, slug string) string {
876- label := map[string]string{"/repos": "Repositories", "/files": "Files", "/file": "File", "/approvals": "Approvals", "/wiki": "Wiki"}[path]
877+ label := map[string]string{"/repos": "Repositories", "/files": "Files", "/file": "File", "/approvals": "Approvals", "/wiki": "Wiki", "/rooms": "Rooms"}[path]
878 if strings.HasPrefix(path, "/wiki/") {
879 label = plainString(valueMap(result)["title"])
880 if label == "" {
881@@ -640,6 +665,12 @@ func browseTitle(path string, query url.Values, result any, slug string) string
882 }
883 label += " | wiki"
884 }
885+ if room := roomRoute(path); room.tab == "room" || room.tab == "thread" {
886+ label = "#" + room.id
887+ if room.tab == "thread" {
888+ label += " | thread"
889+ }
890+ }
891 if path == "/repo" {
892 label = query.Get("repo")
893 if label == "" {
894@@ -695,6 +726,9 @@ func browseTab(path string) string {
895 if strings.HasPrefix(path, "/wiki/") {
896 return "wikipage"
897 }
898+ if room := roomRoute(path); room.tab != "" {
899+ return room.tab
900+ }
901 switch path {
902 case "/wiki":
903 return "wiki"

About

ownerc01503e8035f
visibilitypublic
headd870709cf7d1

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs