clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

copy the page address from the crumb and stack wiki rows on phones

Dami committed 2beb15e
commit
2beb15ec7ff1e0ab7d5e55cf31c914fab06371f2
parent
34b6786edfc087e660f274f08a3c0da6ce482b87
author
Dami <dami.etoile@gmail.com>
10 files changed, +111 -12
Mdocs/app.md6++++
Minternal/webui/components.js19+++++++++++++
Minternal/webui/page.html4+-
Minternal/webui/rooms_test.go4+-
Minternal/webui/shell_test.go4+-
Minternal/webui/style.css7+++--
Minternal/webui/templates.go29++++++++++++++++++++
Minternal/webui/wiki_test.go4+-
Mpackage.json3+-
Ascripts/page-link.test.mjs43++++++++++++++++++++++++++++++
M docs/app.md +6 -0
M internal/webui/components.js +19 -0
M internal/webui/page.html +2 -2
@@ -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> &raquo; {{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> &raquo; <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> &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>70+<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>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>
M internal/webui/rooms_test.go +2 -2
@@ -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- `&raquo; rooms/general</span>`,160+ `&raquo; <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- `&raquo; rooms/general/thread/` + shortID(roomThread) + `</span>`,207+ `&raquo; <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)
M internal/webui/shell_test.go +2 -2
M internal/webui/style.css +4 -3
@@ -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 }
M internal/webui/templates.go +29 -0
@@ -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 },
M internal/webui/wiki_test.go +2 -2
@@ -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 &lt;b&gt;</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&amp;q=release"`,154- `<b>demo</b> &raquo; wiki`, `<a href="/wiki?new=1">new page</a>`)154+ `<b>demo</b> &raquo; <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> &raquo; wiki/Release Notes 1-4`)179+ `<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>`)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.
M package.json +2 -1
A scripts/page-link.test.mjs +43 -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+});