- commit
- 2beb15ec7ff1e0ab7d5e55cf31c914fab06371f2
- parent
- 34b6786edfc087e660f274f08a3c0da6ce482b87
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 6 | ++++ |
| M | internal/ | 19 | +++++++++++++ |
| M | internal/ | 4 | +- |
| M | internal/ | 4 | +- |
| M | internal/ | 4 | +- |
| M | internal/ | 7 | +++-- |
| M | internal/ | 29 | ++++++++++++++++++++ |
| M | internal/ | 4 | +- |
| M | package.json | 3 | +- |
| A | scripts/ | 43 | ++++++++++++++++++++++++++++++ |
@@ -32,3 +32,9 @@ Rebuild the bundle after changing `scripts/nostr-name-setup.mjs` or updating the
3232 3333 **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.3434 35+36+37+## Copying a page address38+39+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.40+\n\ No newline at end of file@@ -656,6 +656,24 @@
656656 }657657 }658658 659+ // PageLink is the crumb that names the current page. A tap copies the660+ // page's public address, the one the server put in url, and shows661+ // "copied" for a moment. Without JavaScript it is plain text.662+ class PageLink extends HTMLElement {663+ connectedCallback() {664+ if (this.bound) return;665+ this.bound = true;666+ this.addEventListener("click", () => {667+ const url = this.getAttribute("url") || location.href.split("#", 1)[0];668+ navigator.clipboard?.writeText(url).then(() => {669+ this.dataset.copied = "";670+ clearTimeout(this.timer);671+ this.timer = setTimeout(() => { delete this.dataset.copied; }, 1500);672+ }).catch(() => {});673+ });674+ }675+ }676+659677 // Rooms: the compose bar, room creation, room administration and the live660678 // stream. Each signed event is built here, signed by the connected signer,661679 // verified unchanged and published once to /events. room-message markup@@ -1676,6 +1694,7 @@
16761694 customElements.define("push-toggle", PushToggle);16771695 customElements.define("share-link", ShareLink);16781696 customElements.define("nostr-key", NostrKey);1697+ customElements.define("page-link", PageLink);16791698 customElements.define("json-view", JsonView);16801699 16811700 // Fixi normally swaps a small target. Internal page links return the full@@ -20,7 +20,7 @@
2020 <script src="/webmcp.js"></script>2121 </head>2222 <body>23-<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>23+<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>2424 <table id="layout" aria-label="{{.Slug}}"><tbody><tr>2525 <td id="rail"><div id="railbox">{{template "rail" .}}</div></td>2626 <td id="main"><div id="content" tabindex="-1">@@ -67,7 +67,7 @@
6767 {{end}}</div></td>6868 <td id="aside"><div id="panel">{{template "panel" .}}</div></td>6969 </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>70+<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>7171 <script src="{{scriptURL "tiny.js"}}"></script>7272 <script src="{{scriptURL "bridge.js"}}"></script>7373 {{if or (eq .Tab "files") (eq .Tab "file")}}<script src="{{scriptURL "blossom-encryption.js"}}"></script>@@ -157,7 +157,7 @@ func TestRoomPageRendersMessagesOldestFirstWithMarkers(t *testing.T) {
157157 `<ul id="members"><li><nostr-name pubkey="` + roomOwner + `"`, `<li data-agent><nostr-name pubkey="` + roomAgent + `"`, `<small>member | agent</small>`,158158 `<room-action room="general" kind="9000">`, `<room-action room="general" kind="9002">`, `<room-action room="general" kind="9022">`,159159 `<nav id="room-list"><a href="/rooms/general" aria-current="page">General`,160- `» rooms/general</span>`,160+ `» <page-link url="http://relay.example/rooms/general" title="Copy the page address">rooms/general</page-link></span>`,161161 } {162162 if !strings.Contains(body, want) {163163 t.Errorf("room page missing %q", want)@@ -204,7 +204,7 @@ func TestThreadPageRendersRootRepliesAndReplyCompose(t *testing.T) {
204204 `<div id="root"><room-message id="msg-` + roomThread + `"`, `<div id="messages">`, `id="msg-` + roomReply + `"`,205205 `<room-live room="general" root="` + roomThread + `"></room-live>`,206206 `<room-compose room="general" pubkey="` + roomOwner + `" kind="12" root="` + roomThread + `" root-pubkey="` + roomAgent + `">`,207- `» rooms/general/thread/` + shortID(roomThread) + `</span>`,207+ `» <page-link url="http://relay.example/rooms/general/thread/` + roomThread + `" title="Copy the page address">rooms/general/thread/` + shortID(roomThread) + `</page-link></span>`,208208 } {209209 if !strings.Contains(body, want) {210210 t.Errorf("thread page missing %q", want)@@ -34,8 +34,8 @@ func TestShellRendersRailPanelAndPrompt(t *testing.T) {
3434 t.Fatal(err)3535 }3636 for path, wants := range map[string][]string{37- "/": {`id="mark"`, `href="/search"`, `<b>demo</b> » home`, `id="panel"`, `id="topbar"`, `rel="manifest"`},38- "/manage/rules": {`<b>manage</b>`, `href="/manage/rules" aria-current="page"`, `manage/rules</span>`},37+ "/": {`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"`},38+ "/manage/rules": {`<b>manage</b>`, `href="/manage/rules" aria-current="page"`, `manage/rules</page-link></span>`},3939 "/repo?owner=aa&repo=notes&view=history": {`/history</a>`, `repos/notes/history`},4040 } {4141 recorder := httptest.NewRecorder()@@ -177,7 +177,7 @@ nostr-event>header{font:12px var(--mono);color:var(--muted);margin-bottom:4px}
177177 nostr-event>header small{float:right}178178 nostr-event>pre{margin:0;white-space:pre-wrap;font:14px/1.45 var(--sans)}179179 nostr-event>details{margin-top:6px;font:12px var(--mono)}nostr-event>details>summary{cursor:pointer;color:var(--muted)}180-nostr-key{font-family:var(--mono);cursor:copy}nostr-name{font-family:var(--mono)}180+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)}181181 182182 /* approval-item: one request that waits for a decision, in the open rhythm of nostr-event. */183183 approval-item{display:block;border-bottom:1px solid var(--line);padding:10px 0;max-width:720px}@@ -231,7 +231,7 @@ diff-view td>nostr-event{white-space:normal;max-width:720px;border:0;padding:6px
231231 #reply-anchor{font:13px var(--sans);color:var(--muted)}#events li>p{margin:-6px 0 8px;font:12px var(--mono);color:var(--muted)}232232 233233 /* Wiki: the page list, one article, the merge request bar and the editor. */234-#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)}234+#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)}235235 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)}236236 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)}237237 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}@@ -314,7 +314,8 @@ html,body{overflow:auto}
314314 #tree{border-right:0;border-bottom:1px solid var(--line)}315315 #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}316316 #grid,#gridhead,h2:has(+#gridhead){display:none}317-#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}317+/* Phones: each wiki row reads as a title line and one muted line of facts. */318+#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:" | "}318319 room-compose{bottom:calc(28px + env(safe-area-inset-bottom,0px));margin:8px -2.5ch 0;padding:10px 2.5ch 8px;z-index:1}319320 body:has(#room) #layout>tbody>tr>td#aside,body:has(#thread) #layout>tbody>tr>td#aside{display:none}320321 }@@ -158,9 +158,37 @@ func promptPath(path string, query url.Values) string {
158158 if room := roomRoute("/" + path); room.tab == "thread" {159159 return "rooms/" + room.id + "/thread/" + shortID(room.event)160160 }161+ if strings.HasPrefix(path, "wiki/") {162+ return "wiki/" + wikiPageName("/"+path)163+ }161164 return path162165 }163166 167+// pageAddress is the address the crumb copies: the relay's public URL plus168+// the page's canonical path, so a link copied on a tailnet or LAN address169+// still opens for anyone. Wiki names are normalized the way the relay170+// resolves them; repositories and files keep the query that names them.171+func pageAddress(base, path string, query url.Values) string {172+ path = "/" + strings.Trim(path, "/")173+ if path == "/" {174+ path = ""175+ }176+ if strings.HasPrefix(path, "/wiki/") {177+ path = "/wiki/" + url.PathEscape(wikiPageName(path))178+ }179+ keep := url.Values{}180+ for _, key := range []string{"owner", "repo", "view", "path", "ref", "sha", "hash", "q", "id", "author", "version", "merge"} {181+ if value := query.Get(key); value != "" {182+ keep.Set(key, value)183+ }184+ }185+ address := strings.TrimSuffix(base, "/") + path186+ if len(keep) > 0 {187+ address += "?" + keep.Encode()188+ }189+ return address190+}191+164192 // wsURL turns the relay's public http(s) URL into its websocket form.165193 func wsURL(base string) string {166194 switch {@@ -238,6 +266,7 @@ func parseTemplates() (*template.Template, error) {
238266 "wsURL": wsURL,239267 "short": short,240268 "prompt": promptPath,269+ "pageAddress": pageAddress,241270 "railKind": railKind,242271 "repoView": repoView,243272 "relayItems": func() []navItem { return relayNav },@@ -151,7 +151,7 @@ func TestWikiListRendersPagesAndSearch(t *testing.T) {
151151 `href="/wiki" aria-current="page"`, `id="wiki-search"`, `value="release"`,152152 `<a href="/wiki/release-notes-1-4">Release notes 1.4 <b></a>`, `<nostr-name pubkey="`+wikiOwner+`"`,153153 `<td>2</td><td>1 open</td>`, `2026-09-08 14:00 UTC`, `href="/wiki?cursor=release-notes-1-4&q=release"`,154- `<b>demo</b> » wiki`, `<a href="/wiki?new=1">new page</a>`)154+ `<b>demo</b> » <page-link url="http://relay.example/wiki`, `<a href="/wiki?new=1">new page</a>`)155155 wantNone(t, "/wiki", body, "<wiki-compose")156156 body = wikiGet(t, app, "/wiki?new=1")157157 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>`)@@ -176,7 +176,7 @@ func TestWikiPageRendersArticleMergeBarAndPanel(t *testing.T) {
176176 `<h4>Other versions</h4>`, `href="/wiki/release-notes-1-4?version=`+wikiOtherV+`">222222222222</a> | fork`,177177 `<h4>Also known as</h4>`, `<code>changelog-1-4</code>`,178178 `href="/wiki/release-notes-1-4?edit=1">edit</a>`, `href="/wiki/release-notes-1-4?history=1">history</a>`,179- `<b>demo</b> » wiki/Release Notes 1-4`)179+ `<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>`)180180 wantNone(t, path, body, "<script>alert", ">fork</a>")181181 // The other author sees the request but cannot answer it, and forks182182 // instead of editing.@@ -14,7 +14,8 @@
1414 "test:approvals": "node --test scripts/approvals.test.mjs",1515 "test:wiki": "node --test scripts/wiki-compose.test.mjs",1616 "test:rooms": "node --test scripts/rooms.test.mjs",17- "test:profile": "node --test scripts/profile-form.test.mjs"17+ "test:profile": "node --test scripts/profile-form.test.mjs",18+ "test:page-link": "node --test scripts/page-link.test.mjs"1819 },1920 "devDependencies": {2021 "@noble/hashes": "2.4.0",@@ -0,0 +1,43 @@
1+// The crumb copies the page's public address on a tap and says so briefly.2+import test from "node:test";3+import assert from "node:assert/strict";4+import fs from "node:fs";5+import vm from "node:vm";6+7+const source = fs.readFileSync("internal/webui/components.js", "utf8");8+const linkSource = source.slice(source.indexOf(" // PageLink is the crumb"), source.indexOf(" // Rooms: the compose bar"));9+10+function setup({url = "https://relay.example/wiki/notes", clipboard = true} = {}) {11+ const written = [];12+ class HTMLElement {13+ constructor() { this.dataset = {}; this.listeners = {}; }14+ getAttribute(name) { return name === "url" ? url : null; }15+ addEventListener(name, fn) { this.listeners[name] = fn; }16+ }17+ const timers = [];18+ const sandbox = {HTMLElement, navigator: clipboard ? {clipboard: {writeText: async value => { written.push(value); }}} : {}, location: {href: "https://tailnet.example/wiki/notes#x"},19+ setTimeout: (fn, ms) => { timers.push({fn, ms}); return timers.length; }, clearTimeout: () => {}};20+ const Constructor = vm.runInNewContext(`${linkSource}\nPageLink`, sandbox);21+ const link = new Constructor();22+ link.connectedCallback();23+ return {link, written, timers};24+}25+26+test("a tap copies the public address from the server, not the browser's location", async () => {27+ const s = setup();28+ s.link.listeners.click();29+ await new Promise(resolve => setImmediate(resolve));30+ assert.deepEqual(s.written, ["https://relay.example/wiki/notes"]);31+ assert.equal(s.link.dataset.copied, "");32+ assert.equal(s.timers[0].ms, 1500);33+ s.timers[0].fn();34+ assert.equal("copied" in s.link.dataset, false);35+});36+37+test("without a clipboard nothing happens and nothing throws", async () => {38+ const s = setup({clipboard: false});39+ s.link.listeners.click();40+ await new Promise(resolve => setImmediate(resolve));41+ assert.deepEqual(s.written, []);42+ assert.equal("copied" in s.link.dataset, false);43+});