| 1 | diff --git a/AGENTS.md b/AGENTS.md |
| 2 | index 74f1ada..bdc0bdf 100644 |
| 3 | --- a/AGENTS.md |
| 4 | +++ b/AGENTS.md |
| 5 | @@ -1,3 +1,4 @@ |
| 6 | - The middle dot character (U+00B7) is disallowed in project text and interfaces. |
| 7 | - Write all documentation in AP style, with clear, concise organization. Documentation is a user-facing surface; omit incidental implementation notes. |
| 8 | +- After completing work in this project, commit the changes, push them and deploy to Slate. Verify the deployment before reporting completion. |
| 9 | - Web UI markup carries no class attributes. Style ids, elements and custom element names in `internal/webui/style.css`, and render repeated or large data through the elements in `internal/webui/components.js` (`rpc-form`, `signed-form`, `publish-list`, `json-view`, `nostr-key`) and the `nostrEvent` and `jsonView` template partials. The shell is a rail, a content column, a panel and a command footer; `page.html` owns the rail and panel dispatch, each page template renders one `<section>` of content, and every page must render without JavaScript. |
| 10 | diff --git a/internal/webui/components.js b/internal/webui/components.js |
| 11 | index fe6a4f5..39eddcf 100644 |
| 12 | --- a/internal/webui/components.js |
| 13 | +++ b/internal/webui/components.js |
| 14 | @@ -698,6 +698,49 @@ |
| 15 | } |
| 16 | } |
| 17 | |
| 18 | + class NostrAvatar extends HTMLElement { |
| 19 | + connectedCallback() { |
| 20 | + this.render(); |
| 21 | + } |
| 22 | + |
| 23 | + attributeChangedCallback() { this.render(); } |
| 24 | + |
| 25 | + static get observedAttributes() { return ["pubkey"]; } |
| 26 | + |
| 27 | + render() { |
| 28 | + const pubkey = String(this.getAttribute("pubkey") || "").toLowerCase(); |
| 29 | + const fallback = pubkey.slice(0, 2).toUpperCase(); |
| 30 | + const serial = (this.serial || 0) + 1; |
| 31 | + this.serial = serial; |
| 32 | + const restore = () => { |
| 33 | + if (serial !== this.serial) return; |
| 34 | + this.replaceChildren(document.createTextNode(fallback)); |
| 35 | + }; |
| 36 | + const load = window.tinyNames?.load; |
| 37 | + if (!load || !/^[0-9a-f]{64}$/.test(pubkey)) { |
| 38 | + restore(); |
| 39 | + return; |
| 40 | + } |
| 41 | + Promise.resolve(load({pubkey})).then(user => { |
| 42 | + const raw = user?.image; |
| 43 | + let url; |
| 44 | + try { |
| 45 | + const candidate = new URL(raw); |
| 46 | + if (candidate.protocol === "http:" || candidate.protocol === "https:") url = candidate.href; |
| 47 | + } catch {} |
| 48 | + if (!url || serial !== this.serial) return restore(); |
| 49 | + const image = el("img"); |
| 50 | + image.src = url; |
| 51 | + image.alt = ""; |
| 52 | + image.loading = "lazy"; |
| 53 | + image.referrerPolicy = "no-referrer"; |
| 54 | + image.setAttribute("aria-hidden", "true"); |
| 55 | + image.addEventListener("error", restore, {once: true}); |
| 56 | + this.replaceChildren(image); |
| 57 | + }).catch(restore); |
| 58 | + } |
| 59 | + } |
| 60 | + |
| 61 | // PageLink is the crumb that names the current page. A tap copies the |
| 62 | // page's public address, the one the server put in url, and shows |
| 63 | // "copied" for a moment. Without JavaScript it is plain text. |
| 64 | @@ -1337,6 +1380,7 @@ |
| 65 | customElements.define("push-toggle", PushToggle); |
| 66 | customElements.define("share-link", ShareLink); |
| 67 | customElements.define("nostr-key", NostrKey); |
| 68 | + customElements.define("nostr-avatar", NostrAvatar); |
| 69 | customElements.define("page-link", PageLink); |
| 70 | customElements.define("json-view", JsonView); |
| 71 | |
| 72 | diff --git a/internal/webui/markdown.go b/internal/webui/markdown.go |
| 73 | index 6f91623..9a9b8de 100644 |
| 74 | --- a/internal/webui/markdown.go |
| 75 | +++ b/internal/webui/markdown.go |
| 76 | @@ -17,7 +17,7 @@ import ( |
| 77 | type blockRenderer func(lang, source string) (string, bool) |
| 78 | |
| 79 | // renderMarkdown turns a small, common subset of Markdown into HTML: ATX |
| 80 | -// headings, paragraphs, fenced code, unordered and ordered lists, inline |
| 81 | +// headings, paragraphs, tables, fenced code, unordered and ordered lists, inline |
| 82 | // code, emphasis, and links. Every piece of text is escaped first and only |
| 83 | // the tags this function emits reach the page, so untrusted README files and |
| 84 | // relay descriptions cannot inject markup. Anything else stays plain text. |
| 85 | @@ -65,6 +65,11 @@ func markdownHTML(source string, breaks bool, blocks blockRenderer) template.HTM |
| 86 | line := lines[i] |
| 87 | marker, lang, fenced := views.OpenFence(line) |
| 88 | switch { |
| 89 | + case !fenced && i+1 < len(lines) && isTableCandidate(line, lines[i+1]): |
| 90 | + flush() |
| 91 | + table, end := renderTable(lines, i) |
| 92 | + out.WriteString(table) |
| 93 | + i = end |
| 94 | case fenced: |
| 95 | flush() |
| 96 | var code []string |
| 97 | @@ -115,6 +120,127 @@ func markdownHTML(source string, breaks bool, blocks blockRenderer) template.HTM |
| 98 | return template.HTML(out.String()) |
| 99 | } |
| 100 | |
| 101 | +var tableDelimiter = regexp.MustCompile(`^:?-{3,}:?$`) |
| 102 | + |
| 103 | +func isTableCandidate(header, delimiter string) bool { |
| 104 | + headerCells, headerHasPipes := splitTableRow(header) |
| 105 | + delimiterCells, delimiterHasPipes := splitTableRow(delimiter) |
| 106 | + if !headerHasPipes || !delimiterHasPipes || len(headerCells) == 0 || len(headerCells) != len(delimiterCells) { |
| 107 | + return false |
| 108 | + } |
| 109 | + for _, cell := range delimiterCells { |
| 110 | + if !tableDelimiter.MatchString(strings.TrimSpace(cell)) { |
| 111 | + return false |
| 112 | + } |
| 113 | + } |
| 114 | + return true |
| 115 | +} |
| 116 | + |
| 117 | +func splitTableRow(line string) ([]string, bool) { |
| 118 | + line = strings.TrimSpace(line) |
| 119 | + var cells []string |
| 120 | + var cell strings.Builder |
| 121 | + inCode := false |
| 122 | + hasPipe := false |
| 123 | + for i := 0; i < len(line); i++ { |
| 124 | + if line[i] == '`' { |
| 125 | + inCode = !inCode |
| 126 | + } |
| 127 | + if line[i] == '|' && !inCode && !escapedAt(line, i) { |
| 128 | + hasPipe = true |
| 129 | + cells = append(cells, strings.TrimSpace(cell.String())) |
| 130 | + cell.Reset() |
| 131 | + continue |
| 132 | + } |
| 133 | + cell.WriteByte(line[i]) |
| 134 | + } |
| 135 | + cells = append(cells, strings.TrimSpace(cell.String())) |
| 136 | + if len(cells) > 1 && cells[0] == "" { |
| 137 | + cells = cells[1:] |
| 138 | + } |
| 139 | + if len(cells) > 1 && cells[len(cells)-1] == "" { |
| 140 | + cells = cells[:len(cells)-1] |
| 141 | + } |
| 142 | + return cells, hasPipe |
| 143 | +} |
| 144 | + |
| 145 | +func escapedAt(value string, index int) bool { |
| 146 | + backslashes := 0 |
| 147 | + for index > 0 && value[index-1] == '\\' { |
| 148 | + backslashes++ |
| 149 | + index-- |
| 150 | + } |
| 151 | + return backslashes%2 == 1 |
| 152 | +} |
| 153 | + |
| 154 | +func renderTable(lines []string, start int) (string, int) { |
| 155 | + header, _ := splitTableRow(lines[start]) |
| 156 | + separator, _ := splitTableRow(lines[start+1]) |
| 157 | + alignments := tableAlignments(separator) |
| 158 | + var out strings.Builder |
| 159 | + out.WriteString(`<div data-markdown-table="" role="region" aria-label="Table" tabindex="0"><table><thead><tr>`) |
| 160 | + for i, cell := range header { |
| 161 | + out.WriteString(`<th scope="col"` + tableAlignAttribute(alignments[i]) + `>` + inlineMarkdown(unescapeTablePipes(cell)) + `</th>`) |
| 162 | + } |
| 163 | + out.WriteString(`</tr></thead><tbody>`) |
| 164 | + end := start + 1 |
| 165 | + for end+1 < len(lines) { |
| 166 | + cells, pipes := splitTableRow(lines[end+1]) |
| 167 | + if !isTableBodyRow(lines[end+1], pipes) { |
| 168 | + break |
| 169 | + } |
| 170 | + if len(cells) > len(header) { |
| 171 | + cells = cells[:len(header)] |
| 172 | + } |
| 173 | + for len(cells) < len(header) { |
| 174 | + cells = append(cells, "") |
| 175 | + } |
| 176 | + out.WriteString("<tr>") |
| 177 | + for i, cell := range cells { |
| 178 | + out.WriteString(`<td` + tableAlignAttribute(alignments[i]) + `>` + inlineMarkdown(unescapeTablePipes(cell)) + `</td>`) |
| 179 | + } |
| 180 | + out.WriteString("</tr>") |
| 181 | + end++ |
| 182 | + } |
| 183 | + out.WriteString(`</tbody></table></div>` + "\n") |
| 184 | + return out.String(), end |
| 185 | +} |
| 186 | + |
| 187 | +func isTableBodyRow(line string, pipes bool) bool { |
| 188 | + if !pipes || strings.TrimSpace(line) == "" || strings.HasPrefix(strings.TrimSpace(line), "#") { |
| 189 | + return false |
| 190 | + } |
| 191 | + _, _, fenced := views.OpenFence(line) |
| 192 | + return !fenced |
| 193 | +} |
| 194 | + |
| 195 | +func tableAlignments(cells []string) []string { |
| 196 | + alignments := make([]string, len(cells)) |
| 197 | + for i, cell := range cells { |
| 198 | + cell = strings.TrimSpace(cell) |
| 199 | + switch { |
| 200 | + case strings.HasPrefix(cell, ":") && strings.HasSuffix(cell, ":"): |
| 201 | + alignments[i] = "center" |
| 202 | + case strings.HasSuffix(cell, ":"): |
| 203 | + alignments[i] = "right" |
| 204 | + case strings.HasPrefix(cell, ":"): |
| 205 | + alignments[i] = "left" |
| 206 | + } |
| 207 | + } |
| 208 | + return alignments |
| 209 | +} |
| 210 | + |
| 211 | +func tableAlignAttribute(alignment string) string { |
| 212 | + if alignment == "" { |
| 213 | + return "" |
| 214 | + } |
| 215 | + return ` data-align="` + alignment + `"` |
| 216 | +} |
| 217 | + |
| 218 | +func unescapeTablePipes(value string) string { |
| 219 | + return strings.ReplaceAll(value, `\|`, `|`) |
| 220 | +} |
| 221 | + |
| 222 | var ( |
| 223 | orderedItem = regexp.MustCompile(`^\s*\d+[.)]\s+`) |
| 224 | bulletItem = regexp.MustCompile(`^\s*[-*+]\s+`) |
| 225 | diff --git a/internal/webui/markdown_test.go b/internal/webui/markdown_test.go |
| 226 | index 7daed24..6690d15 100644 |
| 227 | --- a/internal/webui/markdown_test.go |
| 228 | +++ b/internal/webui/markdown_test.go |
| 229 | @@ -54,3 +54,50 @@ func TestChatMarkdownBreaksLinesAndAutolinksOutsideLinksAndCode(t *testing.T) { |
| 230 | t.Fatalf("expected three links, got %s", got) |
| 231 | } |
| 232 | } |
| 233 | + |
| 234 | +func TestMarkdownRendersTablesWithAlignmentAndInlineMarkdown(t *testing.T) { |
| 235 | + source := "| Name | Count | Notes |\n| :--- | ---: | :---: |\n| **Ada** | 3 | [docs](https://example.com) |\n| Grace | | `a | b` |" |
| 236 | + got := string(renderMarkdown(source)) |
| 237 | + for _, want := range []string{ |
| 238 | + `<div data-markdown-table="" role="region" aria-label="Table" tabindex="0"><table>`, |
| 239 | + `<thead><tr><th scope="col" data-align="left">Name</th><th scope="col" data-align="right">Count</th><th scope="col" data-align="center">Notes</th></tr></thead>`, |
| 240 | + `<tbody><tr><td data-align="left"><strong>Ada</strong></td><td data-align="right">3</td><td data-align="center"><a href="https://example.com">docs</a></td></tr>`, |
| 241 | + `<tr><td data-align="left">Grace</td><td data-align="right"></td><td data-align="center"><code>a | b</code></td></tr></tbody></table></div>`, |
| 242 | + } { |
| 243 | + if !strings.Contains(got, want) { |
| 244 | + t.Errorf("missing %q in %s", want, got) |
| 245 | + } |
| 246 | + } |
| 247 | +} |
| 248 | + |
| 249 | +func TestMarkdownTableEscapesPipesAndLeavesMalformedInputAsText(t *testing.T) { |
| 250 | + got := string(renderMarkdown("a \\| b\n---\n\n| one | two |\n| --- |\n| three | four | five |")) |
| 251 | + if strings.Contains(got, `data-markdown-table`) { |
| 252 | + t.Fatalf("malformed table was rendered: %s", got) |
| 253 | + } |
| 254 | + if !strings.Contains(got, `a \| b ---`) || !strings.Contains(got, `| one | two |`) { |
| 255 | + t.Fatalf("malformed table was not preserved as text: %s", got) |
| 256 | + } |
| 257 | + |
| 258 | + got = string(renderMarkdown("a \\| b | c\n--- | ---\nvalue \\| kept | ok")) |
| 259 | + if !strings.Contains(got, `<th scope="col">a | b</th>`) || !strings.Contains(got, `<td>value | kept</td>`) { |
| 260 | + t.Fatalf("escaped pipe was split or escaped incorrectly: %s", got) |
| 261 | + } |
| 262 | +} |
| 263 | + |
| 264 | +func TestChatMarkdownAutolinksTableText(t *testing.T) { |
| 265 | + got := string(renderChatMarkdown("| Link |\n| --- |\n| https://example.com/path |")) |
| 266 | + if !strings.Contains(got, `<td><a href="https://example.com/path" rel="noopener">https://example.com/path</a></td>`) { |
| 267 | + t.Fatalf("table cell was not autolinked: %s", got) |
| 268 | + } |
| 269 | +} |
| 270 | + |
| 271 | +func TestMarkdownTableStopsBeforeHeadingsAndFences(t *testing.T) { |
| 272 | + got := string(renderMarkdown("| Name | Value |\n| --- | --- |\n| one | two |\n# | Heading |\n\n```sh | example\n| code |\n```")) |
| 273 | + if strings.Contains(got, "<td># | Heading |</td>") || strings.Contains(got, "<td>```sh | example</td>") { |
| 274 | + t.Fatalf("table consumed a following block: %s", got) |
| 275 | + } |
| 276 | + if !strings.Contains(got, "<h1>| Heading |</h1>") || !strings.Contains(got, "<pre><code data-lang=\"sh\">| code |</code></pre>") { |
| 277 | + t.Fatalf("following heading or fence was not rendered separately: %s", got) |
| 278 | + } |
| 279 | +} |
| 280 | diff --git a/internal/webui/page.html b/internal/webui/page.html |
| 281 | index 56db928..04e0c7e 100644 |
| 282 | --- a/internal/webui/page.html |
| 283 | +++ b/internal/webui/page.html |
| 284 | @@ -79,9 +79,9 @@ then feature modules are loaded for the current page. */}} |
| 285 | <script src="{{scriptURL "blossom-upload.js"}}"></script> |
| 286 | <script src="{{scriptURL "file-messages.js"}}"></script> |
| 287 | <script src="{{scriptURL "file-workspace.js"}}"></script> |
| 288 | -{{end}}<script src="{{scriptURL "components.js"}}"></script> |
| 289 | +{{end}}<script src="{{scriptURL "nostr-name.js"}}"></script> |
| 290 | +<script src="{{scriptURL "components.js"}}"></script> |
| 291 | <script src="{{scriptURL "rooms.js"}}"></script> |
| 292 | -<script src="{{scriptURL "nostr-name.js"}}"></script> |
| 293 | {{if eq .Tab "connect"}}<script src="{{scriptURL "private-services.js"}}"></script> |
| 294 | {{end}}</body> |
| 295 | </html> |
| 296 | diff --git a/internal/webui/rooms.go b/internal/webui/rooms.go |
| 297 | index 04c1f97..3817ffa 100644 |
| 298 | --- a/internal/webui/rooms.go |
| 299 | +++ b/internal/webui/rooms.go |
| 300 | @@ -289,19 +289,20 @@ func clock(value any) string { |
| 301 | // roomItem is one message as the page shows it: the event's fields, the |
| 302 | // author's markers, the thread state and the reactions it has received. |
| 303 | type roomItem struct { |
| 304 | - ID, PubKey, Kind, Content, Room, Root, Role, Notice string |
| 305 | - CreatedAt, UpdatedAt int64 |
| 306 | - Agent, InThread, Edited bool |
| 307 | - Mentions []string |
| 308 | - Replies int |
| 309 | - Reactions []reaction |
| 310 | - Attachments []roomAttachment |
| 311 | + ID, PubKey, Avatar, Kind, Content, Room, Root, Role, Notice string |
| 312 | + CreatedAt, UpdatedAt int64 |
| 313 | + Agent, InThread, Edited, Own bool |
| 314 | + Mentions []string |
| 315 | + Replies int |
| 316 | + Reactions []reaction |
| 317 | + Attachments []roomAttachment |
| 318 | } |
| 319 | |
| 320 | // roomItems turns a browse result's messages or replies into view items, |
| 321 | // oldest first. Reactions and edits become summaries on their targets. |
| 322 | -func roomItems(value any, field string) []roomItem { |
| 323 | +func roomItems(value any, field string, actors ...string) []roomItem { |
| 324 | data := valueMap(value) |
| 325 | + actor := firstString(actors) |
| 326 | room := plainString(valueMap(data["room"])["id"]) |
| 327 | members := memberIndex(value) |
| 328 | replies := replyCounts(value) |
| 329 | @@ -322,13 +323,14 @@ func roomItems(value any, field string) []roomItem { |
| 330 | item.Replies = replies[item.ID] |
| 331 | item.Reactions = reactions[item.ID] |
| 332 | item.InThread = field == "replies" |
| 333 | + item.Own = actor != "" && item.PubKey == actor && item.Notice == "" |
| 334 | items = append(items, item) |
| 335 | } |
| 336 | return items |
| 337 | } |
| 338 | |
| 339 | // roomRoot is the thread page's root message, with the page's replies counted. |
| 340 | -func roomRoot(value any) roomItem { |
| 341 | +func roomRoot(value any, actors ...string) roomItem { |
| 342 | data := valueMap(value) |
| 343 | item, _ := newRoomItem(data["root"], plainString(valueMap(data["room"])["id"]), memberIndex(value)) |
| 344 | if edit, found := editSummary(value)[item.ID][item.PubKey]; found && edit.CreatedAt >= item.CreatedAt { |
| 345 | @@ -337,9 +339,18 @@ func roomRoot(value any) roomItem { |
| 346 | } |
| 347 | item.Replies = len(roomSlice(value, "replies")) |
| 348 | item.InThread = true |
| 349 | + actor := firstString(actors) |
| 350 | + item.Own = actor != "" && item.PubKey == actor && item.Notice == "" |
| 351 | return item |
| 352 | } |
| 353 | |
| 354 | +func firstString(values []string) string { |
| 355 | + if len(values) == 0 { |
| 356 | + return "" |
| 357 | + } |
| 358 | + return values[0] |
| 359 | +} |
| 360 | + |
| 361 | func newRoomItem(row any, room string, members map[string]map[string]any) (roomItem, bool) { |
| 362 | e := valueMap(row) |
| 363 | 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"])} |
| 364 | @@ -363,6 +374,9 @@ func newRoomItem(row any, room string, members map[string]map[string]any) (roomI |
| 365 | item.Root = roots[0] |
| 366 | } |
| 367 | } |
| 368 | + if len(item.PubKey) >= 2 { |
| 369 | + item.Avatar = strings.ToUpper(item.PubKey[:2]) |
| 370 | + } |
| 371 | if member := members[item.PubKey]; member != nil { |
| 372 | item.Role = plainString(member["role"]) |
| 373 | item.Agent, _ = member["agent"].(bool) |
| 374 | diff --git a/internal/webui/rooms.html b/internal/webui/rooms.html |
| 375 | index 6b45829..79d4c38 100644 |
| 376 | --- a/internal/webui/rooms.html |
| 377 | +++ b/internal/webui/rooms.html |
| 378 | @@ -24,12 +24,12 @@ establishes the initial state. */}} |
| 379 | {{template "alert" .Error}} |
| 380 | {{if not .Actor}}<p><a href="/signin">Sign in</a> to read this room.</p>{{end}} |
| 381 | </section> |
| 382 | -{{else}}{{$data := asMap .Event}}{{$room := asMap (index $data "room")}}{{$id := str (index $room "id")}}<section id="room"> |
| 383 | +{{else}}{{$data := asMap .Event}}{{$room := asMap (index $data "room")}}{{$id := str (index $room "id")}}<section id="room" data-viewer="{{.Actor}}"> |
| 384 | <h1>{{or (str (index $room "name")) $id}} <small>{{if eq (str (index $room "access")) "members"}}members only{{else}}open room{{end}}</small></h1> |
| 385 | {{with str (index $room "about")}}<p>{{.}}</p>{{end}} |
| 386 | {{with index $data "next_cursor"}}<p id="pages"><a href="/rooms/{{$id}}?cursor={{urlquery (str .)}}">load earlier</a></p>{{end}} |
| 387 | {{if .Query.Get "cursor"}}<p id="newest"><a href="/rooms/{{$id}}">newest messages</a></p>{{end}} |
| 388 | -<div id="messages">{{range roomItems .Event "messages"}} |
| 389 | +<div id="messages">{{range roomItems .Event "messages" .Actor}} |
| 390 | {{template "roomMessage" .}}{{else}}<p id="empty">No messages yet.</p>{{end}} |
| 391 | </div> |
| 392 | {{if not (.Query.Get "cursor")}}<room-live room="{{$id}}"></room-live> |
| 393 | @@ -43,11 +43,11 @@ establishes the initial state. */}} |
| 394 | <h1>Thread</h1> |
| 395 | {{template "alert" .Error}} |
| 396 | </section> |
| 397 | -{{else}}{{$data := asMap .Event}}{{$room := asMap (index $data "room")}}{{$id := str (index $room "id")}}{{$root := roomRoot .Event}}<section id="thread"> |
| 398 | +{{else}}{{$data := asMap .Event}}{{$room := asMap (index $data "room")}}{{$id := str (index $room "id")}}{{$root := roomRoot .Event .Actor}}<section id="thread" data-viewer="{{.Actor}}"> |
| 399 | <p id="crumbs"><a href="/rooms/{{$id}}">{{or (str (index $room "name")) $id}}</a></p> |
| 400 | <h1>Thread <small>{{$root.Replies}} {{if eq $root.Replies 1}}reply{{else}}replies{{end}}</small></h1> |
| 401 | <div id="root">{{template "roomMessage" $root}}</div> |
| 402 | -<div id="messages">{{range roomItems .Event "replies"}} |
| 403 | +<div id="messages">{{range roomItems .Event "replies" .Actor}} |
| 404 | {{template "roomMessage" .}}{{else}}<p id="empty">No replies yet.</p>{{end}} |
| 405 | </div> |
| 406 | {{with index $data "next_cursor"}}<p id="pages"><a href="/rooms/{{$id}}/thread/{{$root.ID}}?cursor={{urlquery (str .)}}">load earlier</a></p>{{end}} |
| 407 | @@ -62,8 +62,8 @@ establishes the initial state. */}} |
| 408 | {{define "roomSendIcon"}}<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19V5m-7 7 7-7 7 7"/></svg>{{end}} |
| 409 | |
| 410 | {{/* One message. room-live builds the same markup for messages that arrive on the stream. */}} |
| 411 | -{{define "roomMessage"}}<room-message id="msg-{{.ID}}" data-id="{{.ID}}" data-kind="{{.Kind}}" data-updated-at="{{.UpdatedAt}}" data-pubkey="{{.PubKey}}"{{if .Agent}} data-agent{{end}}{{if .Notice}} data-notice{{end}}{{if .Edited}} data-edited{{end}}> |
| 412 | -<header><b>{{template "nostrName" .PubKey}}</b>{{if .Role}} | {{.Role}}{{end}}<small><time datetime="{{datetime .CreatedAt}}" title="{{when .CreatedAt}}">{{clock .CreatedAt}}</time></small></header> |
| 413 | +{{define "roomMessage"}}<room-message id="msg-{{.ID}}" data-id="{{.ID}}" data-kind="{{.Kind}}" data-updated-at="{{.UpdatedAt}}" data-pubkey="{{.PubKey}}"{{if .Own}} data-own{{end}}{{if .Agent}} data-agent{{end}}{{if .Notice}} data-notice{{end}}{{if .Edited}} data-edited{{end}}><nostr-avatar pubkey="{{.PubKey}}" aria-hidden="true">{{.Avatar}}</nostr-avatar> |
| 414 | +<header><b>{{template "nostrName" .PubKey}}</b>{{if .Own}}<span data-own-label>you</span>{{end}}{{if .Role}} | {{.Role}}{{end}}<small><time datetime="{{datetime .CreatedAt}}" title="{{when .CreatedAt}}">{{clock .CreatedAt}}</time></small></header> |
| 415 | {{if .Notice}}<p>{{.Notice}}</p>{{else}}<div>{{chatMarkdown (roomAttachmentContent .Content .Attachments)}}</div>{{roomAttachmentMarkup .Attachments}}{{end}} |
| 416 | {{if or .Mentions (and (not .InThread) (or (eq .Kind "11") .Root)) .Reactions .Edited}}<footer>{{if .Mentions}}<span>to {{range .Mentions}}{{template "nostrName" .}} {{end}}</span>{{end}}{{if not .InThread}}{{if eq .Kind "11"}}<a href="/rooms/{{.Room}}/thread/{{.ID}}">thread{{if .Replies}} | {{.Replies}} {{if eq .Replies 1}}reply{{else}}replies{{end}}{{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}}{{if .Edited}}<span data-edited>edited</span>{{end}}</footer>{{end}} |
| 417 | </room-message>{{end}} |
| 418 | diff --git a/internal/webui/rooms.js b/internal/webui/rooms.js |
| 419 | index 5eb86d3..652ebc0 100644 |
| 420 | --- a/internal/webui/rooms.js |
| 421 | +++ b/internal/webui/rooms.js |
| 422 | @@ -88,6 +88,86 @@ |
| 423 | node.append(text.slice(last)); |
| 424 | return node; |
| 425 | }; |
| 426 | + const pipeEscaped = (value, index) => { |
| 427 | + let slashes = 0; |
| 428 | + for (let i = index - 1; i >= 0 && value[i] === "\\"; i--) slashes++; |
| 429 | + return slashes % 2 === 1; |
| 430 | + }; |
| 431 | + const pipeRow = line => { |
| 432 | + let value = String(line).trim(); |
| 433 | + if (!value.includes("|")) return null; |
| 434 | + const leadingPipe = value.startsWith("|"); |
| 435 | + const trailingPipe = value.endsWith("|") && !pipeEscaped(value, value.length - 1); |
| 436 | + if (value.startsWith("|")) value = value.slice(1); |
| 437 | + const last = value.length - 1; |
| 438 | + if (value.endsWith("|") && !pipeEscaped(value, last)) value = value.slice(0, -1); |
| 439 | + const cells = []; |
| 440 | + let hasPipe = false; |
| 441 | + let cell = "", code = false; |
| 442 | + for (let i = 0; i < value.length; i++) { |
| 443 | + const character = value[i]; |
| 444 | + if (character === "`") { code = !code; cell += character; continue; } |
| 445 | + if (character === "|" && pipeEscaped(value, i)) { cell = cell.slice(0, -1) + "|"; continue; } |
| 446 | + if (character === "|" && !code) { |
| 447 | + hasPipe = true; cells.push(cell.trim()); cell = ""; continue; |
| 448 | + } |
| 449 | + cell += character; |
| 450 | + } |
| 451 | + cells.push(cell.trim()); |
| 452 | + return (hasPipe || leadingPipe || trailingPipe) ? cells : null; |
| 453 | + }; |
| 454 | + const tableDelimiter = cell => { |
| 455 | + const value = String(cell).trim(); |
| 456 | + if (!/^:?-{3,}:?$/.test(value)) return null; |
| 457 | + return value.startsWith(":") ? (value.endsWith(":") ? "center" : "left") : (value.endsWith(":") ? "right" : ""); |
| 458 | + }; |
| 459 | + const tableBodyLine = line => { |
| 460 | + const value = String(line).trim(); |
| 461 | + return Boolean(value) && !value.startsWith("#") && !value.startsWith("```") && !value.startsWith("~~~"); |
| 462 | + }; |
| 463 | + const markdownTable = (lines, start) => { |
| 464 | + const header = pipeRow(lines[start]), separator = pipeRow(lines[start + 1]); |
| 465 | + if (!header || !separator || header.length !== separator.length) return null; |
| 466 | + const align = separator.map(tableDelimiter); |
| 467 | + if (align.some(value => value === null)) return null; |
| 468 | + const rows = []; |
| 469 | + let index = start + 2; |
| 470 | + for (; index < lines.length; index++) { |
| 471 | + if (!tableBodyLine(lines[index])) break; |
| 472 | + const row = pipeRow(lines[index]); |
| 473 | + if (!row) break; |
| 474 | + rows.push(row); |
| 475 | + } |
| 476 | + const wrapper = el("div"); |
| 477 | + wrapper.dataset.markdownTable = ""; |
| 478 | + wrapper.setAttribute("data-markdown-table", ""); |
| 479 | + wrapper.setAttribute("role", "region"); |
| 480 | + wrapper.setAttribute("aria-label", "Table"); |
| 481 | + wrapper.setAttribute("tabindex", "0"); |
| 482 | + const table = el("table"), thead = el("thead"), heading = el("tr"); |
| 483 | + header.forEach((cell, column) => { |
| 484 | + const th = el("th"); |
| 485 | + th.setAttribute("scope", "col"); |
| 486 | + if (align[column]) { th.dataset.align = align[column]; th.setAttribute("data-align", align[column]); } |
| 487 | + chatInline(th, cell); |
| 488 | + heading.append(th); |
| 489 | + }); |
| 490 | + thead.append(heading); |
| 491 | + const tbody = el("tbody"); |
| 492 | + rows.forEach(row => { |
| 493 | + const tr = el("tr"); |
| 494 | + for (let column = 0; column < header.length; column++) { |
| 495 | + const td = el("td"); |
| 496 | + if (align[column]) { td.dataset.align = align[column]; td.setAttribute("data-align", align[column]); } |
| 497 | + chatInline(td, row[column] || ""); |
| 498 | + tr.append(td); |
| 499 | + } |
| 500 | + tbody.append(tr); |
| 501 | + }); |
| 502 | + table.append(thead, tbody); |
| 503 | + wrapper.append(table); |
| 504 | + return {node: wrapper, end: index}; |
| 505 | + }; |
| 506 | const listItem = line => /^\s*(?:[-*+]|\d+[.)])\s+/.test(line); |
| 507 | const chatMarkdown = (node, text) => { |
| 508 | const lines = String(text).replace(/\r\n/g, "\n").split("\n"); |
| 509 | @@ -101,11 +181,18 @@ |
| 510 | }; |
| 511 | for (let i = 0; i < lines.length; i++) { |
| 512 | const line = lines[i]; |
| 513 | - if (line.startsWith("```")) { |
| 514 | + const fence = line.trim().match(/^(`{3,}|~{3,})/); |
| 515 | + const table = !fence && markdownTable(lines, i); |
| 516 | + if (fence) { |
| 517 | flush(); |
| 518 | - const code = []; |
| 519 | - for (i++; i < lines.length && !lines[i].startsWith("```"); i++) code.push(lines[i]); |
| 520 | + const code = [], marker = fence[1]; |
| 521 | + const closesFence = value => value.length >= marker.length && [...value].every(character => character === marker[0]); |
| 522 | + for (i++; i < lines.length && !closesFence(lines[i].trim()); i++) code.push(lines[i]); |
| 523 | const pre = el("pre"); pre.append(el("code", code.join("\n"))); node.append(pre); |
| 524 | + } else if (table) { |
| 525 | + flush(); |
| 526 | + node.append(table.node); |
| 527 | + i = table.end - 1; |
| 528 | } else if (line.startsWith("#")) { |
| 529 | flush(); |
| 530 | const level = Math.min(6, line.length - line.replace(/^#+/, "").length); |
| 531 | @@ -245,23 +332,31 @@ |
| 532 | const node = el("room-message"); |
| 533 | const notice = event.kind === 44100 ? "joined the room" : event.kind === 44101 ? "left the room" : ""; |
| 534 | const pubkey = notice ? tagValue(event, "p") : event.pubkey; |
| 535 | + const viewer = (document.getElementById("room") || document.getElementById("thread"))?.dataset.viewer; |
| 536 | node.id = "msg-" + event.id; |
| 537 | node.dataset.id = event.id; |
| 538 | node.dataset.kind = String(event.kind); |
| 539 | node.dataset.updatedAt = String(event.created_at); |
| 540 | node.dataset.pubkey = pubkey; |
| 541 | const member = members[pubkey]; |
| 542 | + const own = !notice && Boolean(viewer) && pubkey === viewer; |
| 543 | + if (own) node.dataset.own = ""; |
| 544 | if (member?.agent) node.dataset.agent = ""; |
| 545 | if (notice) node.dataset.notice = ""; |
| 546 | + const avatar = el("nostr-avatar", pubkey.slice(0, 2).toUpperCase()); |
| 547 | + avatar.setAttribute("pubkey", pubkey); |
| 548 | + avatar.setAttribute("aria-hidden", "true"); |
| 549 | const header = el("header"), name = el("b"), time = el("time", clock(event.created_at)), small = el("small"); |
| 550 | name.append(nameNode(pubkey)); |
| 551 | time.dateTime = new Date(event.created_at * 1000).toISOString().replace(/\.\d+Z$/, "Z"); |
| 552 | time.title = time.dateTime.slice(0, 16).replace("T", " ") + " UTC"; |
| 553 | small.append(time); |
| 554 | - header.append(name, member?.role ? " | " + member.role : "", small); |
| 555 | + header.append(name); |
| 556 | + if (own) { const label = el("span", "you"); label.dataset.ownLabel = ""; header.append(label); } |
| 557 | + header.append(member?.role ? " | " + member.role : "", small); |
| 558 | const attachments = notice ? [] : roomAttachments(event); |
| 559 | const body = notice ? el("p", notice) : chatMarkdown(el("div"), attachmentContent(event.content || "", attachments)); |
| 560 | - node.append(header, body); |
| 561 | + node.append(avatar, header, body); |
| 562 | const media = attachmentNodes(attachments); if (media) node.append(media); |
| 563 | const footer = el("footer"); |
| 564 | const mentions = notice ? [] : (event.tags || []).filter(tag => tag[0] === "p" && isHex64(tag[1]) && tag[1] !== pubkey).map(tag => tag[1]); |
| 565 | diff --git a/internal/webui/rooms_test.go b/internal/webui/rooms_test.go |
| 566 | index 0ce88fe..c744345 100644 |
| 567 | --- a/internal/webui/rooms_test.go |
| 568 | +++ b/internal/webui/rooms_test.go |
| 569 | @@ -143,6 +143,10 @@ func TestRoomPageRendersMessagesOldestFirstWithMarkers(t *testing.T) { |
| 570 | `<h1>General <small>open room</small></h1>`, `<p>Where General happens.</p>`, |
| 571 | `<a href="/rooms/general?cursor=cursor-1">load earlier</a>`, |
| 572 | `<room-message id="msg-` + roomHello + `" data-id="` + roomHello + `" data-kind="9" data-updated-at="1757203650" data-pubkey="` + roomAgent + `" data-agent data-edited>`, |
| 573 | + `<nostr-avatar pubkey="` + roomAgent + `" aria-hidden="true">BB</nostr-avatar>`, |
| 574 | + `<room-message id="msg-` + roomChat + `" data-id="` + roomChat + `" data-kind="9" data-updated-at="1757202000" data-pubkey="` + roomOwner + `" data-own><nostr-avatar pubkey="` + roomOwner + `" aria-hidden="true">AA</nostr-avatar>`, |
| 575 | + `</b><span data-own-label>you</span>`, |
| 576 | + `<section id="room" data-viewer="` + roomOwner + `">`, |
| 577 | `<a href="https://example.com/docs" rel="noopener">https://example.com/docs</a>.`, |
| 578 | `<a href="/open?target=nostr%3Anpub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc2">nostr:npub1`, |
| 579 | `<script>alert(1)</script>`, |
| 580 | @@ -202,6 +206,7 @@ func TestThreadPageRendersRootRepliesAndReplyCompose(t *testing.T) { |
| 581 | for _, want := range []string{ |
| 582 | `<p id="crumbs"><a href="/rooms/general">General</a></p>`, `<h1>Thread <small>1 reply</small></h1>`, |
| 583 | `<div id="root"><room-message id="msg-` + roomThread + `"`, `<div id="messages">`, `id="msg-` + roomReply + `"`, |
| 584 | + `data-viewer="` + roomOwner + `"`, `<span data-own-label>you</span>`, |
| 585 | `<room-live room="general" root="` + roomThread + `"></room-live>`, |
| 586 | `<room-compose room="general" pubkey="` + roomOwner + `" kind="12" root="` + roomThread + `" root-pubkey="` + roomAgent + `">`, |
| 587 | `» <page-link url="http://relay.example/rooms/general/thread/` + roomThread + `" title="Copy the page address">rooms/general/thread/` + shortID(roomThread) + `</page-link></span>`, |
| 588 | diff --git a/internal/webui/style.css b/internal/webui/style.css |
| 589 | index 3cfccbb..2d5ef63 100644 |
| 590 | --- a/internal/webui/style.css |
| 591 | +++ b/internal/webui/style.css |
| 592 | @@ -111,6 +111,14 @@ dl{margin:.5rem 0}dt{font-weight:600}dd{margin:0 0 .35rem 1rem} |
| 593 | #readme{max-width:720px}#readme h1{font-size:24px;border-bottom:1px solid var(--line);padding-bottom:8px}#readme h2{font-size:17px} |
| 594 | #readme pre{background:var(--rail);border:1px solid var(--line);padding:10px 12px;overflow:auto;font-size:12.5px;line-height:1.5;white-space:pre} |
| 595 | #readme code{font-size:12.5px}#readme ul,#readme ol{padding-left:20px} |
| 596 | +[data-markdown-table]{max-width:100%;margin:10px 0;overflow-x:auto;overscroll-behavior-x:contain;border:1px solid var(--line);border-radius:8px} |
| 597 | +[data-markdown-table]:focus-visible{outline:2px solid var(--accent);outline-offset:2px} |
| 598 | +[data-markdown-table]>table{width:100%;border-collapse:collapse;font:inherit;text-align:left} |
| 599 | +[data-markdown-table] th,[data-markdown-table] td{min-width:8em;padding:10px 12px;vertical-align:top;border-bottom:1px solid var(--line);border-right:1px solid var(--line);overflow-wrap:anywhere;line-height:1.5} |
| 600 | +[data-markdown-table] th{background:var(--rail);font-weight:600;color:var(--ink)} |
| 601 | +[data-markdown-table] th:last-child,[data-markdown-table] td:last-child{border-right:0} |
| 602 | +[data-markdown-table] tbody tr:last-child td{border-bottom:0} |
| 603 | +[data-markdown-table] [data-align=left]{text-align:left}[data-markdown-table] [data-align=center]{text-align:center}[data-markdown-table] [data-align=right]{text-align:right} |
| 604 | |
| 605 | /* rpc-form, signed-form, publish-list: one signed call each. */ |
| 606 | rpc-form,signed-form,publish-list,nostr-compose,file-tools,file-upload,file-workspace-root:not([hidden]){display:block;border-bottom:1px solid var(--line);padding:10px 0} |
| 607 | @@ -279,11 +287,17 @@ wiki-compose output{display:block;font:12px var(--mono);color:var(--muted);margi |
| 608 | /* Rooms read as a centered conversation column; the compose bar spans the |
| 609 | content column but its controls sit in the same 720px lane. */ |
| 610 | #room>*:not(room-compose),#thread>*:not(room-compose),room-compose>*{max-width:720px;margin-left:auto;margin-right:auto} |
| 611 | -room-message{display:block;padding:8px 0;border-bottom:1px solid var(--line)} |
| 612 | -room-message>header{font:12px var(--mono);color:var(--muted);margin-bottom:2px;user-select:none;-webkit-user-select:none} |
| 613 | +room-message{display:block;position:relative;min-width:0;margin:10px 0;padding:12px 0 14px 44px;border-bottom:1px solid var(--line)} |
| 614 | +room-message>nostr-avatar{position:absolute;top:12px;left:0} |
| 615 | +nostr-avatar{display:inline-block;box-sizing:border-box;width:32px;height:32px;overflow:hidden;border:1px solid var(--line);border-radius:50%;background:var(--rail);color:var(--muted);text-align:center;font:11px/30px var(--mono);user-select:none;-webkit-user-select:none} |
| 616 | +nostr-avatar img{display:block;width:100%;height:100%;object-fit:cover} |
| 617 | +room-message>header{font:12px/1.5 var(--mono);color:var(--muted);margin-bottom:6px;overflow-wrap:anywhere;user-select:none;-webkit-user-select:none} |
| 618 | room-message>header b{color:var(--ink);font-weight:500}room-message>header b nostr-key{cursor:copy} |
| 619 | -room-message>header small{float:right} |
| 620 | -room-message>p,room-message>div{margin:0;font:14px/1.45 var(--sans);overflow-wrap:anywhere}room-message>p{white-space:pre-wrap} |
| 621 | +room-message>header small{float:right;margin-left:8px;white-space:nowrap} |
| 622 | +room-message[data-own]{margin-left:24px;padding:12px 14px 14px 56px;background:var(--soft);border:1px solid var(--line);border-left:3px solid var(--accent);border-radius:12px} |
| 623 | +room-message[data-own]>nostr-avatar{left:12px} |
| 624 | +room-message [data-own-label]{margin-left:6px;color:var(--accent);font-size:11px} |
| 625 | +room-message>p,room-message>div{min-width:0;margin:0;font:14px/1.55 var(--sans);overflow-wrap:anywhere}room-message>p{white-space:pre-wrap} |
| 626 | room-message>p a,room-message>div a{color:var(--accent)} |
| 627 | room-message>div p{margin:0 0 6px}room-message>div>:last-child{margin-bottom:0}room-message>div ul,room-message>div ol{margin:0 0 6px 20px;padding:0} |
| 628 | room-message>div pre{margin:0 0 6px;padding:6px 8px;background:var(--rail);border:1px solid var(--line);overflow:auto;font:12px/1.4 var(--mono);white-space:pre}room-message>div code{font:12px var(--mono)}room-message>div h1,room-message>div h2,room-message>div h3,room-message>div h4{margin:4px 0 6px;font-size:14px;border:0} |
| 629 | @@ -351,6 +365,7 @@ html,body{overflow:auto} |
| 630 | #tree{border-right:0;border-bottom:1px solid var(--line)} |
| 631 | #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} |
| 632 | #grid,#gridhead,h2:has(+#gridhead){display:none} |
| 633 | +room-message[data-own]{margin-left:12px;padding-right:10px} |
| 634 | /* Phones: each wiki row reads as a title line and one muted line of facts. */ |
| 635 | #wiki-list #rows thead{display:none}#wiki-list #rows,#wiki-list #rows tbody,#wiki-list #rows tr{display:block;width:auto}#wiki-list #rows tr{padding:8px 0;border-bottom:1px solid var(--line)}#wiki-list #rows td{display:inline;padding:0;border:0;width:auto;height:auto;line-height:1.4;font:12px var(--mono);color:var(--muted)}#wiki-list #rows td:first-child{display:block;font:14px var(--sans);color:var(--ink);margin:0 0 2px}#wiki-list #rows td:first-child small{display:block;margin:2px 0 0}#wiki-list #rows td:nth-child(3){display:none}#wiki-list #rows td:nth-child(2)::after,#wiki-list #rows td:nth-child(4)::after{content:" | "} |
| 636 | room-compose{bottom:calc(28px + env(safe-area-inset-bottom,0px));margin:8px -2.5ch 0;padding:8px 12px;z-index:1} |
| 637 | diff --git a/scripts/chat-identity.test.mjs b/scripts/chat-identity.test.mjs |
| 638 | new file mode 100644 |
| 639 | index 0000000..fcdbf3d |
| 640 | --- /dev/null |
| 641 | +++ b/scripts/chat-identity.test.mjs |
| 642 | @@ -0,0 +1,35 @@ |
| 643 | +import test from "node:test"; |
| 644 | +import assert from "node:assert/strict"; |
| 645 | +import {setup, FakeNode} from "./rooms.test.mjs"; |
| 646 | + |
| 647 | +test("live room and thread messages identify the viewer and render avatars", () => { |
| 648 | + for (const page of ["room", "thread"]) { |
| 649 | + const s = setup(), section = new FakeNode("section"); |
| 650 | + section.id = page; |
| 651 | + section.dataset.viewer = s.pubkey; |
| 652 | + s.sandbox.document.body.append(section); |
| 653 | + const event = {id: "e".repeat(64), pubkey: s.pubkey, kind: 9, created_at: 1789041600, tags: [], content: "My message"}; |
| 654 | + const own = s.rooms.roomAppend(event, {room: "build", inThread: page === "thread"}); |
| 655 | + assert.equal(own.hasAttribute("data-own"), true); |
| 656 | + assert.equal(own.querySelector("header [data-own-label]").textContent, "you"); |
| 657 | + const avatar = own.querySelector(":scope > nostr-avatar"); |
| 658 | + assert.equal(avatar.getAttribute("pubkey"), s.pubkey); |
| 659 | + assert.equal(avatar.getAttribute("aria-hidden"), "true"); |
| 660 | + assert.equal(avatar.textContent, s.pubkey.slice(0, 2).toUpperCase()); |
| 661 | + const other = s.rooms.messageNode({...event, pubkey: "b".repeat(64)}); |
| 662 | + assert.equal(other.hasAttribute("data-own"), false); |
| 663 | + assert.equal(other.querySelector("[data-own-label]"), null); |
| 664 | + const notice = s.rooms.messageNode({...event, kind: 44100, tags: [["p", s.pubkey]]}); |
| 665 | + assert.equal(notice.hasAttribute("data-own"), false); |
| 666 | + s.rooms.roomEdit({...event, kind: 40003, tags: [["e", event.id]], content: "Edited message"}); |
| 667 | + assert.equal(own.hasAttribute("data-own"), true); |
| 668 | + assert.equal(own.querySelector("nostr-avatar"), avatar); |
| 669 | + } |
| 670 | +}); |
| 671 | + |
| 672 | +test("guest messages do not claim a viewer identity", () => { |
| 673 | + const s = setup(); |
| 674 | + const message = s.rooms.messageNode({id: "e".repeat(64), pubkey: s.pubkey, kind: 9, created_at: 1789041600, tags: [], content: "Hello"}); |
| 675 | + assert.equal(message.hasAttribute("data-own"), false); |
| 676 | + assert.equal(message.querySelector("[data-own-label]"), null); |
| 677 | +}); |
| 678 | diff --git a/scripts/chat-tables.test.mjs b/scripts/chat-tables.test.mjs |
| 679 | new file mode 100644 |
| 680 | index 0000000..73a981b |
| 681 | --- /dev/null |
| 682 | +++ b/scripts/chat-tables.test.mjs |
| 683 | @@ -0,0 +1,70 @@ |
| 684 | +import test from "node:test"; |
| 685 | +import assert from "node:assert/strict"; |
| 686 | +import { FakeNode, setup } from "./rooms.test.mjs"; |
| 687 | + |
| 688 | +test("chat tables render semantic headers, alignment and uneven rows", () => { |
| 689 | + const s = setup(); |
| 690 | + const body = s.rooms.chatMarkdown(new FakeNode("div"), "| Name | Notes | Score |\n| :--- | :---: | ---: |\n| Ada | **ready** | 10 |\n| Grace | `a|b` |\n| Alan | left"); |
| 691 | + const region = body.children[0], table = region.querySelector("table"); |
| 692 | + assert.equal(region.getAttribute("data-markdown-table"), ""); |
| 693 | + assert.equal(region.getAttribute("role"), "region"); |
| 694 | + assert.equal(region.getAttribute("aria-label"), "Table"); |
| 695 | + assert.equal(region.getAttribute("tabindex"), "0"); |
| 696 | + assert.deepEqual(table.querySelectorAll("th").map(cell => [cell.getAttribute("scope"), cell.getAttribute("data-align")]), [["col", "left"], ["col", "center"], ["col", "right"]]); |
| 697 | + assert.equal(table.querySelectorAll("tbody tr").length, 3); |
| 698 | + assert.equal(table.querySelectorAll("tbody tr")[1].children.length, 3); |
| 699 | + assert.equal(table.querySelectorAll("tbody tr")[1].children[1].querySelector("code").textContent, "a|b"); |
| 700 | + assert.equal(table.querySelectorAll("tbody tr")[2].children[2].textContent, ""); |
| 701 | + assert.equal(table.querySelector("strong").textContent, "ready"); |
| 702 | +}); |
| 703 | + |
| 704 | +test("chat tables accept escaped pipes and keep inline content safe", () => { |
| 705 | + const s = setup(); |
| 706 | + const body = s.rooms.chatMarkdown(new FakeNode("div"), "Label | Link\n--- | ---\nA \\| B | [go](javascript:alert(1)) <img>"); |
| 707 | + const table = body.querySelector("table"); |
| 708 | + assert.equal(table.querySelector("tbody td").textContent, "A | B"); |
| 709 | + assert.equal(table.querySelectorAll("a").length, 0); |
| 710 | + assert.equal(table.textContent.includes("<img>"), true); |
| 711 | +}); |
| 712 | + |
| 713 | +test("chat tables preserve single columns and distinguish escaped trailing pipes", () => { |
| 714 | + const s = setup(); |
| 715 | + const body = s.rooms.chatMarkdown(new FakeNode("div"), "| Value |\n| --- |\n| `a\\|b` |"); |
| 716 | + assert.equal(body.querySelectorAll("table").length, 1); |
| 717 | + assert.equal(body.querySelector("th").textContent, "Value"); |
| 718 | + assert.equal(body.querySelector("tbody code").textContent, "a|b"); |
| 719 | + const prose = s.rooms.chatMarkdown(new FakeNode("div"), "A \\\\| B\n| --- |"); |
| 720 | + assert.equal(prose.querySelectorAll("table").length, 0); |
| 721 | +}); |
| 722 | + |
| 723 | +test("malformed tables and tables in fenced code remain prose or code", () => { |
| 724 | + const s = setup(); |
| 725 | + const body = s.rooms.chatMarkdown(new FakeNode("div"), "| Header | Other\n| -- | ---\n\n```\n| A | B |\n| --- | --- |\n```"); |
| 726 | + assert.equal(body.querySelectorAll("[data-markdown-table]").length, 0); |
| 727 | + assert.equal(body.querySelectorAll("pre").length, 1); |
| 728 | + assert.equal(body.querySelector("p").textContent, "| Header | Other| -- | ---"); |
| 729 | + assert.equal(body.querySelector("pre").textContent.includes("| A | B |"), true); |
| 730 | +}); |
| 731 | + |
| 732 | +test("rerendering edited chat content creates a fresh table node", () => { |
| 733 | + const s = setup(); |
| 734 | + const body = new FakeNode("div"); |
| 735 | + s.rooms.chatMarkdown(body, "| Before | State |\n| --- | --- |\n| old | draft |"); |
| 736 | + assert.equal(body.querySelector("tbody td").textContent, "old"); |
| 737 | + body.replaceChildren(); |
| 738 | + s.rooms.chatMarkdown(body, "| After | State |\n| --- | --- |\n| new | sent |"); |
| 739 | + assert.equal(body.querySelector("th").textContent, "After"); |
| 740 | + assert.equal(body.querySelector("tbody td").textContent, "new"); |
| 741 | +}); |
| 742 | + |
| 743 | +test("tables stop before headings and preserve backtick and tilde fences", () => { |
| 744 | + const s = setup(); |
| 745 | + for (const marker of ["```", " ~~~~"]) { |
| 746 | + const body = s.rooms.chatMarkdown(new FakeNode("div"), "| Name | Value |\n| --- | --- |\n| one | two |\n" + marker + "text | example\n| A | B |\n|---|---|\n" + marker + "\n# Heading | text"); |
| 747 | + assert.equal(body.querySelectorAll("table").length, 1); |
| 748 | + assert.equal(body.querySelector("th").getAttribute("data-align"), null); |
| 749 | + assert.equal(body.querySelectorAll("tbody tr").length, 1); |
| 750 | + assert.equal(body.querySelector("pre").textContent, "| A | B |\n|---|---|"); |
| 751 | + assert.equal(body.children.find(node => node.localName === "h1").textContent, "Heading | text"); |
| 752 | + } |
| 753 | +}); |
| 754 | diff --git a/scripts/nostr-avatar.test.mjs b/scripts/nostr-avatar.test.mjs |
| 755 | new file mode 100644 |
| 756 | index 0000000..31dc21d |
| 757 | --- /dev/null |
| 758 | +++ b/scripts/nostr-avatar.test.mjs |
| 759 | @@ -0,0 +1,80 @@ |
| 760 | +import test from "node:test"; |
| 761 | +import assert from "node:assert/strict"; |
| 762 | +import fs from "node:fs"; |
| 763 | +import vm from "node:vm"; |
| 764 | + |
| 765 | +const source = fs.readFileSync("internal/webui/components.js", "utf8"); |
| 766 | +const avatarSource = source.slice(source.indexOf(" class NostrAvatar "), source.indexOf(" // PageLink")); |
| 767 | +const key = "a".repeat(64); |
| 768 | + |
| 769 | +function setup(userPromise) { |
| 770 | + const images = []; |
| 771 | + const document = { |
| 772 | + createTextNode: text => ({textContent: text}), |
| 773 | + createElement: name => { |
| 774 | + const image = {nodeName: name, addEventListener: (event, callback) => { image.handlers ??= {}; image.handlers[event] = callback; }, setAttribute() {}}; |
| 775 | + images.push(image); |
| 776 | + return image; |
| 777 | + } |
| 778 | + }; |
| 779 | + const window = {tinyNames: {load: () => typeof userPromise === "function" ? userPromise() : userPromise}}; |
| 780 | + const HTMLElement = class { |
| 781 | + constructor() { this.attributes = {}; this.children = []; } |
| 782 | + getAttribute(name) { return this.attributes[name] ?? null; } |
| 783 | + replaceChildren(...children) { this.children = children; } |
| 784 | + querySelector(name) { return this.children.find(child => child.nodeName === name) || null; } |
| 785 | + }; |
| 786 | + const el = (name, text) => { const node = document.createElement(name); if (text !== undefined) node.textContent = text; return node; }; |
| 787 | + const Constructor = vm.runInNewContext(`${avatarSource}\nNostrAvatar`, {HTMLElement, window, document, URL, Promise, String, setTimeout, el}); |
| 788 | + const component = new Constructor(); |
| 789 | + component.attributes.pubkey = key; |
| 790 | + return {component, images}; |
| 791 | +} |
| 792 | + |
| 793 | +test("loads a safe profile image with privacy attributes", async () => { |
| 794 | + const {component, images} = setup(Promise.resolve({image: "https://cdn.example/avatar.png"})); |
| 795 | + component.connectedCallback(); |
| 796 | + await new Promise(resolve => setTimeout(resolve, 0)); |
| 797 | + const image = component.children[0]; |
| 798 | + assert.equal(image.src, "https://cdn.example/avatar.png"); |
| 799 | + assert.equal(image.loading, "lazy"); |
| 800 | + assert.equal(image.referrerPolicy, "no-referrer"); |
| 801 | + assert.equal(image.alt, ""); |
| 802 | +}); |
| 803 | + |
| 804 | +test("keeps the two character fallback for missing or unsafe pictures", async () => { |
| 805 | + for (const image of [undefined, "javascript:alert(1)", "/avatar.png"]) { |
| 806 | + const {component} = setup(Promise.resolve(image === undefined ? {} : {image})); |
| 807 | + component.connectedCallback(); |
| 808 | + await new Promise(resolve => setTimeout(resolve, 0)); |
| 809 | + assert.equal(component.children[0].textContent, "AA"); |
| 810 | + } |
| 811 | +}); |
| 812 | + |
| 813 | +test("restores the fallback after an image load failure", async () => { |
| 814 | + const {component, images} = setup(Promise.resolve({image: "https://cdn.example/avatar.png"})); |
| 815 | + component.connectedCallback(); |
| 816 | + await new Promise(resolve => setTimeout(resolve, 0)); |
| 817 | + images[0].handlers.error(); |
| 818 | + assert.equal(component.children[0].textContent, "AA"); |
| 819 | +}); |
| 820 | + |
| 821 | +test("ignores a stale profile result after the pubkey changes", async () => { |
| 822 | + let resolve, calls = 0; |
| 823 | + const {component} = setup(() => calls++ === 0 ? new Promise(done => { resolve = done; }) : Promise.resolve({})); |
| 824 | + component.connectedCallback(); |
| 825 | + component.attributes.pubkey = "b".repeat(64); |
| 826 | + component.attributeChangedCallback(); |
| 827 | + resolve({image: "https://cdn.example/avatar.png"}); |
| 828 | + await new Promise(resolve => setTimeout(resolve, 0)); |
| 829 | + assert.equal(component.children[0].textContent, "BB"); |
| 830 | +}); |
| 831 | + |
| 832 | +test("replaces a prior image when the pubkey becomes invalid", async () => { |
| 833 | + const {component} = setup(Promise.resolve({image: "https://cdn.example/avatar.png"})); |
| 834 | + component.connectedCallback(); |
| 835 | + await new Promise(resolve => setTimeout(resolve, 0)); |
| 836 | + component.attributes.pubkey = "bad"; |
| 837 | + component.attributeChangedCallback(); |
| 838 | + assert.equal(component.children[0].textContent, "BA"); |
| 839 | +}); |