- commit
- d870709cf7d111f864d96e0ccdecfb7554227cc2
- parent
- e3a6c7a010c9e002ddf73a11cf22a906ae3b4240
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 20 | ++- |
| A | internal/ | 295 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 64 | +++++++ |
| A | internal/ | 245 | +++++++++++++++++++++++++ |
| M | internal/ | 33 | +++ |
| M | internal/ | 20 | ++- |
| M | internal/ | 42 | ++++- |
@@ -56,6 +56,9 @@
5656 {{else if eq .Tab "wikipage"}}{{template "wikipage" .}}5757 {{else if eq .Tab "file"}}{{template "file" .}}5858 {{else if eq .Tab "approvals"}}{{template "approvals" .}}59+{{else if eq .Tab "rooms"}}{{template "rooms" .}}60+{{else if eq .Tab "room"}}{{template "room" .}}61+{{else if eq .Tab "thread"}}{{template "thread" .}}5962 {{else if eq .Tab "event"}}{{template "event" .}}6063 {{else if eq .Tab "view"}}{{template "view" .}}6164 {{else if eq .Tab "invite"}}{{template "invite" .}}@@ -85,15 +88,20 @@
8588 <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>8689 {{else if eq $kind "manage"}}<header><span><a href="/">{{.Slug}}</a>/<b>manage</b></span></header>8790 {{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}}91+{{else if eq $kind "rooms"}}<header><span><a href="/">{{.Slug}}</a>/<a href="/rooms"><b>rooms</b></a></span></header>92+<h4>Rooms</h4>93+<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>94+<p><a href="/rooms#new-room">+ new room</a></p>8895 {{else}}<header><span><a id="mark" href="/" title="{{.Slug}}"><i>t</i><i>i</i><i>n</i><i>y</i></a></span></header>8996 {{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}}90-{{end}}<p id="rail-tools">{{if eq $kind "repo"}}<a href="/repos">← all repositories</a>{{else if eq $kind "manage"}}<a href="/">← relay</a>{{end}}<button id="theme" type="button" aria-label="Switch theme">theme</button></p>{{end}}97+{{end}}<p id="rail-tools">{{if eq $kind "repo"}}<a href="/repos">← all repositories</a>{{else if or (eq $kind "manage") (eq $kind "rooms")}}<a href="/">← relay</a>{{end}}<button id="theme" type="button" aria-label="Switch theme">theme</button></p>{{end}}9198 9299 {{/* The panel holds facts and filters for the current page. */}}93100 {{define "panel"}}{{$kind := railKind .Tab}}{{if .Private}}{{template "panel-signin" .}}94101 {{else if eq .Tab "repo"}}{{template "panel-repo" .}}95102 {{else if eq .Tab "agents"}}{{template "panel-agents" .}}96103 {{else if eq $kind "manage"}}{{template "panel-manage" .}}104+{{else if eq $kind "rooms"}}{{template "panel-rooms" .}}97105 {{else if or (eq .Tab "inbox") (eq .Tab "outbox") (eq .Tab "search") (eq .Tab "articles")}}{{template "panel-feed" .}}98106 {{else if eq .Tab "repos"}}{{template "panel-repos" .}}99107 {{else if or (eq .Tab "files") (eq .Tab "file")}}{{template "panel-files" .}}@@ -135,6 +143,16 @@
135143 <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>136144 <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>137145 <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}}146+{{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>147+<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>148+{{else if .Error}}<section><h4>Room</h4><table><tr><th>id</th><td><code>{{.View}}</code></td></tr></table></section>149+{{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>150+{{if eq .Tab "thread"}}<section><h4>Actions</h4><ul><li><a href="/rooms/{{$id}}">open room</a></li></ul></section>151+{{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>152+{{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>153+<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>154+{{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>155+{{end}}{{end}}{{end}}{{end}}138156 139157 {{/* Shared partials. */}}140158 @@ -0,0 +1,295 @@
1+package webui2+3+import (4+ "context"5+ "encoding/json"6+ "html/template"7+ "regexp"8+ "sort"9+ "strings"10+ "time"11+)12+13+// roomPath is one rooms page: the list, one room, or one thread in a room.14+type roomPath struct {15+ tab string16+ id string17+ event string18+}19+20+var (21+ roomIDPattern = regexp.MustCompile(`^[a-z0-9_-]{1,64}$`)22+ eventIDPattern = regexp.MustCompile(`^[0-9a-f]{64}$`)23+ roomLinkPattern = regexp.MustCompile(`https?://[^\s<>"']+|(?:web\+)?nostr:[a-z0-9]+`)24+)25+26+// roomRoute maps /rooms, /rooms/<id> and /rooms/<id>/thread/<event> to a27+// page. The stream path belongs to the daemon and is not a page.28+func roomRoute(path string) roomPath {29+ if path == "/rooms" {30+ return roomPath{tab: "rooms"}31+ }32+ rest, ok := strings.CutPrefix(path, "/rooms/")33+ if !ok {34+ return roomPath{}35+ }36+ parts := strings.Split(rest, "/")37+ if !roomIDPattern.MatchString(parts[0]) {38+ return roomPath{}39+ }40+ switch {41+ case len(parts) == 1:42+ return roomPath{tab: "room", id: parts[0]}43+ case len(parts) == 3 && parts[1] == "thread" && eventIDPattern.MatchString(parts[2]):44+ return roomPath{tab: "thread", id: parts[0], event: parts[2]}45+ }46+ return roomPath{}47+}48+49+// roomList reads the rooms the viewer may see for the rail. A failure50+// leaves the rail empty rather than failing the page.51+func (a *App) roomList(ctx context.Context, actor string) []any {52+ raw, _ := json.Marshal(map[string]any{"limit": 100})53+ result, err := a.backend.Query(ctx, "browserooms", []json.RawMessage{raw}, actor)54+ if err != nil {55+ return nil56+ }57+ return browseRows(result)58+}59+60+// roomSlice normalizes a typed slice from the in-process backend into the61+// generic rows a JSON client would receive.62+func roomSlice(value any, field string) []any {63+ return collaborationSlice(value, field)64+}65+66+func roomMembers(value any) []any { return roomSlice(value, "members") }67+68+// memberIndex maps member pubkeys to their rows so a message can carry the69+// author's role and agent marker.70+func memberIndex(value any) map[string]map[string]any {71+ index := map[string]map[string]any{}72+ for _, row := range roomMembers(value) {73+ member := valueMap(row)74+ if pubkey := plainString(member["pubkey"]); pubkey != "" {75+ index[pubkey] = member76+ }77+ }78+ return index79+}80+81+// replyCounts counts the thread replies on this page by their root id.82+func replyCounts(value any) map[string]int {83+ counts := map[string]int{}84+ for _, row := range roomSlice(value, "messages") {85+ e := valueMap(row)86+ if plainString(e["kind"]) != "12" {87+ continue88+ }89+ if roots := tagValues(row, "e"); len(roots) > 0 {90+ counts[roots[0]]++91+ }92+ }93+ return counts94+}95+96+// reaction is one line of a message's reaction summary.97+type reaction struct {98+ Content string99+ Count int100+}101+102+// reactionSummary groups the page's reactions by the message they answer.103+func reactionSummary(value any) map[string][]reaction {104+ counts := map[string]map[string]int{}105+ for _, row := range roomSlice(value, "messages") {106+ e := valueMap(row)107+ if plainString(e["kind"]) != "7" {108+ continue109+ }110+ targets := tagValues(row, "e")111+ if len(targets) == 0 {112+ continue113+ }114+ target := targets[len(targets)-1]115+ content := strings.TrimSpace(plainString(e["content"]))116+ if content == "" || content == "+" {117+ content = "+1"118+ }119+ if counts[target] == nil {120+ counts[target] = map[string]int{}121+ }122+ counts[target][content]++123+ }124+ summary := map[string][]reaction{}125+ for target, byContent := range counts {126+ for content, count := range byContent {127+ summary[target] = append(summary[target], reaction{Content: content, Count: count})128+ }129+ sort.Slice(summary[target], func(i, j int) bool {130+ if summary[target][i].Count != summary[target][j].Count {131+ return summary[target][i].Count > summary[target][j].Count132+ }133+ return summary[target][i].Content < summary[target][j].Content134+ })135+ }136+ return summary137+}138+139+// tagValues lists the second field of every tag with the given name.140+func tagValues(value any, name string) []string {141+ var out []string142+ var tags [][]string143+ if encoded, err := json.Marshal(valueMap(value)["tags"]); err == nil {144+ _ = json.Unmarshal(encoded, &tags)145+ }146+ for _, tag := range tags {147+ if len(tag) > 1 && tag[0] == name {148+ out = append(out, tag[1])149+ }150+ }151+ return out152+}153+154+// roomContent renders message text as escaped HTML with http(s) URLs and155+// nostr links turned into anchors. Nostr links open through /open so the156+// relay resolves them to a page.157+func roomContent(text string) template.HTML {158+ var out strings.Builder159+ last := 0160+ for _, match := range roomLinkPattern.FindAllStringIndex(text, -1) {161+ out.WriteString(template.HTMLEscapeString(text[last:match[0]]))162+ link := text[match[0]:match[1]]163+ trimmed := strings.TrimRight(link, ".,;:!?)")164+ rest := link[len(trimmed):]165+ href := trimmed166+ if !strings.HasPrefix(trimmed, "http") {167+ href = "/open?target=" + template.URLQueryEscaper(trimmed)168+ }169+ out.WriteString(`<a href="` + template.HTMLEscapeString(href) + `"`)170+ if strings.HasPrefix(trimmed, "http") {171+ out.WriteString(` rel="noopener"`)172+ }173+ out.WriteString(`>` + template.HTMLEscapeString(trimmed) + `</a>` + template.HTMLEscapeString(rest))174+ last = match[1]175+ }176+ out.WriteString(template.HTMLEscapeString(text[last:]))177+ return template.HTML(out.String())178+}179+180+// age renders how long ago a Unix timestamp was, compactly, for the rail.181+func age(value any) string {182+ seconds := unixSeconds(value)183+ if seconds <= 0 {184+ return ""185+ }186+ elapsed := time.Since(time.Unix(seconds, 0))187+ switch {188+ case elapsed < time.Minute:189+ return "now"190+ case elapsed < time.Hour:191+ return plainString(int(elapsed.Minutes())) + "m"192+ case elapsed < 24*time.Hour:193+ return plainString(int(elapsed.Hours())) + "h"194+ default:195+ return plainString(int(elapsed.Hours()/24)) + "d"196+ }197+}198+199+// clock renders a message time: the UTC clock time today, otherwise the day200+// and time.201+func clock(value any) string {202+ seconds := unixSeconds(value)203+ if seconds <= 0 {204+ return ""205+ }206+ stamp := time.Unix(seconds, 0).UTC()207+ if stamp.Format("2006-01-02") == time.Now().UTC().Format("2006-01-02") {208+ return stamp.Format("15:04")209+ }210+ return stamp.Format("Jan 2 15:04")211+}212+213+// roomItem is one message as the page shows it: the event's fields, the214+// author's markers, the thread state and the reactions it has received.215+type roomItem struct {216+ ID, PubKey, Kind, Content, Room, Root, Role, Notice string217+ CreatedAt int64218+ Agent, InThread bool219+ Mentions []string220+ Replies int221+ Reactions []reaction222+}223+224+// roomItems turns a browse result's messages or replies into view items,225+// oldest first. Reactions and edits become summaries on their targets.226+func roomItems(value any, field string) []roomItem {227+ data := valueMap(value)228+ room := plainString(valueMap(data["room"])["id"])229+ members := memberIndex(value)230+ replies := replyCounts(value)231+ reactions := reactionSummary(value)232+ rows := roomSlice(value, field)233+ items := make([]roomItem, 0, len(rows))234+ for i := len(rows) - 1; i >= 0; i-- {235+ item, ok := newRoomItem(rows[i], room, members)236+ if !ok {237+ continue238+ }239+ item.Replies = replies[item.ID]240+ item.Reactions = reactions[item.ID]241+ item.InThread = field == "replies"242+ items = append(items, item)243+ }244+ return items245+}246+247+// roomRoot is the thread page's root message, with the page's replies counted.248+func roomRoot(value any) roomItem {249+ data := valueMap(value)250+ item, _ := newRoomItem(data["root"], plainString(valueMap(data["room"])["id"]), memberIndex(value))251+ item.Replies = len(roomSlice(value, "replies"))252+ item.InThread = true253+ return item254+}255+256+func newRoomItem(row any, room string, members map[string]map[string]any) (roomItem, bool) {257+ e := valueMap(row)258+ 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"])}259+ switch item.Kind {260+ case "7", "40003":261+ return roomItem{}, false262+ case "44100", "44101":263+ who := tagValues(row, "p")264+ if len(who) == 0 {265+ return roomItem{}, false266+ }267+ item.PubKey = who[0]268+ item.Notice = "joined the room"269+ if item.Kind == "44101" {270+ item.Notice = "left the room"271+ }272+ case "12":273+ if roots := tagValues(row, "e"); len(roots) > 0 {274+ item.Root = roots[0]275+ }276+ }277+ if member := members[item.PubKey]; member != nil {278+ item.Role = plainString(member["role"])279+ item.Agent, _ = member["agent"].(bool)280+ }281+ if item.Notice == "" {282+ for _, mention := range tagValues(row, "p") {283+ if eventIDPattern.MatchString(mention) && mention != item.PubKey {284+ item.Mentions = append(item.Mentions, mention)285+ }286+ }287+ }288+ return item, item.ID != ""289+}290+291+// roomAdmin reports whether a room role may administer the room.292+func roomAdmin(role any) bool {293+ value := plainString(role)294+ return value == "owner" || value == "admin"295+}@@ -0,0 +1,64 @@
1+{{/* Rooms: the list, one room's timeline with its compose bar, and one thread. */}}2+{{define "rooms"}}<section id="rooms">3+<h1>Rooms</h1>4+{{if .Error}}{{template "alert" .Error}}5+{{else if .Feed}}<table id="rows">6+<thead><tr><th>room</th><th>access</th><th>members</th><th>last message</th></tr></thead>7+<tbody>8+{{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>9+{{end}}</tbody>10+</table>11+{{else}}<p>No rooms yet.</p>12+{{end}}{{with (asMap .Event).next_cursor}}<p id="pages"><a href="{{browsePageURL "/rooms" $.Query "cursor" .}}">next page</a></p>13+{{end}}{{if .Actor}}<h2 id="new-room">New room</h2>14+<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>15+<noscript><p>Creating a room needs JavaScript and a connected signer.</p></noscript>16+{{else}}<p><a href="/signin">Sign in</a> to create a room.</p>17+{{end}}</section>18+{{end}}19+20+{{define "room"}}{{if .Error}}<section id="room">21+<h1>#{{.View}}</h1>22+{{template "alert" .Error}}23+{{if not .Actor}}<p><a href="/signin">Sign in</a> to read this room.</p>{{end}}24+</section>25+{{else}}{{$data := asMap .Event}}{{$room := asMap (index $data "room")}}{{$id := str (index $room "id")}}<section id="room">26+<h1>{{or (str (index $room "name")) $id}} <small>{{if eq (str (index $room "access")) "members"}}members only{{else}}open room{{end}}</small></h1>27+{{with str (index $room "about")}}<p>{{.}}</p>{{end}}28+{{with index $data "next_cursor"}}<p id="pages"><a href="/rooms/{{$id}}?cursor={{urlquery (str .)}}">load earlier</a></p>{{end}}29+{{if .Query.Get "cursor"}}<p id="newest"><a href="/rooms/{{$id}}">newest messages</a></p>{{end}}30+<div id="messages">{{range roomItems .Event "messages"}}31+{{template "roomMessage" .}}{{else}}<p id="empty">No messages yet.</p>{{end}}32+</div>33+{{if not (.Query.Get "cursor")}}<room-live room="{{$id}}"></room-live>34+{{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>35+<noscript><p>Sending needs JavaScript and a connected signer.</p></noscript>36+{{else}}<p id="guest"><a href="/signin">Sign in</a> to send messages.</p>37+{{end}}{{end}}</section>38+{{end}}{{end}}39+40+{{define "thread"}}{{if .Error}}<section id="thread">41+<h1>Thread</h1>42+{{template "alert" .Error}}43+</section>44+{{else}}{{$data := asMap .Event}}{{$room := asMap (index $data "room")}}{{$id := str (index $room "id")}}{{$root := roomRoot .Event}}<section id="thread">45+<p id="crumbs"><a href="/rooms/{{$id}}">{{or (str (index $room "name")) $id}}</a></p>46+<h1>Thread <small>{{$root.Replies}} replies</small></h1>47+<div id="root">{{template "roomMessage" $root}}</div>48+<div id="messages">{{range roomItems .Event "replies"}}49+{{template "roomMessage" .}}{{else}}<p id="empty">No replies yet.</p>{{end}}50+</div>51+{{with index $data "next_cursor"}}<p id="pages"><a href="/rooms/{{$id}}/thread/{{$root.ID}}?cursor={{urlquery (str .)}}">load earlier</a></p>{{end}}52+{{if not (.Query.Get "cursor")}}<room-live room="{{$id}}" root="{{$root.ID}}"></room-live>53+{{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>54+<noscript><p>Replying needs JavaScript and a connected signer.</p></noscript>55+{{else}}<p id="guest"><a href="/signin">Sign in</a> to reply.</p>56+{{end}}{{end}}</section>57+{{end}}{{end}}58+59+{{/* One message. room-live builds the same markup for messages that arrive on the stream. */}}60+{{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}}>61+<header><b>{{template "nostrKey" .PubKey}}</b>{{if .Role}} | {{.Role}}{{end}}<small><time datetime="{{datetime .CreatedAt}}" title="{{when .CreatedAt}}">{{clock .CreatedAt}}</time></small></header>62+<p>{{if .Notice}}{{.Notice}}{{else}}{{roomContent .Content}}{{end}}</p>63+{{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}}64+</room-message>{{end}}@@ -0,0 +1,245 @@
1+package webui2+3+import (4+ "context"5+ "encoding/json"6+ "errors"7+ "net/http"8+ "net/http/httptest"9+ "strings"10+ "testing"11+12+ "github.com/FelineStateMachine/tinyrelay/internal/policy"13+)14+15+var (16+ roomOwner = strings.Repeat("a", 64)17+ roomAgent = strings.Repeat("b", 64)18+ roomGuest = strings.Repeat("c", 64)19+ roomHello = strings.Repeat("1", 64)20+ roomChat = strings.Repeat("2", 64)21+ roomThread = strings.Repeat("3", 64)22+ roomReply = strings.Repeat("4", 64)23+ roomLike = strings.Repeat("5", 64)24+)25+26+// roomsBackend answers the room browse calls for an open room and a27+// members-only room. Guests see the open room only.28+type roomsBackend struct {29+ fakeBackend30+ calls []string31+}32+33+func roomEvent(id, pubkey string, kind int, createdAt int64, content string, tags ...[]string) map[string]any {34+ return map[string]any{"id": id, "pubkey": pubkey, "kind": kind, "created_at": createdAt, "content": content, "tags": tags, "sig": ""}35+}36+37+func roomRecord(id, name, access, role string) map[string]any {38+ 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}39+}40+41+func (b *roomsBackend) Query(_ context.Context, method string, params []json.RawMessage, actor string) (any, error) {42+ b.calls = append(b.calls, method+":"+actor)43+ q := map[string]any{}44+ if len(params) > 0 {45+ _ = json.Unmarshal(params[0], &q)46+ }47+ role := ""48+ if actor != "" {49+ role = "owner"50+ }51+ id, _ := q["id"].(string)52+ switch method {53+ case "browserooms":54+ items := []any{roomRecord("general", "General", "open", role)}55+ if actor != "" {56+ items = append(items, roomRecord("build", "Build", "members", role))57+ }58+ return map[string]any{"items": items, "next_cursor": ""}, nil59+ case "browseroom", "browsethread":60+ if id == "build" && actor == "" {61+ return nil, errors.New("auth-required: this room is members-only")62+ }63+ if id != "general" && id != "build" {64+ return nil, errors.New("not found: room")65+ }66+ default:67+ return nil, nil68+ }69+ members := []any{map[string]any{"pubkey": roomOwner, "role": "owner", "added_at": 1757200000}, map[string]any{"pubkey": roomAgent, "role": "member", "added_at": 1757200100, "agent": true}}70+ root := roomEvent(roomThread, roomAgent, 11, 1757203000, "Release notes draft", []string{"h", id})71+ reply := roomEvent(roomReply, roomOwner, 12, 1757203300, "Looks good", []string{"h", id}, []string{"e", roomThread}, []string{"p", roomAgent})72+ if method == "browsethread" {73+ return map[string]any{"room": roomRecord(id, "General", "open", ""), "root": root, "replies": []any{reply}, "next_cursor": ""}, nil74+ }75+ messages := []any{76+ roomEvent(roomLike, roomOwner, 7, 1757203600, "+", []string{"h", id}, []string{"e", roomChat}),77+ reply,78+ root,79+ roomEvent(roomChat, roomOwner, 9, 1757202000, "@agent take a look <script>alert(1)</script>", []string{"h", id}, []string{"p", roomAgent}),80+ roomEvent(roomHello, roomAgent, 9, 1757201000, "hello, see https://example.com/docs. and nostr:npub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc2", []string{"h", id}),81+ }82+ 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"}, nil83+}84+85+func roomsApp(t *testing.T, actor string) (*App, *roomsBackend) {86+ t.Helper()87+ backend := &roomsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(roomOwner)}}88+ app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return actor, nil }})89+ if err != nil {90+ t.Fatal(err)91+ }92+ return app, backend93+}94+95+func roomsPage(t *testing.T, app *App, path string) string {96+ t.Helper()97+ recorder := httptest.NewRecorder()98+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))99+ if recorder.Code != http.StatusOK {100+ t.Fatalf("%s: status=%d", path, recorder.Code)101+ }102+ body := recorder.Body.String()103+ if strings.Contains(body, "class=") {104+ t.Errorf("%s carries a class attribute", path)105+ }106+ content := body[strings.Index(body, `id="content"`):strings.Index(body, `id="aside"`)]107+ if sections := strings.Count(content, "<section"); sections != 1 {108+ t.Errorf("%s renders %d sections in the content column, want 1", path, sections)109+ }110+ return body111+}112+113+func TestRoomsListRendersRoomsRailAndCreateForm(t *testing.T) {114+ app, backend := roomsApp(t, roomOwner)115+ body := roomsPage(t, app, "/rooms")116+ 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="/">← relay</a>`, `<h4>Rooms</h4>`, `<td>2</td>`} {117+ if !strings.Contains(body, want) {118+ t.Errorf("rooms list missing %q", want)119+ }120+ }121+ if backend.calls[0] != "browserooms:"+roomOwner {122+ t.Fatalf("calls = %v", backend.calls)123+ }124+ guestApp, _ := roomsApp(t, "")125+ body = roomsPage(t, guestApp, "/rooms")126+ 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"`) {127+ t.Fatalf("guest rooms list: %s", body[strings.Index(body, `id="content"`):])128+ }129+ // The relay rail lists rooms first in the conversation group.130+ home := roomsPage(t, app, "/")131+ rooms, inbox, search := strings.Index(home, `href="/rooms"`), strings.Index(home, `href="/inbox"`), strings.Index(home, `href="/search"`)132+ if rooms < 0 || inbox < 0 || !(search < rooms && rooms < inbox) {133+ t.Fatalf("relay rail order: search=%d rooms=%d inbox=%d", search, rooms, inbox)134+ }135+}136+137+func TestRoomPageRendersMessagesOldestFirstWithMarkers(t *testing.T) {138+ app, backend := roomsApp(t, roomOwner)139+ body := roomsPage(t, app, "/rooms/general")140+ for _, want := range []string{141+ `<h1>General <small>open room</small></h1>`, `<p>Where General happens.</p>`,142+ `<a href="/rooms/general?cursor=cursor-1">load earlier</a>`,143+ `<room-message id="msg-` + roomHello + `" data-id="` + roomHello + `" data-kind="9" data-pubkey="` + roomAgent + `" data-agent>`,144+ `<a href="https://example.com/docs" rel="noopener">https://example.com/docs</a>.`,145+ `<a href="/open?target=nostr%3Anpub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc2">nostr:npub1`,146+ `<script>alert(1)</script>`,147+ `<span>to <nostr-key hex="` + roomAgent + `"`,148+ `<span data-reaction="+1">+1 1</span>`,149+ `<a href="/rooms/general/thread/` + roomThread + `">thread | 1 replies</a>`,150+ `<a href="/rooms/general/thread/` + roomThread + `">in thread</a>`,151+ `<room-live room="general"></room-live>`,152+ `<room-compose room="general" pubkey="` + roomOwner + `">`,153+ `<noscript><p>Sending needs JavaScript and a connected signer.</p></noscript>`,154+ `<ul id="members"><li><nostr-key hex="` + roomOwner + `"`, `<li data-agent><nostr-key hex="` + roomAgent + `"`, `<small>member | agent</small>`,155+ `<room-action room="general" kind="9000">`, `<room-action room="general" kind="9002">`, `<room-action room="general" kind="9022">`,156+ `<nav id="room-list"><a href="/rooms/general" aria-current="page">General`,157+ `» rooms/general</span>`,158+ } {159+ if !strings.Contains(body, want) {160+ t.Errorf("room page missing %q", want)161+ }162+ }163+ 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)164+ if !(hello < chat && chat < thread && thread < reply) {165+ t.Fatalf("messages are not oldest first: %d %d %d %d", hello, chat, thread, reply)166+ }167+ if strings.Contains(body, `data-kind="7"`) || strings.Contains(body, "<script>alert") {168+ t.Fatal("reactions rendered as messages or content unescaped")169+ }170+ if len(backend.calls) != 2 || backend.calls[0] != "browseroom:"+roomOwner || backend.calls[1] != "browserooms:"+roomOwner {171+ t.Fatalf("calls = %v", backend.calls)172+ }173+ guestApp, _ := roomsApp(t, "")174+ body = roomsPage(t, guestApp, "/rooms/general")175+ 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) {176+ t.Fatalf("guest room page: %s", body[strings.Index(body, `id="content"`):])177+ }178+}179+180+func TestMembersOnlyRoomIsHiddenFromGuests(t *testing.T) {181+ guestApp, _ := roomsApp(t, "")182+ body := roomsPage(t, guestApp, "/rooms/build")183+ 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.`) {184+ t.Fatalf("guest saw a members-only room: %s", body[strings.Index(body, `id="content"`):])185+ }186+ if !strings.Contains(body, `<h1>#build</h1>`) || strings.Contains(body, "Where Build happens") {187+ t.Fatal("guest error page leaked room details")188+ }189+ app, _ := roomsApp(t, roomOwner)190+ body = roomsPage(t, app, "/rooms/build")191+ if !strings.Contains(body, `<h1>Build <small>members only</small></h1>`) || !strings.Contains(body, `id="msg-`+roomHello) {192+ t.Fatal("owner did not see the members-only room")193+ }194+}195+196+func TestThreadPageRendersRootRepliesAndReplyCompose(t *testing.T) {197+ app, _ := roomsApp(t, roomOwner)198+ body := roomsPage(t, app, "/rooms/general/thread/"+roomThread)199+ for _, want := range []string{200+ `<p id="crumbs"><a href="/rooms/general">General</a></p>`, `<h1>Thread <small>1 replies</small></h1>`,201+ `<div id="root"><room-message id="msg-` + roomThread + `"`, `<div id="messages">`, `id="msg-` + roomReply + `"`,202+ `<room-live room="general" root="` + roomThread + `"></room-live>`,203+ `<room-compose room="general" pubkey="` + roomOwner + `" kind="12" root="` + roomThread + `" root-pubkey="` + roomAgent + `">`,204+ `» rooms/general/thread/` + shortID(roomThread) + `</span>`,205+ } {206+ if !strings.Contains(body, want) {207+ t.Errorf("thread page missing %q", want)208+ }209+ }210+ if strings.Contains(body, `">thread</a>`) || strings.Contains(body, ">in thread</a>") {211+ t.Fatal("thread page links back to itself")212+ }213+}214+215+func TestRoomRoutesRejectMalformedPaths(t *testing.T) {216+ for path, want := range map[string]roomPath{217+ "/rooms": {tab: "rooms"},218+ "/rooms/general": {tab: "room", id: "general"},219+ "/rooms/General": {},220+ "/rooms/a/b": {},221+ "/rooms/a/thread/xyz": {},222+ "/rooms/a/thread/" + roomThread: {tab: "thread", id: "a", event: roomThread},223+ "/rooms/a/stream": {},224+ } {225+ if got := roomRoute(path); got != want {226+ t.Errorf("roomRoute(%q) = %+v, want %+v", path, got, want)227+ }228+ }229+ app, backend := roomsApp(t, roomOwner)230+ recorder := httptest.NewRecorder()231+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/rooms/Not%20A%20Room", nil))232+ for _, call := range backend.calls {233+ if strings.HasPrefix(call, "browseroom") {234+ t.Fatalf("malformed room path reached the backend: %v", backend.calls)235+ }236+ }237+}238+239+func TestRoomContentLinksAndEscapes(t *testing.T) {240+ got := string(roomContent(`<b>hi</b> https://a.example/x?y=1) then nostr:note1abc, done`))241+ want := `<b>hi</b> <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`242+ if got != want {243+ t.Fatalf("roomContent:\n got %s\nwant %s", got, want)244+ }245+}@@ -239,6 +239,37 @@ wiki-compose output{display:block;font:12px var(--mono);color:var(--muted);margi
239239 /* Sign in. */240240 #signer-qr:not([hidden]){display:block;margin:8px 0;background:#fff;padding:8px;border:1px solid var(--line)}241241 242+/* Rooms: the rail lists rooms, messages read oldest first, and the compose bar stays at the bottom of the column. */243+#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}244+#room-list a{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}#room-list a small{float:right;color:var(--muted);font-size:11px}245+#rooms #rows td:first-child{width:220px}#rooms #rows td:nth-child(3){text-align:right;width:80px;font:12px var(--mono)}246+#content>section#room,#content>section#thread{padding-bottom:0}247+#room h1 small,#thread h1 small{font:12px var(--mono);color:var(--muted)}248+#crumbs,#newest,#empty{font:12px var(--mono);color:var(--muted)}249+#messages,#root{max-width:720px}250+room-message{display:block;padding:8px 0;border-bottom:1px solid var(--line)}251+room-message>header{font:12px var(--mono);color:var(--muted);margin-bottom:2px;user-select:none;-webkit-user-select:none}252+room-message>header b{color:var(--ink);font-weight:500}room-message>header b nostr-key{cursor:copy}253+room-message>header small{float:right}254+room-message>p{margin:0;font:14px/1.45 var(--sans);white-space:pre-wrap;overflow-wrap:anywhere}255+room-message>p a{color:var(--accent)}256+room-message>footer{font:12px var(--mono);color:var(--muted);margin-top:4px}257+room-message>footer>*+*::before{content:" | "}258+room-message[data-agent]>header b::after{content:" agent";color:var(--bark);font-size:11px}259+room-message[data-notice]>p{color:var(--muted);font-size:13px}260+room-live{display:block;font:11px var(--mono);color:var(--muted);text-align:right}room-live:empty{display:none}261+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)}262+room-compose textarea{min-height:3.2em;width:100%;font:14px var(--sans);max-width:720px;display:block}263+room-compose p{margin:6px 0 0}room-compose button{margin-right:6px}264+room-compose output,room-create output,room-action output{display:block;font:12px var(--mono);color:var(--muted);margin-top:4px;overflow-wrap:anywhere}265+room-compose output[data-error],room-create output[data-error],room-action output[data-error]{color:var(--alert)}266+room-create{display:block;max-width:720px;border-bottom:1px solid var(--line);padding:10px 0}267+room-create label{display:inline-block;margin:0 10px 6px 0;vertical-align:top;font:13px var(--sans);color:var(--muted)}268+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}269+room-action{display:block}#panel room-action label{margin-top:6px}#panel room-action button{margin-top:8px}270+#members li[data-agent] small{color:var(--bark)}271+#panel #members li{font:12px var(--mono);line-height:22px}272+242273 /* Phones: a top bar with a menu, the rail slides in, the panel stacks under the content. */243274 @media(max-width:720px){244275 html,body{overflow:auto}@@ -264,4 +295,6 @@ html,body{overflow:auto}
264295 #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}265296 #grid,#gridhead,h2:has(+#gridhead){display:none}266297 #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}298+room-compose{bottom:calc(28px + env(safe-area-inset-bottom,0px));margin:8px -2.5ch 0;padding:10px 2.5ch 8px;z-index:2}299+body:has(#room) #layout>tbody>tr>td#aside,body:has(#thread) #layout>tbody>tr>td#aside{display:none}267300 }@@ -16,7 +16,7 @@ import (
1616 // templateFS holds every page template. page.html owns the shell and the1717 // shared partials; the other files each define one group of tabs.1818 //19-//go:embed page.html public.html manage.html browse.html repo.html collaboration.html wiki.html19+//go:embed page.html public.html manage.html browse.html repo.html collaboration.html wiki.html rooms.html2020 var templateFS embed.FS2121 2222 // styleCSS is inlined into every page so the UI needs no extra request and@@ -102,7 +102,7 @@ type navItem struct {
102102 103103 // relayNav: entry points, then conversation, then collaborative artifacts,104104 // then what the relay publishes and stores, then what is yours.105-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}}105+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}}106106 107107 // manageNav: who is here, what they may do, what the relay is, what it does108108 // over time, and how it is doing.@@ -120,11 +120,15 @@ func navGroups(items []navItem) [][]navItem {
120120 return groups121121 }122122 123-// railKind picks the sitemap for a tab: one repository, management, or the relay.123+// railKind picks the sitemap for a tab: one repository, the rooms,124+// management, or the relay.124125 func railKind(tab string) string {125126 if tab == "repo" {126127 return "repo"127128 }129+ if tab == "rooms" || tab == "room" || tab == "thread" {130+ return "rooms"131+ }128132 for _, item := range manageNav {129133 if item.Tab == tab {130134 return "manage"@@ -145,6 +149,9 @@ func promptPath(path string, query url.Values) string {
145149 case path == "file" && (query.Get("sha") != "" || query.Get("hash") != ""):146150 return "files/" + shortID(query.Get("sha")+query.Get("hash"))147151 }152+ if room := roomRoute("/" + path); room.tab == "thread" {153+ return "rooms/" + room.id + "/thread/" + shortID(room.event)154+ }148155 return path149156 }150157 @@ -256,6 +263,13 @@ func parseTemplates() (*template.Template, error) {
256263 "wikiHTML": wikiHTML,257264 "wikiView": wikiPageView,258265 "wikiURL": wikiURL,266+ "roomItems": roomItems,267+ "roomRoot": roomRoot,268+ "roomMembers": roomMembers,269+ "roomContent": roomContent,270+ "age": age,271+ "clock": clock,272+ "roomAdmin": roomAdmin,259273 }260274 tmpl, err := template.New("webui").Funcs(funcs).ParseFS(templateFS, "*.html")261275 if err != nil {@@ -90,6 +90,8 @@ type PageData struct {
9090 // Connections lists the ways to open this relay in client apps that the9191 // viewer may see, with link placeholders already resolved.9292 Connections []any93+ // Rooms lists the rooms the viewer may see; the rooms rail renders it.94+ Rooms []any9395 }9496 9597 func New(backend Backend, options Options) (*App, error) {@@ -160,7 +162,7 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
160162 http.Redirect(writer, request, requestPrefix(request)+"/manage/health", http.StatusMovedPermanently)161163 return162164 }163- 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") {165+ 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 != "") {164166 a.browse(writer, request)165167 return166168 }@@ -531,6 +533,15 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
531533 case "/manage/health":532534 method = "browsestatus"533535 }536+ room := roomRoute(path)537+ switch room.tab {538+ case "rooms":539+ method = "browserooms"540+ case "room":541+ method = "browseroom"542+ case "thread":543+ method = "browsethread"544+ }534545 if strings.HasPrefix(path, "/wiki/") {535546 method = "browsewikipage"536547 }@@ -594,6 +605,10 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
594605 query = map[string]any{"d": pageQuery.Get("d"), "author": request.URL.Query().Get("author"), "version": request.URL.Query().Get("version")}595606 case "browsestatus":596607 query = map[string]any{}608+ case "browserooms":609+ query = map[string]any{"cursor": request.URL.Query().Get("cursor"), "limit": 100}610+ case "browseroom", "browsethread":611+ query = map[string]any{"id": room.id, "event": room.event, "cursor": request.URL.Query().Get("cursor"), "limit": 50}597612 }598613 raw, _ := json.Marshal(query)599614 params = append(params, raw)@@ -603,7 +618,11 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
603618 }604619 result, err := a.backend.Query(request.Context(), method, params, actor)605620 if err != nil {606- a.render(writer, request, PageData{Tab: browseTab(path), Error: err.Error(), Query: pageQuery})621+ data := PageData{Tab: browseTab(path), Error: err.Error(), Query: pageQuery, View: room.id}622+ if room.tab != "" {623+ data.Rooms = a.roomList(request.Context(), actor)624+ }625+ a.render(writer, request, data)607626 return608627 }609628 if method == "browserepo" && pageQuery.Get("view") == "" {@@ -614,7 +633,7 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
614633 // the first page.615634 result = a.includeApproval(request.Context(), actor, result, pageQuery.Get("id"))616635 }617- data := PageData{Tab: browseTab(path), Feed: browseRows(result), Event: result, Query: pageQuery}636+ data := PageData{Tab: browseTab(path), Feed: browseRows(result), Event: result, Query: pageQuery, View: room.id}618637 data.Title = browseTitle(path, pageQuery, result, a.backend.Slug())619638 if id := pageQuery.Get("merge"); method == "browsewikipage" && len(id) == 64 {620639 raw, _ := json.Marshal(map[string]any{"id": id})@@ -624,6 +643,12 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
624643 data.Notice = mergeErr.Error()625644 }626645 }646+ switch room.tab {647+ case "rooms":648+ data.Rooms = data.Feed649+ case "room", "thread":650+ data.Rooms = a.roomList(request.Context(), actor)651+ }627652 if method == "browserepo" && pageQuery.Get("view") == "home" {628653 data.Readme = a.readme(request.Context(), actor, pageQuery)629654 }@@ -632,7 +657,7 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
632657 }633658 634659 func browseTitle(path string, query url.Values, result any, slug string) string {635- label := map[string]string{"/repos": "Repositories", "/files": "Files", "/file": "File", "/approvals": "Approvals", "/wiki": "Wiki"}[path]660+ label := map[string]string{"/repos": "Repositories", "/files": "Files", "/file": "File", "/approvals": "Approvals", "/wiki": "Wiki", "/rooms": "Rooms"}[path]636661 if strings.HasPrefix(path, "/wiki/") {637662 label = plainString(valueMap(result)["title"])638663 if label == "" {@@ -640,6 +665,12 @@ func browseTitle(path string, query url.Values, result any, slug string) string
640665 }641666 label += " | wiki"642667 }668+ if room := roomRoute(path); room.tab == "room" || room.tab == "thread" {669+ label = "#" + room.id670+ if room.tab == "thread" {671+ label += " | thread"672+ }673+ }643674 if path == "/repo" {644675 label = query.Get("repo")645676 if label == "" {@@ -695,6 +726,9 @@ func browseTab(path string) string {
695726 if strings.HasPrefix(path, "/wiki/") {696727 return "wikipage"697728 }729+ if room := roomRoute(path); room.tab != "" {730+ return room.tab731+ }698732 switch path {699733 case "/wiki":700734 return "wiki"