| 1 | diff --git a/docs/app.md b/docs/app.md |
| 2 | index 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 |
| 16 | diff --git a/internal/webui/components.js b/internal/webui/components.js |
| 17 | index 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 |
| 53 | diff --git a/internal/webui/page.html b/internal/webui/page.html |
| 54 | index 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> » {{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> » <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> » {{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> » <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> |
| 75 | diff --git a/internal/webui/rooms_test.go b/internal/webui/rooms_test.go |
| 76 | index 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 | - `» rooms/general</span>`, |
| 84 | + `» <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 | - `» rooms/general/thread/` + shortID(roomThread) + `</span>`, |
| 93 | + `» <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) |
| 97 | diff --git a/internal/webui/shell_test.go b/internal/webui/shell_test.go |
| 98 | index 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> » 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> » <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() |
| 112 | diff --git a/internal/webui/style.css b/internal/webui/style.css |
| 113 | index 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 | } |
| 144 | diff --git a/internal/webui/templates.go b/internal/webui/templates.go |
| 145 | index 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 }, |
| 194 | diff --git a/internal/webui/wiki_test.go b/internal/webui/wiki_test.go |
| 195 | index 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 <b></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&q=release"`, |
| 202 | - `<b>demo</b> » wiki`, `<a href="/wiki?new=1">new page</a>`) |
| 203 | + `<b>demo</b> » <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> » wiki/Release Notes 1-4`) |
| 212 | + `<b>demo</b> » <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. |
| 216 | diff --git a/package.json b/package.json |
| 217 | index 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", |
| 230 | diff --git a/scripts/page-link.test.mjs b/scripts/page-link.test.mjs |
| 231 | new file mode 100644 |
| 232 | index 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 | +}); |