Render Markdown tables in server pages, live messages and edits with accessible headers and horizontal scrolling. Show profile pictures with safe fallbacks and distinguish your messages with tinted cards and a you label. Record commit, push and Slate deployment as the project completion workflow. Validation: 173 JavaScript tests, browser bundle verification, Go web UI race tests and vet, and desktop, mobile and no-JavaScript browser checks.
- commit
- bcdaf4eb7f768d1575417cf60eb6a8cf9843c47c
- parent
- 728249910fc8cccbc04ace016fd68ef9898076ad
- author
- Dami <dami.etoile@gmail.com>
| M | AGENTS.md | 1 | + |
| M | internal/ | 44 | ++++++++++ |
| M | internal/ | 128 | ++++++++++++++++++++++++++++++- |
| M | internal/ | 47 | +++++++++++ |
| M | internal/ | 4 | +- |
| M | internal/ | 32 | +++++-- |
| M | internal/ | 12 | +- |
| M | internal/ | 105 | +++++++++++++++++++++++- |
| M | internal/ | 5 | + |
| M | internal/ | 23 | ++++- |
| A | scripts/ | 35 | ++++++++ |
| A | scripts/ | 70 | ++++++++++++++++ |
| A | scripts/ | 80 | +++++++++++++++++++ |
@@ -1,3 +1,4 @@
11 - The middle dot character (U+00B7) is disallowed in project text and interfaces.22 - Write all documentation in AP style, with clear, concise organization. Documentation is a user-facing surface; omit incidental implementation notes.3+- After completing work in this project, commit the changes, push them and deploy to Slate. Verify the deployment before reporting completion.34 - 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.@@ -698,6 +698,49 @@
698698 }699699 }700700 701+ class NostrAvatar extends HTMLElement {702+ connectedCallback() {703+ this.render();704+ }705+706+ attributeChangedCallback() { this.render(); }707+708+ static get observedAttributes() { return ["pubkey"]; }709+710+ render() {711+ const pubkey = String(this.getAttribute("pubkey") || "").toLowerCase();712+ const fallback = pubkey.slice(0, 2).toUpperCase();713+ const serial = (this.serial || 0) + 1;714+ this.serial = serial;715+ const restore = () => {716+ if (serial !== this.serial) return;717+ this.replaceChildren(document.createTextNode(fallback));718+ };719+ const load = window.tinyNames?.load;720+ if (!load || !/^[0-9a-f]{64}$/.test(pubkey)) {721+ restore();722+ return;723+ }724+ Promise.resolve(load({pubkey})).then(user => {725+ const raw = user?.image;726+ let url;727+ try {728+ const candidate = new URL(raw);729+ if (candidate.protocol === "http:" || candidate.protocol === "https:") url = candidate.href;730+ } catch {}731+ if (!url || serial !== this.serial) return restore();732+ const image = el("img");733+ image.src = url;734+ image.alt = "";735+ image.loading = "lazy";736+ image.referrerPolicy = "no-referrer";737+ image.setAttribute("aria-hidden", "true");738+ image.addEventListener("error", restore, {once: true});739+ this.replaceChildren(image);740+ }).catch(restore);741+ }742+ }743+701744 // PageLink is the crumb that names the current page. A tap copies the702745 // page's public address, the one the server put in url, and shows703746 // "copied" for a moment. Without JavaScript it is plain text.@@ -1337,6 +1380,7 @@
13371380 customElements.define("push-toggle", PushToggle);13381381 customElements.define("share-link", ShareLink);13391382 customElements.define("nostr-key", NostrKey);1383+ customElements.define("nostr-avatar", NostrAvatar);13401384 customElements.define("page-link", PageLink);13411385 customElements.define("json-view", JsonView);13421386 @@ -17,7 +17,7 @@ import (
1717 type blockRenderer func(lang, source string) (string, bool)1818 1919 // renderMarkdown turns a small, common subset of Markdown into HTML: ATX20-// headings, paragraphs, fenced code, unordered and ordered lists, inline20+// headings, paragraphs, tables, fenced code, unordered and ordered lists, inline2121 // code, emphasis, and links. Every piece of text is escaped first and only2222 // the tags this function emits reach the page, so untrusted README files and2323 // relay descriptions cannot inject markup. Anything else stays plain text.@@ -65,6 +65,11 @@ func markdownHTML(source string, breaks bool, blocks blockRenderer) template.HTM
6565 line := lines[i]6666 marker, lang, fenced := views.OpenFence(line)6767 switch {68+ case !fenced && i+1 < len(lines) && isTableCandidate(line, lines[i+1]):69+ flush()70+ table, end := renderTable(lines, i)71+ out.WriteString(table)72+ i = end6873 case fenced:6974 flush()7075 var code []string@@ -115,6 +120,127 @@ func markdownHTML(source string, breaks bool, blocks blockRenderer) template.HTM
115120 return template.HTML(out.String())116121 }117122 123+var tableDelimiter = regexp.MustCompile(`^:?-{3,}:?$`)124+125+func isTableCandidate(header, delimiter string) bool {126+ headerCells, headerHasPipes := splitTableRow(header)127+ delimiterCells, delimiterHasPipes := splitTableRow(delimiter)128+ if !headerHasPipes || !delimiterHasPipes || len(headerCells) == 0 || len(headerCells) != len(delimiterCells) {129+ return false130+ }131+ for _, cell := range delimiterCells {132+ if !tableDelimiter.MatchString(strings.TrimSpace(cell)) {133+ return false134+ }135+ }136+ return true137+}138+139+func splitTableRow(line string) ([]string, bool) {140+ line = strings.TrimSpace(line)141+ var cells []string142+ var cell strings.Builder143+ inCode := false144+ hasPipe := false145+ for i := 0; i < len(line); i++ {146+ if line[i] == '`' {147+ inCode = !inCode148+ }149+ if line[i] == '|' && !inCode && !escapedAt(line, i) {150+ hasPipe = true151+ cells = append(cells, strings.TrimSpace(cell.String()))152+ cell.Reset()153+ continue154+ }155+ cell.WriteByte(line[i])156+ }157+ cells = append(cells, strings.TrimSpace(cell.String()))158+ if len(cells) > 1 && cells[0] == "" {159+ cells = cells[1:]160+ }161+ if len(cells) > 1 && cells[len(cells)-1] == "" {162+ cells = cells[:len(cells)-1]163+ }164+ return cells, hasPipe165+}166+167+func escapedAt(value string, index int) bool {168+ backslashes := 0169+ for index > 0 && value[index-1] == '\\' {170+ backslashes++171+ index--172+ }173+ return backslashes%2 == 1174+}175+176+func renderTable(lines []string, start int) (string, int) {177+ header, _ := splitTableRow(lines[start])178+ separator, _ := splitTableRow(lines[start+1])179+ alignments := tableAlignments(separator)180+ var out strings.Builder181+ out.WriteString(`<div data-markdown-table="" role="region" aria-label="Table" tabindex="0"><table><thead><tr>`)182+ for i, cell := range header {183+ out.WriteString(`<th scope="col"` + tableAlignAttribute(alignments[i]) + `>` + inlineMarkdown(unescapeTablePipes(cell)) + `</th>`)184+ }185+ out.WriteString(`</tr></thead><tbody>`)186+ end := start + 1187+ for end+1 < len(lines) {188+ cells, pipes := splitTableRow(lines[end+1])189+ if !isTableBodyRow(lines[end+1], pipes) {190+ break191+ }192+ if len(cells) > len(header) {193+ cells = cells[:len(header)]194+ }195+ for len(cells) < len(header) {196+ cells = append(cells, "")197+ }198+ out.WriteString("<tr>")199+ for i, cell := range cells {200+ out.WriteString(`<td` + tableAlignAttribute(alignments[i]) + `>` + inlineMarkdown(unescapeTablePipes(cell)) + `</td>`)201+ }202+ out.WriteString("</tr>")203+ end++204+ }205+ out.WriteString(`</tbody></table></div>` + "\n")206+ return out.String(), end207+}208+209+func isTableBodyRow(line string, pipes bool) bool {210+ if !pipes || strings.TrimSpace(line) == "" || strings.HasPrefix(strings.TrimSpace(line), "#") {211+ return false212+ }213+ _, _, fenced := views.OpenFence(line)214+ return !fenced215+}216+217+func tableAlignments(cells []string) []string {218+ alignments := make([]string, len(cells))219+ for i, cell := range cells {220+ cell = strings.TrimSpace(cell)221+ switch {222+ case strings.HasPrefix(cell, ":") && strings.HasSuffix(cell, ":"):223+ alignments[i] = "center"224+ case strings.HasSuffix(cell, ":"):225+ alignments[i] = "right"226+ case strings.HasPrefix(cell, ":"):227+ alignments[i] = "left"228+ }229+ }230+ return alignments231+}232+233+func tableAlignAttribute(alignment string) string {234+ if alignment == "" {235+ return ""236+ }237+ return ` data-align="` + alignment + `"`238+}239+240+func unescapeTablePipes(value string) string {241+ return strings.ReplaceAll(value, `\|`, `|`)242+}243+118244 var (119245 orderedItem = regexp.MustCompile(`^\s*\d+[.)]\s+`)120246 bulletItem = regexp.MustCompile(`^\s*[-*+]\s+`)@@ -54,3 +54,50 @@ func TestChatMarkdownBreaksLinesAndAutolinksOutsideLinksAndCode(t *testing.T) {
5454 t.Fatalf("expected three links, got %s", got)5555 }5656 }57+58+func TestMarkdownRendersTablesWithAlignmentAndInlineMarkdown(t *testing.T) {59+ source := "| Name | Count | Notes |\n| :--- | ---: | :---: |\n| **Ada** | 3 | [docs](https://example.com) |\n| Grace | | `a | b` |"60+ got := string(renderMarkdown(source))61+ for _, want := range []string{62+ `<div data-markdown-table="" role="region" aria-label="Table" tabindex="0"><table>`,63+ `<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>`,64+ `<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>`,65+ `<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>`,66+ } {67+ if !strings.Contains(got, want) {68+ t.Errorf("missing %q in %s", want, got)69+ }70+ }71+}72+73+func TestMarkdownTableEscapesPipesAndLeavesMalformedInputAsText(t *testing.T) {74+ got := string(renderMarkdown("a \\| b\n---\n\n| one | two |\n| --- |\n| three | four | five |"))75+ if strings.Contains(got, `data-markdown-table`) {76+ t.Fatalf("malformed table was rendered: %s", got)77+ }78+ if !strings.Contains(got, `a \| b ---`) || !strings.Contains(got, `| one | two |`) {79+ t.Fatalf("malformed table was not preserved as text: %s", got)80+ }81+82+ got = string(renderMarkdown("a \\| b | c\n--- | ---\nvalue \\| kept | ok"))83+ if !strings.Contains(got, `<th scope="col">a | b</th>`) || !strings.Contains(got, `<td>value | kept</td>`) {84+ t.Fatalf("escaped pipe was split or escaped incorrectly: %s", got)85+ }86+}87+88+func TestChatMarkdownAutolinksTableText(t *testing.T) {89+ got := string(renderChatMarkdown("| Link |\n| --- |\n| https://example.com/path |"))90+ if !strings.Contains(got, `<td><a href="https://example.com/path" rel="noopener">https://example.com/path</a></td>`) {91+ t.Fatalf("table cell was not autolinked: %s", got)92+ }93+}94+95+func TestMarkdownTableStopsBeforeHeadingsAndFences(t *testing.T) {96+ got := string(renderMarkdown("| Name | Value |\n| --- | --- |\n| one | two |\n# | Heading |\n\n```sh | example\n| code |\n```"))97+ if strings.Contains(got, "<td># | Heading |</td>") || strings.Contains(got, "<td>```sh | example</td>") {98+ t.Fatalf("table consumed a following block: %s", got)99+ }100+ if !strings.Contains(got, "<h1>| Heading |</h1>") || !strings.Contains(got, "<pre><code data-lang=\"sh\">| code |</code></pre>") {101+ t.Fatalf("following heading or fence was not rendered separately: %s", got)102+ }103+}@@ -79,9 +79,9 @@ then feature modules are loaded for the current page. */}}
7979 <script src="{{scriptURL "blossom-upload.js"}}"></script>8080 <script src="{{scriptURL "file-messages.js"}}"></script>8181 <script src="{{scriptURL "file-workspace.js"}}"></script>82-{{end}}<script src="{{scriptURL "components.js"}}"></script>82+{{end}}<script src="{{scriptURL "nostr-name.js"}}"></script>83+<script src="{{scriptURL "components.js"}}"></script>8384 <script src="{{scriptURL "rooms.js"}}"></script>84-<script src="{{scriptURL "nostr-name.js"}}"></script>8585 {{if eq .Tab "connect"}}<script src="{{scriptURL "private-services.js"}}"></script>8686 {{end}}</body>8787 </html>@@ -289,19 +289,20 @@ func clock(value any) string {
289289 // roomItem is one message as the page shows it: the event's fields, the290290 // author's markers, the thread state and the reactions it has received.291291 type roomItem struct {292- ID, PubKey, Kind, Content, Room, Root, Role, Notice string293- CreatedAt, UpdatedAt int64294- Agent, InThread, Edited bool295- Mentions []string296- Replies int297- Reactions []reaction298- Attachments []roomAttachment292+ ID, PubKey, Avatar, Kind, Content, Room, Root, Role, Notice string293+ CreatedAt, UpdatedAt int64294+ Agent, InThread, Edited, Own bool295+ Mentions []string296+ Replies int297+ Reactions []reaction298+ Attachments []roomAttachment299299 }300300 301301 // roomItems turns a browse result's messages or replies into view items,302302 // oldest first. Reactions and edits become summaries on their targets.303-func roomItems(value any, field string) []roomItem {303+func roomItems(value any, field string, actors ...string) []roomItem {304304 data := valueMap(value)305+ actor := firstString(actors)305306 room := plainString(valueMap(data["room"])["id"])306307 members := memberIndex(value)307308 replies := replyCounts(value)@@ -322,13 +323,14 @@ func roomItems(value any, field string) []roomItem {
322323 item.Replies = replies[item.ID]323324 item.Reactions = reactions[item.ID]324325 item.InThread = field == "replies"326+ item.Own = actor != "" && item.PubKey == actor && item.Notice == ""325327 items = append(items, item)326328 }327329 return items328330 }329331 330332 // roomRoot is the thread page's root message, with the page's replies counted.331-func roomRoot(value any) roomItem {333+func roomRoot(value any, actors ...string) roomItem {332334 data := valueMap(value)333335 item, _ := newRoomItem(data["root"], plainString(valueMap(data["room"])["id"]), memberIndex(value))334336 if edit, found := editSummary(value)[item.ID][item.PubKey]; found && edit.CreatedAt >= item.CreatedAt {@@ -337,9 +339,18 @@ func roomRoot(value any) roomItem {
337339 }338340 item.Replies = len(roomSlice(value, "replies"))339341 item.InThread = true342+ actor := firstString(actors)343+ item.Own = actor != "" && item.PubKey == actor && item.Notice == ""340344 return item341345 }342346 347+func firstString(values []string) string {348+ if len(values) == 0 {349+ return ""350+ }351+ return values[0]352+}353+343354 func newRoomItem(row any, room string, members map[string]map[string]any) (roomItem, bool) {344355 e := valueMap(row)345356 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"])}@@ -363,6 +374,9 @@ func newRoomItem(row any, room string, members map[string]map[string]any) (roomI
363374 item.Root = roots[0]364375 }365376 }377+ if len(item.PubKey) >= 2 {378+ item.Avatar = strings.ToUpper(item.PubKey[:2])379+ }366380 if member := members[item.PubKey]; member != nil {367381 item.Role = plainString(member["role"])368382 item.Agent, _ = member["agent"].(bool)@@ -24,12 +24,12 @@ establishes the initial state. */}}
2424 {{template "alert" .Error}}2525 {{if not .Actor}}<p><a href="/signin">Sign in</a> to read this room.</p>{{end}}2626 </section>27-{{else}}{{$data := asMap .Event}}{{$room := asMap (index $data "room")}}{{$id := str (index $room "id")}}<section id="room">27+{{else}}{{$data := asMap .Event}}{{$room := asMap (index $data "room")}}{{$id := str (index $room "id")}}<section id="room" data-viewer="{{.Actor}}">2828 <h1>{{or (str (index $room "name")) $id}} <small>{{if eq (str (index $room "access")) "members"}}members only{{else}}open room{{end}}</small></h1>2929 {{with str (index $room "about")}}<p>{{.}}</p>{{end}}3030 {{with index $data "next_cursor"}}<p id="pages"><a href="/rooms/{{$id}}?cursor={{urlquery (str .)}}">load earlier</a></p>{{end}}3131 {{if .Query.Get "cursor"}}<p id="newest"><a href="/rooms/{{$id}}">newest messages</a></p>{{end}}32-<div id="messages">{{range roomItems .Event "messages"}}32+<div id="messages">{{range roomItems .Event "messages" .Actor}}3333 {{template "roomMessage" .}}{{else}}<p id="empty">No messages yet.</p>{{end}}3434 </div>3535 {{if not (.Query.Get "cursor")}}<room-live room="{{$id}}"></room-live>@@ -43,11 +43,11 @@ establishes the initial state. */}}
4343 <h1>Thread</h1>4444 {{template "alert" .Error}}4545 </section>46-{{else}}{{$data := asMap .Event}}{{$room := asMap (index $data "room")}}{{$id := str (index $room "id")}}{{$root := roomRoot .Event}}<section id="thread">46+{{else}}{{$data := asMap .Event}}{{$room := asMap (index $data "room")}}{{$id := str (index $room "id")}}{{$root := roomRoot .Event .Actor}}<section id="thread" data-viewer="{{.Actor}}">4747 <p id="crumbs"><a href="/rooms/{{$id}}">{{or (str (index $room "name")) $id}}</a></p>4848 <h1>Thread <small>{{$root.Replies}} {{if eq $root.Replies 1}}reply{{else}}replies{{end}}</small></h1>4949 <div id="root">{{template "roomMessage" $root}}</div>50-<div id="messages">{{range roomItems .Event "replies"}}50+<div id="messages">{{range roomItems .Event "replies" .Actor}}5151 {{template "roomMessage" .}}{{else}}<p id="empty">No replies yet.</p>{{end}}5252 </div>5353 {{with index $data "next_cursor"}}<p id="pages"><a href="/rooms/{{$id}}/thread/{{$root.ID}}?cursor={{urlquery (str .)}}">load earlier</a></p>{{end}}@@ -62,8 +62,8 @@ establishes the initial state. */}}
6262 {{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}}6363 6464 {{/* One message. room-live builds the same markup for messages that arrive on the stream. */}}65-{{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}}>66-<header><b>{{template "nostrName" .PubKey}}</b>{{if .Role}} | {{.Role}}{{end}}<small><time datetime="{{datetime .CreatedAt}}" title="{{when .CreatedAt}}">{{clock .CreatedAt}}</time></small></header>65+{{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>66+<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>6767 {{if .Notice}}<p>{{.Notice}}</p>{{else}}<div>{{chatMarkdown (roomAttachmentContent .Content .Attachments)}}</div>{{roomAttachmentMarkup .Attachments}}{{end}}6868 {{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}}6969 </room-message>{{end}}@@ -88,6 +88,86 @@
8888 node.append(text.slice(last));8989 return node;9090 };91+ const pipeEscaped = (value, index) => {92+ let slashes = 0;93+ for (let i = index - 1; i >= 0 && value[i] === "\\"; i--) slashes++;94+ return slashes % 2 === 1;95+ };96+ const pipeRow = line => {97+ let value = String(line).trim();98+ if (!value.includes("|")) return null;99+ const leadingPipe = value.startsWith("|");100+ const trailingPipe = value.endsWith("|") && !pipeEscaped(value, value.length - 1);101+ if (value.startsWith("|")) value = value.slice(1);102+ const last = value.length - 1;103+ if (value.endsWith("|") && !pipeEscaped(value, last)) value = value.slice(0, -1);104+ const cells = [];105+ let hasPipe = false;106+ let cell = "", code = false;107+ for (let i = 0; i < value.length; i++) {108+ const character = value[i];109+ if (character === "`") { code = !code; cell += character; continue; }110+ if (character === "|" && pipeEscaped(value, i)) { cell = cell.slice(0, -1) + "|"; continue; }111+ if (character === "|" && !code) {112+ hasPipe = true; cells.push(cell.trim()); cell = ""; continue;113+ }114+ cell += character;115+ }116+ cells.push(cell.trim());117+ return (hasPipe || leadingPipe || trailingPipe) ? cells : null;118+ };119+ const tableDelimiter = cell => {120+ const value = String(cell).trim();121+ if (!/^:?-{3,}:?$/.test(value)) return null;122+ return value.startsWith(":") ? (value.endsWith(":") ? "center" : "left") : (value.endsWith(":") ? "right" : "");123+ };124+ const tableBodyLine = line => {125+ const value = String(line).trim();126+ return Boolean(value) && !value.startsWith("#") && !value.startsWith("```") && !value.startsWith("~~~");127+ };128+ const markdownTable = (lines, start) => {129+ const header = pipeRow(lines[start]), separator = pipeRow(lines[start + 1]);130+ if (!header || !separator || header.length !== separator.length) return null;131+ const align = separator.map(tableDelimiter);132+ if (align.some(value => value === null)) return null;133+ const rows = [];134+ let index = start + 2;135+ for (; index < lines.length; index++) {136+ if (!tableBodyLine(lines[index])) break;137+ const row = pipeRow(lines[index]);138+ if (!row) break;139+ rows.push(row);140+ }141+ const wrapper = el("div");142+ wrapper.dataset.markdownTable = "";143+ wrapper.setAttribute("data-markdown-table", "");144+ wrapper.setAttribute("role", "region");145+ wrapper.setAttribute("aria-label", "Table");146+ wrapper.setAttribute("tabindex", "0");147+ const table = el("table"), thead = el("thead"), heading = el("tr");148+ header.forEach((cell, column) => {149+ const th = el("th");150+ th.setAttribute("scope", "col");151+ if (align[column]) { th.dataset.align = align[column]; th.setAttribute("data-align", align[column]); }152+ chatInline(th, cell);153+ heading.append(th);154+ });155+ thead.append(heading);156+ const tbody = el("tbody");157+ rows.forEach(row => {158+ const tr = el("tr");159+ for (let column = 0; column < header.length; column++) {160+ const td = el("td");161+ if (align[column]) { td.dataset.align = align[column]; td.setAttribute("data-align", align[column]); }162+ chatInline(td, row[column] || "");163+ tr.append(td);164+ }165+ tbody.append(tr);166+ });167+ table.append(thead, tbody);168+ wrapper.append(table);169+ return {node: wrapper, end: index};170+ };91171 const listItem = line => /^\s*(?:[-*+]|\d+[.)])\s+/.test(line);92172 const chatMarkdown = (node, text) => {93173 const lines = String(text).replace(/\r\n/g, "\n").split("\n");@@ -101,11 +181,18 @@
101181 };102182 for (let i = 0; i < lines.length; i++) {103183 const line = lines[i];104- if (line.startsWith("```")) {184+ const fence = line.trim().match(/^(`{3,}|~{3,})/);185+ const table = !fence && markdownTable(lines, i);186+ if (fence) {105187 flush();106- const code = [];107- for (i++; i < lines.length && !lines[i].startsWith("```"); i++) code.push(lines[i]);188+ const code = [], marker = fence[1];189+ const closesFence = value => value.length >= marker.length && [...value].every(character => character === marker[0]);190+ for (i++; i < lines.length && !closesFence(lines[i].trim()); i++) code.push(lines[i]);108191 const pre = el("pre"); pre.append(el("code", code.join("\n"))); node.append(pre);192+ } else if (table) {193+ flush();194+ node.append(table.node);195+ i = table.end - 1;109196 } else if (line.startsWith("#")) {110197 flush();111198 const level = Math.min(6, line.length - line.replace(/^#+/, "").length);@@ -245,23 +332,31 @@
245332 const node = el("room-message");246333 const notice = event.kind === 44100 ? "joined the room" : event.kind === 44101 ? "left the room" : "";247334 const pubkey = notice ? tagValue(event, "p") : event.pubkey;335+ const viewer = (document.getElementById("room") || document.getElementById("thread"))?.dataset.viewer;248336 node.id = "msg-" + event.id;249337 node.dataset.id = event.id;250338 node.dataset.kind = String(event.kind);251339 node.dataset.updatedAt = String(event.created_at);252340 node.dataset.pubkey = pubkey;253341 const member = members[pubkey];342+ const own = !notice && Boolean(viewer) && pubkey === viewer;343+ if (own) node.dataset.own = "";254344 if (member?.agent) node.dataset.agent = "";255345 if (notice) node.dataset.notice = "";346+ const avatar = el("nostr-avatar", pubkey.slice(0, 2).toUpperCase());347+ avatar.setAttribute("pubkey", pubkey);348+ avatar.setAttribute("aria-hidden", "true");256349 const header = el("header"), name = el("b"), time = el("time", clock(event.created_at)), small = el("small");257350 name.append(nameNode(pubkey));258351 time.dateTime = new Date(event.created_at * 1000).toISOString().replace(/\.\d+Z$/, "Z");259352 time.title = time.dateTime.slice(0, 16).replace("T", " ") + " UTC";260353 small.append(time);261- header.append(name, member?.role ? " | " + member.role : "", small);354+ header.append(name);355+ if (own) { const label = el("span", "you"); label.dataset.ownLabel = ""; header.append(label); }356+ header.append(member?.role ? " | " + member.role : "", small);262357 const attachments = notice ? [] : roomAttachments(event);263358 const body = notice ? el("p", notice) : chatMarkdown(el("div"), attachmentContent(event.content || "", attachments));264- node.append(header, body);359+ node.append(avatar, header, body);265360 const media = attachmentNodes(attachments); if (media) node.append(media);266361 const footer = el("footer");267362 const mentions = notice ? [] : (event.tags || []).filter(tag => tag[0] === "p" && isHex64(tag[1]) && tag[1] !== pubkey).map(tag => tag[1]);@@ -143,6 +143,10 @@ func TestRoomPageRendersMessagesOldestFirstWithMarkers(t *testing.T) {
143143 `<h1>General <small>open room</small></h1>`, `<p>Where General happens.</p>`,144144 `<a href="/rooms/general?cursor=cursor-1">load earlier</a>`,145145 `<room-message id="msg-` + roomHello + `" data-id="` + roomHello + `" data-kind="9" data-updated-at="1757203650" data-pubkey="` + roomAgent + `" data-agent data-edited>`,146+ `<nostr-avatar pubkey="` + roomAgent + `" aria-hidden="true">BB</nostr-avatar>`,147+ `<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>`,148+ `</b><span data-own-label>you</span>`,149+ `<section id="room" data-viewer="` + roomOwner + `">`,146150 `<a href="https://example.com/docs" rel="noopener">https://example.com/docs</a>.`,147151 `<a href="/open?target=nostr%3Anpub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc2">nostr:npub1`,148152 `<script>alert(1)</script>`,@@ -202,6 +206,7 @@ func TestThreadPageRendersRootRepliesAndReplyCompose(t *testing.T) {
202206 for _, want := range []string{203207 `<p id="crumbs"><a href="/rooms/general">General</a></p>`, `<h1>Thread <small>1 reply</small></h1>`,204208 `<div id="root"><room-message id="msg-` + roomThread + `"`, `<div id="messages">`, `id="msg-` + roomReply + `"`,209+ `data-viewer="` + roomOwner + `"`, `<span data-own-label>you</span>`,205210 `<room-live room="general" root="` + roomThread + `"></room-live>`,206211 `<room-compose room="general" pubkey="` + roomOwner + `" kind="12" root="` + roomThread + `" root-pubkey="` + roomAgent + `">`,207212 `» <page-link url="http://relay.example/rooms/general/thread/` + roomThread + `" title="Copy the page address">rooms/general/thread/` + shortID(roomThread) + `</page-link></span>`,@@ -111,6 +111,14 @@ dl{margin:.5rem 0}dt{font-weight:600}dd{margin:0 0 .35rem 1rem}
111111 #readme{max-width:720px}#readme h1{font-size:24px;border-bottom:1px solid var(--line);padding-bottom:8px}#readme h2{font-size:17px}112112 #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}113113 #readme code{font-size:12.5px}#readme ul,#readme ol{padding-left:20px}114+[data-markdown-table]{max-width:100%;margin:10px 0;overflow-x:auto;overscroll-behavior-x:contain;border:1px solid var(--line);border-radius:8px}115+[data-markdown-table]:focus-visible{outline:2px solid var(--accent);outline-offset:2px}116+[data-markdown-table]>table{width:100%;border-collapse:collapse;font:inherit;text-align:left}117+[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}118+[data-markdown-table] th{background:var(--rail);font-weight:600;color:var(--ink)}119+[data-markdown-table] th:last-child,[data-markdown-table] td:last-child{border-right:0}120+[data-markdown-table] tbody tr:last-child td{border-bottom:0}121+[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}114122 115123 /* rpc-form, signed-form, publish-list: one signed call each. */116124 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}@@ -279,11 +287,17 @@ wiki-compose output{display:block;font:12px var(--mono);color:var(--muted);margi
279287 /* Rooms read as a centered conversation column; the compose bar spans the280288 content column but its controls sit in the same 720px lane. */281289 #room>*:not(room-compose),#thread>*:not(room-compose),room-compose>*{max-width:720px;margin-left:auto;margin-right:auto}282-room-message{display:block;padding:8px 0;border-bottom:1px solid var(--line)}283-room-message>header{font:12px var(--mono);color:var(--muted);margin-bottom:2px;user-select:none;-webkit-user-select:none}290+room-message{display:block;position:relative;min-width:0;margin:10px 0;padding:12px 0 14px 44px;border-bottom:1px solid var(--line)}291+room-message>nostr-avatar{position:absolute;top:12px;left:0}292+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}293+nostr-avatar img{display:block;width:100%;height:100%;object-fit:cover}294+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}284295 room-message>header b{color:var(--ink);font-weight:500}room-message>header b nostr-key{cursor:copy}285-room-message>header small{float:right}286-room-message>p,room-message>div{margin:0;font:14px/1.45 var(--sans);overflow-wrap:anywhere}room-message>p{white-space:pre-wrap}296+room-message>header small{float:right;margin-left:8px;white-space:nowrap}297+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}298+room-message[data-own]>nostr-avatar{left:12px}299+room-message [data-own-label]{margin-left:6px;color:var(--accent);font-size:11px}300+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}287301 room-message>p a,room-message>div a{color:var(--accent)}288302 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}289303 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}@@ -351,6 +365,7 @@ html,body{overflow:auto}
351365 #tree{border-right:0;border-bottom:1px solid var(--line)}352366 #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}353367 #grid,#gridhead,h2:has(+#gridhead){display:none}368+room-message[data-own]{margin-left:12px;padding-right:10px}354369 /* Phones: each wiki row reads as a title line and one muted line of facts. */355370 #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:" | "}356371 room-compose{bottom:calc(28px + env(safe-area-inset-bottom,0px));margin:8px -2.5ch 0;padding:8px 12px;z-index:1}@@ -0,0 +1,35 @@
1+import test from "node:test";2+import assert from "node:assert/strict";3+import {setup, FakeNode} from "./rooms.test.mjs";4+5+test("live room and thread messages identify the viewer and render avatars", () => {6+ for (const page of ["room", "thread"]) {7+ const s = setup(), section = new FakeNode("section");8+ section.id = page;9+ section.dataset.viewer = s.pubkey;10+ s.sandbox.document.body.append(section);11+ const event = {id: "e".repeat(64), pubkey: s.pubkey, kind: 9, created_at: 1789041600, tags: [], content: "My message"};12+ const own = s.rooms.roomAppend(event, {room: "build", inThread: page === "thread"});13+ assert.equal(own.hasAttribute("data-own"), true);14+ assert.equal(own.querySelector("header [data-own-label]").textContent, "you");15+ const avatar = own.querySelector(":scope > nostr-avatar");16+ assert.equal(avatar.getAttribute("pubkey"), s.pubkey);17+ assert.equal(avatar.getAttribute("aria-hidden"), "true");18+ assert.equal(avatar.textContent, s.pubkey.slice(0, 2).toUpperCase());19+ const other = s.rooms.messageNode({...event, pubkey: "b".repeat(64)});20+ assert.equal(other.hasAttribute("data-own"), false);21+ assert.equal(other.querySelector("[data-own-label]"), null);22+ const notice = s.rooms.messageNode({...event, kind: 44100, tags: [["p", s.pubkey]]});23+ assert.equal(notice.hasAttribute("data-own"), false);24+ s.rooms.roomEdit({...event, kind: 40003, tags: [["e", event.id]], content: "Edited message"});25+ assert.equal(own.hasAttribute("data-own"), true);26+ assert.equal(own.querySelector("nostr-avatar"), avatar);27+ }28+});29+30+test("guest messages do not claim a viewer identity", () => {31+ const s = setup();32+ const message = s.rooms.messageNode({id: "e".repeat(64), pubkey: s.pubkey, kind: 9, created_at: 1789041600, tags: [], content: "Hello"});33+ assert.equal(message.hasAttribute("data-own"), false);34+ assert.equal(message.querySelector("[data-own-label]"), null);35+});@@ -0,0 +1,70 @@
1+import test from "node:test";2+import assert from "node:assert/strict";3+import { FakeNode, setup } from "./rooms.test.mjs";4+5+test("chat tables render semantic headers, alignment and uneven rows", () => {6+ const s = setup();7+ const body = s.rooms.chatMarkdown(new FakeNode("div"), "| Name | Notes | Score |\n| :--- | :---: | ---: |\n| Ada | **ready** | 10 |\n| Grace | `a|b` |\n| Alan | left");8+ const region = body.children[0], table = region.querySelector("table");9+ assert.equal(region.getAttribute("data-markdown-table"), "");10+ assert.equal(region.getAttribute("role"), "region");11+ assert.equal(region.getAttribute("aria-label"), "Table");12+ assert.equal(region.getAttribute("tabindex"), "0");13+ assert.deepEqual(table.querySelectorAll("th").map(cell => [cell.getAttribute("scope"), cell.getAttribute("data-align")]), [["col", "left"], ["col", "center"], ["col", "right"]]);14+ assert.equal(table.querySelectorAll("tbody tr").length, 3);15+ assert.equal(table.querySelectorAll("tbody tr")[1].children.length, 3);16+ assert.equal(table.querySelectorAll("tbody tr")[1].children[1].querySelector("code").textContent, "a|b");17+ assert.equal(table.querySelectorAll("tbody tr")[2].children[2].textContent, "");18+ assert.equal(table.querySelector("strong").textContent, "ready");19+});20+21+test("chat tables accept escaped pipes and keep inline content safe", () => {22+ const s = setup();23+ const body = s.rooms.chatMarkdown(new FakeNode("div"), "Label | Link\n--- | ---\nA \\| B | [go](javascript:alert(1)) <img>");24+ const table = body.querySelector("table");25+ assert.equal(table.querySelector("tbody td").textContent, "A | B");26+ assert.equal(table.querySelectorAll("a").length, 0);27+ assert.equal(table.textContent.includes("<img>"), true);28+});29+30+test("chat tables preserve single columns and distinguish escaped trailing pipes", () => {31+ const s = setup();32+ const body = s.rooms.chatMarkdown(new FakeNode("div"), "| Value |\n| --- |\n| `a\\|b` |");33+ assert.equal(body.querySelectorAll("table").length, 1);34+ assert.equal(body.querySelector("th").textContent, "Value");35+ assert.equal(body.querySelector("tbody code").textContent, "a|b");36+ const prose = s.rooms.chatMarkdown(new FakeNode("div"), "A \\\\| B\n| --- |");37+ assert.equal(prose.querySelectorAll("table").length, 0);38+});39+40+test("malformed tables and tables in fenced code remain prose or code", () => {41+ const s = setup();42+ const body = s.rooms.chatMarkdown(new FakeNode("div"), "| Header | Other\n| -- | ---\n\n```\n| A | B |\n| --- | --- |\n```");43+ assert.equal(body.querySelectorAll("[data-markdown-table]").length, 0);44+ assert.equal(body.querySelectorAll("pre").length, 1);45+ assert.equal(body.querySelector("p").textContent, "| Header | Other| -- | ---");46+ assert.equal(body.querySelector("pre").textContent.includes("| A | B |"), true);47+});48+49+test("rerendering edited chat content creates a fresh table node", () => {50+ const s = setup();51+ const body = new FakeNode("div");52+ s.rooms.chatMarkdown(body, "| Before | State |\n| --- | --- |\n| old | draft |");53+ assert.equal(body.querySelector("tbody td").textContent, "old");54+ body.replaceChildren();55+ s.rooms.chatMarkdown(body, "| After | State |\n| --- | --- |\n| new | sent |");56+ assert.equal(body.querySelector("th").textContent, "After");57+ assert.equal(body.querySelector("tbody td").textContent, "new");58+});59+60+test("tables stop before headings and preserve backtick and tilde fences", () => {61+ const s = setup();62+ for (const marker of ["```", " ~~~~"]) {63+ 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");64+ assert.equal(body.querySelectorAll("table").length, 1);65+ assert.equal(body.querySelector("th").getAttribute("data-align"), null);66+ assert.equal(body.querySelectorAll("tbody tr").length, 1);67+ assert.equal(body.querySelector("pre").textContent, "| A | B |\n|---|---|");68+ assert.equal(body.children.find(node => node.localName === "h1").textContent, "Heading | text");69+ }70+});@@ -0,0 +1,80 @@
1+import test from "node:test";2+import assert from "node:assert/strict";3+import fs from "node:fs";4+import vm from "node:vm";5+6+const source = fs.readFileSync("internal/webui/components.js", "utf8");7+const avatarSource = source.slice(source.indexOf(" class NostrAvatar "), source.indexOf(" // PageLink"));8+const key = "a".repeat(64);9+10+function setup(userPromise) {11+ const images = [];12+ const document = {13+ createTextNode: text => ({textContent: text}),14+ createElement: name => {15+ const image = {nodeName: name, addEventListener: (event, callback) => { image.handlers ??= {}; image.handlers[event] = callback; }, setAttribute() {}};16+ images.push(image);17+ return image;18+ }19+ };20+ const window = {tinyNames: {load: () => typeof userPromise === "function" ? userPromise() : userPromise}};21+ const HTMLElement = class {22+ constructor() { this.attributes = {}; this.children = []; }23+ getAttribute(name) { return this.attributes[name] ?? null; }24+ replaceChildren(...children) { this.children = children; }25+ querySelector(name) { return this.children.find(child => child.nodeName === name) || null; }26+ };27+ const el = (name, text) => { const node = document.createElement(name); if (text !== undefined) node.textContent = text; return node; };28+ const Constructor = vm.runInNewContext(`${avatarSource}\nNostrAvatar`, {HTMLElement, window, document, URL, Promise, String, setTimeout, el});29+ const component = new Constructor();30+ component.attributes.pubkey = key;31+ return {component, images};32+}33+34+test("loads a safe profile image with privacy attributes", async () => {35+ const {component, images} = setup(Promise.resolve({image: "https://cdn.example/avatar.png"}));36+ component.connectedCallback();37+ await new Promise(resolve => setTimeout(resolve, 0));38+ const image = component.children[0];39+ assert.equal(image.src, "https://cdn.example/avatar.png");40+ assert.equal(image.loading, "lazy");41+ assert.equal(image.referrerPolicy, "no-referrer");42+ assert.equal(image.alt, "");43+});44+45+test("keeps the two character fallback for missing or unsafe pictures", async () => {46+ for (const image of [undefined, "javascript:alert(1)", "/avatar.png"]) {47+ const {component} = setup(Promise.resolve(image === undefined ? {} : {image}));48+ component.connectedCallback();49+ await new Promise(resolve => setTimeout(resolve, 0));50+ assert.equal(component.children[0].textContent, "AA");51+ }52+});53+54+test("restores the fallback after an image load failure", async () => {55+ const {component, images} = setup(Promise.resolve({image: "https://cdn.example/avatar.png"}));56+ component.connectedCallback();57+ await new Promise(resolve => setTimeout(resolve, 0));58+ images[0].handlers.error();59+ assert.equal(component.children[0].textContent, "AA");60+});61+62+test("ignores a stale profile result after the pubkey changes", async () => {63+ let resolve, calls = 0;64+ const {component} = setup(() => calls++ === 0 ? new Promise(done => { resolve = done; }) : Promise.resolve({}));65+ component.connectedCallback();66+ component.attributes.pubkey = "b".repeat(64);67+ component.attributeChangedCallback();68+ resolve({image: "https://cdn.example/avatar.png"});69+ await new Promise(resolve => setTimeout(resolve, 0));70+ assert.equal(component.children[0].textContent, "BB");71+});72+73+test("replaces a prior image when the pubkey becomes invalid", async () => {74+ const {component} = setup(Promise.resolve({image: "https://cdn.example/avatar.png"}));75+ component.connectedCallback();76+ await new Promise(resolve => setTimeout(resolve, 0));77+ component.attributes.pubkey = "bad";78+ component.attributeChangedCallback();79+ assert.equal(component.children[0].textContent, "BA");80+});