tinyrelaycommit

Commit 2beb15ec7ff1

browse files at this commit | history

1diff --git a/docs/app.md b/docs/app.md
2index 0a7ac25..749af24 100644
3--- a/docs/app.md
4+++ b/docs/app.md
5@@ -32,3 +32,9 @@ Rebuild the bundle after changing `scripts/nostr-name-setup.mjs` or updating the
6
7 **Profile** in the home page's Actions, shown once you are signed in, edits the profile (kind 0) other people and apps see for your key: name, display name, about, picture, banner, website, NIP-05 address and lightning address. The form starts from the profile this relay holds, and fields it does not show are kept as they are. Saving signs the event with your connected signer, publishes it to this relay first, then to every relay in **Also publish to**, which starts out as the write relays from your relay list on this relay; each relay reports ok or failed under the form. The browser forgets your cached name at the same time, so pages show the new one on the next load.
8
9+
10+
11+## Copying a page address
12+
13+The crumb that names the current page, next to the relay name at the top on phones and in the footer on larger screens, copies the page's address when tapped and says "copied" for a moment. The address is the relay's public URL with the page's canonical path, so a link copied while browsing over a tailnet or LAN address still opens for anyone: wiki names are normalized the way the relay resolves them, and repositories and files keep the query that names them.
14+\n
15\ No newline at end of file
16diff --git a/internal/webui/components.js b/internal/webui/components.js
17index 5c897cb..6ac7d79 100644
18--- a/internal/webui/components.js
19+++ b/internal/webui/components.js
20@@ -656,6 +656,24 @@
21 }
22 }
23
24+ // PageLink is the crumb that names the current page. A tap copies the
25+ // page's public address, the one the server put in url, and shows
26+ // "copied" for a moment. Without JavaScript it is plain text.
27+ class PageLink extends HTMLElement {
28+ connectedCallback() {
29+ if (this.bound) return;
30+ this.bound = true;
31+ this.addEventListener("click", () => {
32+ const url = this.getAttribute("url") || location.href.split("#", 1)[0];
33+ navigator.clipboard?.writeText(url).then(() => {
34+ this.dataset.copied = "";
35+ clearTimeout(this.timer);
36+ this.timer = setTimeout(() => { delete this.dataset.copied; }, 1500);
37+ }).catch(() => {});
38+ });
39+ }
40+ }
41+
42 // Rooms: the compose bar, room creation, room administration and the live
43 // stream. Each signed event is built here, signed by the connected signer,
44 // verified unchanged and published once to /events. room-message markup
45@@ -1676,6 +1694,7 @@
46 customElements.define("push-toggle", PushToggle);
47 customElements.define("share-link", ShareLink);
48 customElements.define("nostr-key", NostrKey);
49+ customElements.define("page-link", PageLink);
50 customElements.define("json-view", JsonView);
51
52 // Fixi normally swaps a small target. Internal page links return the full
53diff --git a/internal/webui/page.html b/internal/webui/page.html
54index 5875006..8b5cf9f 100644
55--- a/internal/webui/page.html
56+++ b/internal/webui/page.html
57@@ -20,7 +20,7 @@
58 <script src="/webmcp.js"></script>
59 </head>
60 <body>
61-<header id="topbar"><details id="nav-menu"><summary id="menu" aria-controls="rail">menu</summary></details> <span><b>{{.Slug}}</b> &raquo; {{prompt .Path .Query}}</span></header>
62+<header id="topbar"><details id="nav-menu"><summary id="menu" aria-controls="rail">menu</summary></details> <span><b>{{.Slug}}</b> &raquo; <page-link url="{{pageAddress .URL .Path .Query}}" title="Copy the page address">{{prompt .Path .Query}}</page-link></span></header>
63 <table id="layout" aria-label="{{.Slug}}"><tbody><tr>
64 <td id="rail"><div id="railbox">{{template "rail" .}}</div></td>
65 <td id="main"><div id="content" tabindex="-1">
66@@ -67,7 +67,7 @@
67 {{end}}</div></td>
68 <td id="aside"><div id="panel">{{template "panel" .}}</div></td>
69 </tr></tbody></table>
70-<footer id="footer"><table><tr><td><span id="prompt"><b>{{.Slug}}</b> &raquo; {{prompt .Path .Query}}</span></td><td><span id="who">{{if .Private}}private relay{{else if .Actor}}signed in as {{template "nostrName" .Actor}} (<button id="session-logout" type="button">sign out</button>){{else}}browsing as guest (<a href="/signin">Sign in</a>){{end}}</span> <span id="session-status" role="status"></span><span id="navigation-status" role="status" aria-live="polite"></span></td></tr></table><button id="install" type="button" hidden>install app</button></footer>
71+<footer id="footer"><table><tr><td><span id="prompt"><b>{{.Slug}}</b> &raquo; <page-link url="{{pageAddress .URL .Path .Query}}" title="Copy the page address">{{prompt .Path .Query}}</page-link></span></td><td><span id="who">{{if .Private}}private relay{{else if .Actor}}signed in as {{template "nostrName" .Actor}} (<button id="session-logout" type="button">sign out</button>){{else}}browsing as guest (<a href="/signin">Sign in</a>){{end}}</span> <span id="session-status" role="status"></span><span id="navigation-status" role="status" aria-live="polite"></span></td></tr></table><button id="install" type="button" hidden>install app</button></footer>
72 <script src="{{scriptURL "tiny.js"}}"></script>
73 <script src="{{scriptURL "bridge.js"}}"></script>
74 {{if or (eq .Tab "files") (eq .Tab "file")}}<script src="{{scriptURL "blossom-encryption.js"}}"></script>
75diff --git a/internal/webui/rooms_test.go b/internal/webui/rooms_test.go
76index 61430d7..f2bad6f 100644
77--- a/internal/webui/rooms_test.go
78+++ b/internal/webui/rooms_test.go
79@@ -157,7 +157,7 @@ func TestRoomPageRendersMessagesOldestFirstWithMarkers(t *testing.T) {
80 `<ul id="members"><li><nostr-name pubkey="` + roomOwner + `"`, `<li data-agent><nostr-name pubkey="` + roomAgent + `"`, `<small>member | agent</small>`,
81 `<room-action room="general" kind="9000">`, `<room-action room="general" kind="9002">`, `<room-action room="general" kind="9022">`,
82 `<nav id="room-list"><a href="/rooms/general" aria-current="page">General`,
83- `&raquo; rooms/general</span>`,
84+ `&raquo; <page-link url="http://relay.example/rooms/general" title="Copy the page address">rooms/general</page-link></span>`,
85 } {
86 if !strings.Contains(body, want) {
87 t.Errorf("room page missing %q", want)
88@@ -204,7 +204,7 @@ func TestThreadPageRendersRootRepliesAndReplyCompose(t *testing.T) {
89 `<div id="root"><room-message id="msg-` + roomThread + `"`, `<div id="messages">`, `id="msg-` + roomReply + `"`,
90 `<room-live room="general" root="` + roomThread + `"></room-live>`,
91 `<room-compose room="general" pubkey="` + roomOwner + `" kind="12" root="` + roomThread + `" root-pubkey="` + roomAgent + `">`,
92- `&raquo; rooms/general/thread/` + shortID(roomThread) + `</span>`,
93+ `&raquo; <page-link url="http://relay.example/rooms/general/thread/` + roomThread + `" title="Copy the page address">rooms/general/thread/` + shortID(roomThread) + `</page-link></span>`,
94 } {
95 if !strings.Contains(body, want) {
96 t.Errorf("thread page missing %q", want)
97diff --git a/internal/webui/shell_test.go b/internal/webui/shell_test.go
98index 7325244..61d0cd1 100644
99--- a/internal/webui/shell_test.go
100+++ b/internal/webui/shell_test.go
101@@ -34,8 +34,8 @@ func TestShellRendersRailPanelAndPrompt(t *testing.T) {
102 t.Fatal(err)
103 }
104 for path, wants := range map[string][]string{
105- "/": {`id="mark"`, `href="/search"`, `<b>demo</b> &raquo; home`, `id="panel"`, `id="topbar"`, `rel="manifest"`},
106- "/manage/rules": {`<b>manage</b>`, `href="/manage/rules" aria-current="page"`, `manage/rules</span>`},
107+ "/": {`id="mark"`, `href="/search"`, `<b>demo</b> &raquo; <page-link url="http://relay.example" title="Copy the page address">home</page-link>`, `id="panel"`, `id="topbar"`, `rel="manifest"`},
108+ "/manage/rules": {`<b>manage</b>`, `href="/manage/rules" aria-current="page"`, `manage/rules</page-link></span>`},
109 "/repo?owner=aa&repo=notes&view=history": {`/history</a>`, `repos/notes/history`},
110 } {
111 recorder := httptest.NewRecorder()
112diff --git a/internal/webui/style.css b/internal/webui/style.css
113index f4ebea3..0d6fe29 100644
114--- a/internal/webui/style.css
115+++ b/internal/webui/style.css
116@@ -177,7 +177,7 @@ nostr-event>header{font:12px var(--mono);color:var(--muted);margin-bottom:4px}
117 nostr-event>header small{float:right}
118 nostr-event>pre{margin:0;white-space:pre-wrap;font:14px/1.45 var(--sans)}
119 nostr-event>details{margin-top:6px;font:12px var(--mono)}nostr-event>details>summary{cursor:pointer;color:var(--muted)}
120-nostr-key{font-family:var(--mono);cursor:copy}nostr-name{font-family:var(--mono)}
121+nostr-key{font-family:var(--mono);cursor:copy}page-link{cursor:copy}page-link[data-copied]::after{content:" copied";color:var(--accent)}nostr-name{font-family:var(--mono)}
122
123 /* approval-item: one request that waits for a decision, in the open rhythm of nostr-event. */
124 approval-item{display:block;border-bottom:1px solid var(--line);padding:10px 0;max-width:720px}
125@@ -231,7 +231,7 @@ diff-view td>nostr-event{white-space:normal;max-width:720px;border:0;padding:6px
126 #reply-anchor{font:13px var(--sans);color:var(--muted)}#events li>p{margin:-6px 0 8px;font:12px var(--mono);color:var(--muted)}
127
128 /* Wiki: the page list, one article, the merge request bar and the editor. */
129-#wiki-list #rows th{white-space:nowrap}#wiki-list #rows td:first-child{width:auto;font:14px var(--sans)}#wiki-list #rows td:nth-child(3),#wiki-list #rows td:nth-child(4){width:110px;font:12px var(--mono);color:var(--muted)}
130+#wiki-list #rows th{white-space:nowrap}#wiki-list #rows td:first-child{width:auto;font:14px var(--sans)}#wiki-list #rows td:nth-child(2){font:13px var(--mono)}#wiki-list #rows td:nth-child(3),#wiki-list #rows td:nth-child(4){width:110px;font:12px var(--mono);color:var(--muted)}
131 wiki-article{display:block;max-width:720px;overflow-wrap:anywhere}wiki-article>header{font:12px var(--mono);color:var(--muted);margin-bottom:8px}wiki-article>header nostr-key,wiki-article>header nostr-name{color:var(--ink)}
132 wiki-article h1{font-size:20px;margin:18px 0 8px}wiki-article h2{font-size:17px}wiki-article h3{font-size:15px}wiki-article p{margin:0 0 10px}wiki-article table{border-collapse:collapse;margin:0 0 12px;font:13px var(--sans);width:auto}wiki-article th,wiki-article td{border:1px solid var(--line);padding:4px 8px;text-align:left;vertical-align:top;height:auto;white-space:normal}wiki-article th{font-weight:600;background:var(--rail)}
133 wiki-article 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}wiki-article ul,wiki-article ol{padding-left:20px;margin:0 0 10px}
134@@ -314,7 +314,8 @@ html,body{overflow:auto}
135 #tree{border-right:0;border-bottom:1px solid var(--line)}
136 #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}
137 #grid,#gridhead,h2:has(+#gridhead){display:none}
138-#wiki-list #rows th{white-space:normal}#wiki-list #rows td:nth-child(3),#wiki-list #rows th:nth-child(3){display:none}#wiki-list #rows td:nth-child(4){width:70px}
139+/* Phones: each wiki row reads as a title line and one muted line of facts. */
140+#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:" | "}
141 room-compose{bottom:calc(28px + env(safe-area-inset-bottom,0px));margin:8px -2.5ch 0;padding:10px 2.5ch 8px;z-index:1}
142 body:has(#room) #layout>tbody>tr>td#aside,body:has(#thread) #layout>tbody>tr>td#aside{display:none}
143 }
144diff --git a/internal/webui/templates.go b/internal/webui/templates.go
145index 94bf1cc..38df33c 100644
146--- a/internal/webui/templates.go
147+++ b/internal/webui/templates.go
148@@ -158,9 +158,37 @@ func promptPath(path string, query url.Values) string {
149 if room := roomRoute("/" + path); room.tab == "thread" {
150 return "rooms/" + room.id + "/thread/" + shortID(room.event)
151 }
152+ if strings.HasPrefix(path, "wiki/") {
153+ return "wiki/" + wikiPageName("/"+path)
154+ }
155 return path
156 }
157
158+// pageAddress is the address the crumb copies: the relay's public URL plus
159+// the page's canonical path, so a link copied on a tailnet or LAN address
160+// still opens for anyone. Wiki names are normalized the way the relay
161+// resolves them; repositories and files keep the query that names them.
162+func pageAddress(base, path string, query url.Values) string {
163+ path = "/" + strings.Trim(path, "/")
164+ if path == "/" {
165+ path = ""
166+ }
167+ if strings.HasPrefix(path, "/wiki/") {
168+ path = "/wiki/" + url.PathEscape(wikiPageName(path))
169+ }
170+ keep := url.Values{}
171+ for _, key := range []string{"owner", "repo", "view", "path", "ref", "sha", "hash", "q", "id", "author", "version", "merge"} {
172+ if value := query.Get(key); value != "" {
173+ keep.Set(key, value)
174+ }
175+ }
176+ address := strings.TrimSuffix(base, "/") + path
177+ if len(keep) > 0 {
178+ address += "?" + keep.Encode()
179+ }
180+ return address
181+}
182+
183 // wsURL turns the relay's public http(s) URL into its websocket form.
184 func wsURL(base string) string {
185 switch {
186@@ -238,6 +266,7 @@ func parseTemplates() (*template.Template, error) {
187 "wsURL": wsURL,
188 "short": short,
189 "prompt": promptPath,
190+ "pageAddress": pageAddress,
191 "railKind": railKind,
192 "repoView": repoView,
193 "relayItems": func() []navItem { return relayNav },
194diff --git a/internal/webui/wiki_test.go b/internal/webui/wiki_test.go
195index 630305b..12e5da2 100644
196--- a/internal/webui/wiki_test.go
197+++ b/internal/webui/wiki_test.go
198@@ -151,7 +151,7 @@ func TestWikiListRendersPagesAndSearch(t *testing.T) {
199 `href="/wiki" aria-current="page"`, `id="wiki-search"`, `value="release"`,
200 `<a href="/wiki/release-notes-1-4">Release notes 1.4 &lt;b&gt;</a>`, `<nostr-name pubkey="`+wikiOwner+`"`,
201 `<td>2</td><td>1 open</td>`, `2026-09-08 14:00 UTC`, `href="/wiki?cursor=release-notes-1-4&amp;q=release"`,
202- `<b>demo</b> &raquo; wiki`, `<a href="/wiki?new=1">new page</a>`)
203+ `<b>demo</b> &raquo; <page-link url="http://relay.example/wiki`, `<a href="/wiki?new=1">new page</a>`)
204 wantNone(t, "/wiki", body, "<wiki-compose")
205 body = wikiGet(t, app, "/wiki?new=1")
206 wantAll(t, "/wiki?new=1", body, `<h2>New page</h2>`, `<wiki-compose name="" author="" coordinate="" event="">`, `name="title"`, `name="name"`, `name="summary"`, `name="content"`, `value="publish"`, `<noscript>`)
207@@ -176,7 +176,7 @@ func TestWikiPageRendersArticleMergeBarAndPanel(t *testing.T) {
208 `<h4>Other versions</h4>`, `href="/wiki/release-notes-1-4?version=`+wikiOtherV+`">222222222222</a> | fork`,
209 `<h4>Also known as</h4>`, `<code>changelog-1-4</code>`,
210 `href="/wiki/release-notes-1-4?edit=1">edit</a>`, `href="/wiki/release-notes-1-4?history=1">history</a>`,
211- `<b>demo</b> &raquo; wiki/Release Notes 1-4`)
212+ `<b>demo</b> &raquo; <page-link url="http://relay.example/wiki/release-notes-1-4" title="Copy the page address">wiki/release-notes-1-4</page-link>`)
213 wantNone(t, path, body, "<script>alert", ">fork</a>")
214 // The other author sees the request but cannot answer it, and forks
215 // instead of editing.
216diff --git a/package.json b/package.json
217index e8a201f..35a2a6f 100644
218--- a/package.json
219+++ b/package.json
220@@ -14,7 +14,8 @@
221 "test:approvals": "node --test scripts/approvals.test.mjs",
222 "test:wiki": "node --test scripts/wiki-compose.test.mjs",
223 "test:rooms": "node --test scripts/rooms.test.mjs",
224- "test:profile": "node --test scripts/profile-form.test.mjs"
225+ "test:profile": "node --test scripts/profile-form.test.mjs",
226+ "test:page-link": "node --test scripts/page-link.test.mjs"
227 },
228 "devDependencies": {
229 "@noble/hashes": "2.4.0",
230diff --git a/scripts/page-link.test.mjs b/scripts/page-link.test.mjs
231new file mode 100644
232index 0000000..61f89cb
233--- /dev/null
234+++ b/scripts/page-link.test.mjs
235@@ -0,0 +1,43 @@
236+// The crumb copies the page's public address on a tap and says so briefly.
237+import test from "node:test";
238+import assert from "node:assert/strict";
239+import fs from "node:fs";
240+import vm from "node:vm";
241+
242+const source = fs.readFileSync("internal/webui/components.js", "utf8");
243+const linkSource = source.slice(source.indexOf(" // PageLink is the crumb"), source.indexOf(" // Rooms: the compose bar"));
244+
245+function setup({url = "https://relay.example/wiki/notes", clipboard = true} = {}) {
246+ const written = [];
247+ class HTMLElement {
248+ constructor() { this.dataset = {}; this.listeners = {}; }
249+ getAttribute(name) { return name === "url" ? url : null; }
250+ addEventListener(name, fn) { this.listeners[name] = fn; }
251+ }
252+ const timers = [];
253+ const sandbox = {HTMLElement, navigator: clipboard ? {clipboard: {writeText: async value => { written.push(value); }}} : {}, location: {href: "https://tailnet.example/wiki/notes#x"},
254+ setTimeout: (fn, ms) => { timers.push({fn, ms}); return timers.length; }, clearTimeout: () => {}};
255+ const Constructor = vm.runInNewContext(`${linkSource}\nPageLink`, sandbox);
256+ const link = new Constructor();
257+ link.connectedCallback();
258+ return {link, written, timers};
259+}
260+
261+test("a tap copies the public address from the server, not the browser's location", async () => {
262+ const s = setup();
263+ s.link.listeners.click();
264+ await new Promise(resolve => setImmediate(resolve));
265+ assert.deepEqual(s.written, ["https://relay.example/wiki/notes"]);
266+ assert.equal(s.link.dataset.copied, "");
267+ assert.equal(s.timers[0].ms, 1500);
268+ s.timers[0].fn();
269+ assert.equal("copied" in s.link.dataset, false);
270+});
271+
272+test("without a clipboard nothing happens and nothing throws", async () => {
273+ const s = setup({clipboard: false});
274+ s.link.listeners.click();
275+ await new Promise(resolve => setImmediate(resolve));
276+ assert.deepEqual(s.written, []);
277+ assert.equal("copied" in s.link.dataset, false);
278+});

About

ownerc01503e8035f
visibilitypublic
head2beb15ec7ff1

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs