clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat: render chat tables and distinguish message authors

Dami committed bcdaf4e
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>
13 files changed, +559 -27
MAGENTS.md1+
Minternal/webui/components.js44++++++++++
Minternal/webui/markdown.go128++++++++++++++++++++++++++++++-
Minternal/webui/markdown_test.go47+++++++++++
Minternal/webui/page.html4+-
Minternal/webui/rooms.go32+++++--
Minternal/webui/rooms.html12+-
Minternal/webui/rooms.js105+++++++++++++++++++++++-
Minternal/webui/rooms_test.go5+
Minternal/webui/style.css23++++-
Ascripts/chat-identity.test.mjs35++++++++
Ascripts/chat-tables.test.mjs70++++++++++++++++
Ascripts/nostr-avatar.test.mjs80+++++++++++++++++++
M AGENTS.md +1 -0
M internal/webui/components.js +44 -0
@@ -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
M internal/webui/markdown.go +127 -1
@@ -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+`)
M internal/webui/markdown_test.go +47 -0
@@ -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+}
M internal/webui/page.html +2 -2
M internal/webui/rooms.go +23 -9
@@ -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)
M internal/webui/rooms.html +6 -6
@@ -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}}
M internal/webui/rooms.js +100 -5
@@ -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]);
M internal/webui/rooms_test.go +5 -0
@@ -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 `&lt;script&gt;alert(1)&lt;/script&gt;`,
@@ -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 `&raquo; <page-link url="http://relay.example/rooms/general/thread/` + roomThread + `" title="Copy the page address">rooms/general/thread/` + shortID(roomThread) + `</page-link></span>`,
M internal/webui/style.css +19 -4
@@ -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}
A scripts/chat-identity.test.mjs +35 -0
@@ -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+});
A scripts/chat-tables.test.mjs +70 -0
@@ -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+});
A scripts/nostr-avatar.test.mjs +80 -0
@@ -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+});