tinyrelaycommit

Commit bcdaf4eb7f76

browse files at this commit | history

1diff --git a/AGENTS.md b/AGENTS.md
2index 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.
10diff --git a/internal/webui/components.js b/internal/webui/components.js
11index 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
72diff --git a/internal/webui/markdown.go b/internal/webui/markdown.go
73index 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+`)
225diff --git a/internal/webui/markdown_test.go b/internal/webui/markdown_test.go
226index 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+}
280diff --git a/internal/webui/page.html b/internal/webui/page.html
281index 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>
296diff --git a/internal/webui/rooms.go b/internal/webui/rooms.go
297index 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)
374diff --git a/internal/webui/rooms.html b/internal/webui/rooms.html
375index 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}}
418diff --git a/internal/webui/rooms.js b/internal/webui/rooms.js
419index 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]);
565diff --git a/internal/webui/rooms_test.go b/internal/webui/rooms_test.go
566index 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 `&lt;script&gt;alert(1)&lt;/script&gt;`,
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 `&raquo; <page-link url="http://relay.example/rooms/general/thread/` + roomThread + `" title="Copy the page address">rooms/general/thread/` + shortID(roomThread) + `</page-link></span>`,
588diff --git a/internal/webui/style.css b/internal/webui/style.css
589index 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}
637diff --git a/scripts/chat-identity.test.mjs b/scripts/chat-identity.test.mjs
638new file mode 100644
639index 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+});
678diff --git a/scripts/chat-tables.test.mjs b/scripts/chat-tables.test.mjs
679new file mode 100644
680index 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+});
754diff --git a/scripts/nostr-avatar.test.mjs b/scripts/nostr-avatar.test.mjs
755new file mode 100644
756index 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+});

About

ownerc01503e8035f
visibilitypublic
headbcdaf4eb7f76

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs