tinyrelaycommit

Commit 126290ef3420

browse files at this commit | history

1diff --git a/docs/rooms.md b/docs/rooms.md
2index 8a5cce6..1095a68 100644
3--- a/docs/rooms.md
4+++ b/docs/rooms.md
5@@ -59,7 +59,7 @@ Room events reach device notifications and relay push callbacks the same way as
6
7 **Rooms** in the relay navigation lists the rooms you can see with their access rule, member count and last message. Signed-in members create a room at the bottom of the list: the name becomes the room id, lowercased with punctuation replaced by hyphens, unless the **Id** field names one, and the room opens once the relay accepts it. Give the id yourself when a client expects a particular shape, such as a Buzz gateway that wants a UUID.
8
9-Open a room to read its messages, oldest first. Each message shows the author's key, their room role and the time; messages from agents carry an agent marker. Links open in place and `nostr:` links resolve through the relay. A thread shows how many replies it has and opens on its own page, where replies read in order. Reactions appear under the message they answer, and an edited message shows its newest text with an edited marker. Choose **load earlier** for older messages.
10+Open a room to read its messages, oldest first. Each message shows the author's name, their room role and the time; messages from agents carry an agent marker. Message text renders the common Markdown subset that people and agents type: paragraphs with line breaks, bold and italic, inline code and fenced code, lists, headings and links, and bare `https://` or `nostr:` references become links. Nothing else in a message is treated as markup. Links open in place and `nostr:` links resolve through the relay. A thread shows how many replies it has and opens on its own page, where replies read in order. Reactions appear under the message they answer, and an edited message shows its newest text with an edited marker. Choose **load earlier** for older messages.
11
12 The compose bar sits at the bottom of the column. Enter sends and Shift+Enter starts a new line. Mention a person with `@npub...` or `@<hex key>`; the relay notifies them. Sending, creating a room and every room action need JavaScript and a connected signer; without JavaScript the page still shows the newest messages.
13
14diff --git a/internal/webui/components.js b/internal/webui/components.js
15index 55c806c..49d7d74 100644
16--- a/internal/webui/components.js
17+++ b/internal/webui/components.js
18@@ -616,6 +616,74 @@
19 node.append(text.slice(last));
20 return node;
21 };
22+ // chatMarkdown renders the same Markdown subset the page renders for chat
23+ // messages: paragraphs with line breaks, fenced code, headings, lists,
24+ // inline code, emphasis, links, and bare http(s) or nostr: references. It
25+ // builds nodes rather than markup, so message text never becomes HTML.
26+ const inlinePattern = /(`[^`]+`)|\[([^\]]+)\]\(([^)\s]+)\)|\*\*([^*]+)\*\*|(^|[^*\w])\*([^*]+)\*|(https?:\/\/[^\s<>"']+|(?:web\+)?nostr:[a-z0-9]+)/g;
27+ const safeLink = target => /^(https?:\/\/|\/(?!\/)|\.\.?\/|#|mailto:)/.test(target) || (!target.includes(":") && !target.startsWith("//"));
28+ const chatInline = (node, text) => {
29+ let last = 0;
30+ for (const match of String(text).matchAll(inlinePattern)) {
31+ node.append(text.slice(last, match.index));
32+ last = match.index + match[0].length;
33+ if (match[1]) { node.append(el("code", match[1].slice(1, -1))); continue; }
34+ if (match[2] !== undefined) {
35+ if (!safeLink(match[3])) { node.append(match[0]); continue; }
36+ const link = el("a", match[2]);
37+ link.href = match[3];
38+ if (match[3].startsWith("http")) link.rel = "noopener";
39+ node.append(link);
40+ continue;
41+ }
42+ if (match[4] !== undefined) { node.append(chatInline(el("strong"), match[4])); continue; }
43+ if (match[6] !== undefined) { node.append(match[5], chatInline(el("em"), match[6])); continue; }
44+ const trimmed = match[7].replace(/[.,;:!?)]+$/, "");
45+ const link = el("a", trimmed);
46+ link.href = trimmed.startsWith("http") ? trimmed : tiny.localPath("/open?target=" + encodeURIComponent(trimmed));
47+ if (trimmed.startsWith("http")) link.rel = "noopener";
48+ node.append(link, match[7].slice(trimmed.length));
49+ }
50+ node.append(text.slice(last));
51+ return node;
52+ };
53+ const listItem = line => /^\s*(?:[-*+]|\d+[.)])\s+/.test(line);
54+ const chatMarkdown = (node, text) => {
55+ const lines = String(text).replace(/\r\n/g, "\n").split("\n");
56+ let paragraph = [];
57+ const flush = () => {
58+ if (!paragraph.length) return;
59+ const p = el("p");
60+ paragraph.forEach((line, index) => { if (index) p.append(el("br")); chatInline(p, line); });
61+ node.append(p);
62+ paragraph = [];
63+ };
64+ for (let i = 0; i < lines.length; i++) {
65+ const line = lines[i];
66+ if (line.startsWith("```")) {
67+ flush();
68+ const code = [];
69+ for (i++; i < lines.length && !lines[i].startsWith("```"); i++) code.push(lines[i]);
70+ const pre = el("pre"); pre.append(el("code", code.join("\n"))); node.append(pre);
71+ } else if (line.startsWith("#")) {
72+ flush();
73+ const level = Math.min(6, line.length - line.replace(/^#+/, "").length);
74+ node.append(chatInline(el("h" + level), line.slice(level).trim()));
75+ } else if (listItem(line)) {
76+ flush();
77+ const list = el(/^\s*\d/.test(line) ? "ol" : "ul");
78+ for (; i < lines.length && listItem(lines[i]); i++) list.append(chatInline(el("li"), lines[i].replace(/^\s*(?:[-*+]|\d+[.)])\s+/, "")));
79+ i--;
80+ node.append(list);
81+ } else if (!line.trim()) {
82+ flush();
83+ } else {
84+ paragraph.push(line.trim());
85+ }
86+ }
87+ flush();
88+ return node;
89+ };
90 const keyNode = hex => { const node = el("nostr-key", hex.slice(0, 12)); node.setAttribute("hex", hex); node.title = hex; return node; };
91 // nameNode shows a person: the vendored nostr-name element replaces the short
92 // id with the profile name published on this relay.
93@@ -646,7 +714,7 @@
94 time.title = time.dateTime.slice(0, 16).replace("T", " ") + " UTC";
95 small.append(time);
96 header.append(name, member?.role ? " | " + member.role : "", small);
97- const body = notice ? el("p", notice) : linkify(el("p"), event.content || "");
98+ const body = notice ? el("p", notice) : chatMarkdown(el("div"), event.content || "");
99 node.append(header, body);
100 const footer = el("footer");
101 const mentions = notice ? [] : (event.tags || []).filter(tag => tag[0] === "p" && isHex64(tag[1]) && tag[1] !== pubkey).map(tag => tag[1]);
102@@ -705,9 +773,9 @@
103 const targets = (event.tags || []).filter(tag => tag[0] === "e");
104 const target = targets.length && document.getElementById("msg-" + targets[targets.length - 1][1]);
105 if (!target || target.dataset.pubkey !== event.pubkey || target.dataset.notice !== undefined) return;
106- const body = target.querySelector(":scope > p");
107+ const body = target.querySelector(":scope > div");
108 if (!body) return;
109- body.replaceWith(linkify(el("p"), event.content || ""));
110+ body.replaceWith(chatMarkdown(el("div"), event.content || ""));
111 if (target.dataset.edited !== undefined) return;
112 target.dataset.edited = "";
113 let footer = target.querySelector(":scope > footer");
114@@ -884,7 +952,7 @@
115 roomAppend(event, {room: this.getAttribute("room"), inThread: Boolean(root)});
116 }
117 }
118- tiny.rooms = Object.freeze({keyHex, roomMentions, roomID, messageNode, roomAppend, roomReact, roomEdit, linkify});
119+ tiny.rooms = Object.freeze({keyHex, roomMentions, roomID, messageNode, roomAppend, roomReact, roomEdit, linkify, chatMarkdown});
120
121 // JsonView renders any JSON value. Arrays of objects become tables, objects
122 // become definition lists, scalar arrays become lists, and deep nesting
123diff --git a/internal/webui/markdown.go b/internal/webui/markdown.go
124index c2f3046..0d928f9 100644
125--- a/internal/webui/markdown.go
126+++ b/internal/webui/markdown.go
127@@ -15,6 +15,17 @@ import (
128 // the tags this function emits reach the page, so untrusted README files and
129 // relay descriptions cannot inject markup. Anything else stays plain text.
130 func renderMarkdown(source string) template.HTML {
131+ return markdownHTML(source, false)
132+}
133+
134+// renderChatMarkdown is the same subset for chat messages, where a single
135+// newline is a line break and bare http(s) and nostr: references become
136+// links, as people and agents type them.
137+func renderChatMarkdown(source string) template.HTML {
138+ return template.HTML(autolinkOutsideTags(string(markdownHTML(source, true))))
139+}
140+
141+func markdownHTML(source string, breaks bool) template.HTML {
142 lines := strings.Split(strings.ReplaceAll(source, "\r\n", "\n"), "\n")
143 var out strings.Builder
144 var paragraph []string
145@@ -22,7 +33,15 @@ func renderMarkdown(source string) template.HTML {
146 if len(paragraph) == 0 {
147 return
148 }
149- out.WriteString("<p>" + inlineMarkdown(strings.Join(paragraph, " ")) + "</p>\n")
150+ joiner := " "
151+ if breaks {
152+ joiner = "<br>"
153+ }
154+ parts := make([]string, len(paragraph))
155+ for i, line := range paragraph {
156+ parts[i] = inlineMarkdown(line)
157+ }
158+ out.WriteString("<p>" + strings.Join(parts, joiner) + "</p>\n")
159 paragraph = paragraph[:0]
160 }
161 for i := 0; i < len(lines); i++ {
162@@ -147,3 +166,54 @@ func renderRepositoryMarkdown(source string, query url.Values) template.HTML {
163 return `<a href="` + html.EscapeString(destination.String()) + `">`
164 }))
165 }
166+
167+// tagOrText splits rendered HTML into tags and the text between them.
168+var tagOrText = regexp.MustCompile(`<[^>]+>|[^<]+`)
169+
170+// chatLinkPattern matches bare links in escaped text: http(s) URLs and
171+// nostr: references, as roomLinkPattern does before escaping.
172+var chatLinkPattern = regexp.MustCompile(`https?://[^\s<>"']+|(?:web\+)?nostr:[a-z0-9]+`)
173+
174+// autolinkOutsideTags links bare URLs and nostr: references in the text
175+// between tags, leaving anything already inside a link or code alone.
176+func autolinkOutsideTags(rendered string) string {
177+ var out strings.Builder
178+ skip := 0
179+ for _, part := range tagOrText.FindAllString(rendered, -1) {
180+ if strings.HasPrefix(part, "<") {
181+ lower := strings.ToLower(part)
182+ switch {
183+ case strings.HasPrefix(lower, "<a ") || strings.HasPrefix(lower, "<code") || strings.HasPrefix(lower, "<pre"):
184+ skip++
185+ case strings.HasPrefix(lower, "</a") || strings.HasPrefix(lower, "</code") || strings.HasPrefix(lower, "</pre"):
186+ if skip > 0 {
187+ skip--
188+ }
189+ }
190+ out.WriteString(part)
191+ continue
192+ }
193+ if skip > 0 {
194+ out.WriteString(part)
195+ continue
196+ }
197+ last := 0
198+ for _, match := range chatLinkPattern.FindAllStringIndex(part, -1) {
199+ out.WriteString(part[last:match[0]])
200+ link := part[match[0]:match[1]]
201+ trimmed := strings.TrimRight(link, ".,;:!?)")
202+ href := trimmed
203+ if !strings.HasPrefix(trimmed, "http") {
204+ href = "/open?target=" + template.URLQueryEscaper(html.UnescapeString(trimmed))
205+ }
206+ out.WriteString(`<a href="` + html.EscapeString(html.UnescapeString(href)) + `"`)
207+ if strings.HasPrefix(trimmed, "http") {
208+ out.WriteString(` rel="noopener"`)
209+ }
210+ out.WriteString(`>` + trimmed + `</a>` + link[len(trimmed):])
211+ last = match[1]
212+ }
213+ out.WriteString(part[last:])
214+ }
215+ return out.String()
216+}
217diff --git a/internal/webui/markdown_test.go b/internal/webui/markdown_test.go
218index 7268e81..8b57591 100644
219--- a/internal/webui/markdown_test.go
220+++ b/internal/webui/markdown_test.go
221@@ -37,3 +37,20 @@ func TestRepositoryMarkdownDecodesPathsAndPreservesFragments(t *testing.T) {
222 t.Fatalf("encoded README link lost its resource or fragment: %s", got)
223 }
224 }
225+
226+func TestChatMarkdownBreaksLinesAndAutolinksOutsideLinksAndCode(t *testing.T) {
227+ got := string(renderChatMarkdown("It works: **[the page](https://012.run/wiki/agents)**.\nsee https://example.com/x, and nostr:npub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc2\n\n- `code https://not.a.link`\n- [x](javascript:alert(1))\n\n```\nhttps://in.code\n```"))
228+ for _, want := range []string{
229+ `<strong><a href="https://012.run/wiki/agents">the page</a></strong>.<br>see <a href="https://example.com/x" rel="noopener">https://example.com/x</a>, and <a href="/open?target=nostr%3Anpub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc2">nostr:npub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc2</a></p>`,
230+ "<li><code>code https://not.a.link</code></li>",
231+ "<li>[x](javascript:alert(1))</li>",
232+ "<pre><code>https://in.code</code></pre>",
233+ } {
234+ if !strings.Contains(got, want) {
235+ t.Errorf("missing %q in %s", want, got)
236+ }
237+ }
238+ if strings.Count(got, "<a ") != 3 {
239+ t.Fatalf("expected three links, got %s", got)
240+ }
241+}
242diff --git a/internal/webui/rooms.html b/internal/webui/rooms.html
243index 494615a..e8bd029 100644
244--- a/internal/webui/rooms.html
245+++ b/internal/webui/rooms.html
246@@ -59,6 +59,6 @@
247 {{/* One message. room-live builds the same markup for messages that arrive on the stream. */}}
248 {{define "roomMessage"}}<room-message id="msg-{{.ID}}" data-id="{{.ID}}" data-kind="{{.Kind}}" data-pubkey="{{.PubKey}}"{{if .Agent}} data-agent{{end}}{{if .Notice}} data-notice{{end}}{{if .Edited}} data-edited{{end}}>
249 <header><b>{{template "nostrName" .PubKey}}</b>{{if .Role}} | {{.Role}}{{end}}<small><time datetime="{{datetime .CreatedAt}}" title="{{when .CreatedAt}}">{{clock .CreatedAt}}</time></small></header>
250-<p>{{if .Notice}}{{.Notice}}{{else}}{{roomContent .Content}}{{end}}</p>
251+{{if .Notice}}<p>{{.Notice}}</p>{{else}}<div>{{chatMarkdown .Content}}</div>{{end}}
252 {{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}}
253 </room-message>{{end}}
254diff --git a/internal/webui/rooms_test.go b/internal/webui/rooms_test.go
255index d0662f0..61430d7 100644
256--- a/internal/webui/rooms_test.go
257+++ b/internal/webui/rooms_test.go
258@@ -148,7 +148,7 @@ func TestRoomPageRendersMessagesOldestFirstWithMarkers(t *testing.T) {
259 `&lt;script&gt;alert(1)&lt;/script&gt;`,
260 `<span>to <nostr-name pubkey="` + roomAgent + `"`,
261 `<span data-reaction="&#43;1">&#43;1 1</span>`,
262- `<p>hello again, see <a href="https://example.com/docs" rel="noopener">`, `<span data-edited>edited</span>`,
263+ `<div><p>hello again, see <a href="https://example.com/docs" rel="noopener">`, `<span data-edited>edited</span>`,
264 `<a href="/rooms/general/thread/` + roomThread + `">thread | 1 reply</a>`,
265 `<a href="/rooms/general/thread/` + roomThread + `">in thread</a>`,
266 `<room-live room="general"></room-live>`,
267diff --git a/internal/webui/style.css b/internal/webui/style.css
268index 5e78673..c570e4b 100644
269--- a/internal/webui/style.css
270+++ b/internal/webui/style.css
271@@ -258,8 +258,10 @@ room-message{display:block;padding:8px 0;border-bottom:1px solid var(--line)}
272 room-message>header{font:12px var(--mono);color:var(--muted);margin-bottom:2px;user-select:none;-webkit-user-select:none}
273 room-message>header b{color:var(--ink);font-weight:500}room-message>header b nostr-key{cursor:copy}
274 room-message>header small{float:right}
275-room-message>p{margin:0;font:14px/1.45 var(--sans);white-space:pre-wrap;overflow-wrap:anywhere}
276-room-message>p a{color:var(--accent)}
277+room-message>p,room-message>div{margin:0;font:14px/1.45 var(--sans);overflow-wrap:anywhere}room-message>p{white-space:pre-wrap}
278+room-message>p a,room-message>div a{color:var(--accent)}
279+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}
280+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}
281 room-message>footer{font:12px var(--mono);color:var(--muted);margin-top:4px}
282 room-message>footer>*+*::before{content:" | "}
283 room-message[data-agent]>header b::after{content:" agent";color:var(--bark);font-size:11px}
284diff --git a/internal/webui/templates.go b/internal/webui/templates.go
285index 7ed457e..c984b56 100644
286--- a/internal/webui/templates.go
287+++ b/internal/webui/templates.go
288@@ -232,6 +232,7 @@ func parseTemplates() (*template.Template, error) {
289 "datetime": datetime,
290 "when": when,
291 "markdown": renderMarkdown,
292+ "chatMarkdown": renderChatMarkdown,
293 "hasPrefix": strings.HasPrefix,
294 "npub": identityNpub,
295 "wsURL": wsURL,
296diff --git a/scripts/rooms.test.mjs b/scripts/rooms.test.mjs
297index 472fdb4..8f2f5bc 100644
298--- a/scripts/rooms.test.mjs
299+++ b/scripts/rooms.test.mjs
300@@ -186,7 +186,7 @@ test("room-live appends streamed messages once, summarizes reactions and reconne
301 assert.equal(node.querySelector("header b nostr-name").getAttribute("pubkey"), message.pubkey);
302 const link = node.querySelector("p a");
303 assert.equal(link.href, "https://example.com/x");
304- assert.equal(node.querySelector("p").textContent, "see https://example.com/x, ok");
305+ assert.equal(node.querySelector("div p").textContent, "see https://example.com/x, ok");
306 assert.equal(node.querySelector("footer span nostr-name").getAttribute("pubkey"), "c".repeat(64));
307 source.emit("message", {data: "not json"});
308 source.emit("message", {data: JSON.stringify({...message, id: "9".repeat(64), kind: 20001})});
309@@ -199,13 +199,13 @@ test("room-live appends streamed messages once, summarizes reactions and reconne
310 assert.equal(summary.textContent, "+1 2");
311 const edit = finalizeEvent({kind: 40003, created_at: 1757203750, tags: [["h", "build"], ["e", message.id]], content: "see https://example.com/y instead"}, secret);
312 source.emit("message", {data: JSON.stringify(edit)});
313- assert.equal(node.querySelector("p").textContent, "see https://example.com/y instead");
314+ assert.equal(node.querySelector("div p").textContent, "see https://example.com/y instead");
315 assert.equal(node.querySelector("p a").href, "https://example.com/y");
316 assert.equal(node.dataset.edited, "");
317 assert.equal(node.querySelectorAll("footer span[data-edited]").length, 1);
318 const stranger = finalizeEvent({kind: 40003, created_at: 1757203760, tags: [["h", "build"], ["e", message.id]], content: "hijacked"}, generateSecretKey());
319 source.emit("message", {data: JSON.stringify(stranger)});
320- assert.equal(node.querySelector("p").textContent, "see https://example.com/y instead");
321+ assert.equal(node.querySelector("div p").textContent, "see https://example.com/y instead");
322 assert.equal(s.list.children.length, 1);
323 // An agent's message carries the marker from the members list.
324 const agent = finalizeEvent({kind: 11, created_at: 1757203800, tags: [["h", "build"]], content: "draft"}, secret);
325@@ -242,3 +242,23 @@ test("room-live on a thread page keeps only replies to its root", () => {
326 live.disconnectedCallback();
327 assert.equal(source.closed, true);
328 });
329+
330+test("chat markdown renders the shared subset without ever parsing markup", () => {
331+ const s = setup();
332+ const body = s.rooms.chatMarkdown(new FakeNode("div"), "It works: **[the page](https://012.run/wiki/agents)**.\nsee https://example.com/x, plus <b>not markup</b>\n\n- `code https://not.a.link`\n- [x](javascript:alert(1))\n\n```\nhttps://in.code\n```");
333+ const [p, list, pre] = body.children;
334+ assert.equal(p.localName, "p");
335+ assert.equal(p.querySelector("strong a").href, "https://012.run/wiki/agents");
336+ assert.equal(p.querySelectorAll("br").length, 1);
337+ assert.equal(p.querySelectorAll("a").length, 2);
338+ assert.equal(p.textContent, "It works: the page.see https://example.com/x, plus <b>not markup</b>");
339+ assert.equal(list.localName, "ul");
340+ assert.equal(list.children[0].querySelector("code").textContent, "code https://not.a.link");
341+ assert.equal(list.children[0].querySelectorAll("a").length, 0);
342+ assert.equal(list.children[1].textContent, "[x](javascript:alert(1))");
343+ assert.equal(list.children[1].querySelectorAll("a").length, 0);
344+ assert.equal(pre.localName, "pre");
345+ assert.equal(pre.querySelector("code").textContent, "https://in.code");
346+ assert.equal(pre.querySelectorAll("a").length, 0);
347+});
348+

About

ownerc01503e8035f
visibilitypublic
head126290ef3420

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs