| 1 | diff --git a/internal/webui/page.html b/internal/webui/page.html |
| 2 | index 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">← 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}} |
| 26 | +{{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}} |
| 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 | |
| 54 | diff --git a/internal/webui/rooms.go b/internal/webui/rooms.go |
| 55 | new file mode 100644 |
| 56 | index 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 | +} |
| 355 | diff --git a/internal/webui/rooms.html b/internal/webui/rooms.html |
| 356 | new file mode 100644 |
| 357 | index 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}} |
| 425 | diff --git a/internal/webui/rooms_test.go b/internal/webui/rooms_test.go |
| 426 | new file mode 100644 |
| 427 | index 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="/">← 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 | + `<script>alert(1)</script>`, |
| 577 | + `<span>to <nostr-key hex="` + roomAgent + `"`, |
| 578 | + `<span data-reaction="+1">+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 | + `» 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 | + `» 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 := `<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` |
| 672 | + if got != want { |
| 673 | + t.Fatalf("roomContent:\n got %s\nwant %s", got, want) |
| 674 | + } |
| 675 | +} |
| 676 | diff --git a/internal/webui/style.css b/internal/webui/style.css |
| 677 | index 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 | } |
| 725 | diff --git a/internal/webui/templates.go b/internal/webui/templates.go |
| 726 | index 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 { |
| 788 | diff --git a/internal/webui/webui.go b/internal/webui/webui.go |
| 789 | index 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" |