| 1 | diff --git a/internal/webui/components.js b/internal/webui/components.js |
| 2 | index ad1ccf3..44fe1b1 100644 |
| 3 | --- a/internal/webui/components.js |
| 4 | +++ b/internal/webui/components.js |
| 5 | @@ -296,7 +296,102 @@ |
| 6 | } |
| 7 | } |
| 8 | |
| 9 | + // RelayLists reads the signed-in key's relay lists from this relay, says |
| 10 | + // whether this relay is on each, and republishes a list with this relay |
| 11 | + // added. Lists are replaceable events, so the new one carries every |
| 12 | + // existing entry plus this relay. Each publish goes to this relay and to |
| 13 | + // the relays already on the list so other clients find the update. |
| 14 | + class RelayLists extends HTMLElement { |
| 15 | + connectedCallback() { |
| 16 | + if (this.bound) return; |
| 17 | + this.bound = true; |
| 18 | + this.output = this.querySelector("output"); |
| 19 | + this.addEventListener("click", event => { |
| 20 | + const button = event.target.closest("button[name]"); |
| 21 | + const row = button?.closest("tr[data-kind]"); |
| 22 | + if (!button || !row) return; |
| 23 | + button.disabled = true; |
| 24 | + const action = button.name === "add" ? this.add(row) : this.check(row); |
| 25 | + action.catch(err => this.say(row, "error: " + err.message)).finally(() => { button.disabled = false; }); |
| 26 | + }); |
| 27 | + } |
| 28 | + |
| 29 | + say(row, text) { |
| 30 | + let note = row.querySelector("output"); |
| 31 | + if (!note) { note = el("output"); row.lastElementChild.append(" ", note); } |
| 32 | + note.textContent = text; |
| 33 | + } |
| 34 | + |
| 35 | + value(row) { return row.dataset.value === "server" ? this.getAttribute("server") : this.getAttribute("relay"); } |
| 36 | + |
| 37 | + normal(value) { return String(value || "").trim().replace(/\/+$/, "").toLowerCase(); } |
| 38 | + |
| 39 | + // socket opens one websocket, runs fn with send and a message queue, then closes. |
| 40 | + socket(url, fn, timeout = 8000) { |
| 41 | + return new Promise((resolve, reject) => { |
| 42 | + let ws; |
| 43 | + try { ws = new WebSocket(url); } catch (err) { reject(err); return; } |
| 44 | + const timer = setTimeout(() => { ws.close(); reject(Error("timed out talking to " + url)); }, timeout); |
| 45 | + const done = (value, err) => { clearTimeout(timer); ws.close(); err ? reject(err) : resolve(value); }; |
| 46 | + ws.addEventListener("error", () => done(null, Error("could not reach " + url))); |
| 47 | + ws.addEventListener("open", () => fn(message => ws.send(JSON.stringify(message)), handler => ws.addEventListener("message", e => handler(JSON.parse(e.data))), done)); |
| 48 | + }); |
| 49 | + } |
| 50 | + |
| 51 | + current(row) { |
| 52 | + const kind = Number(row.dataset.kind); |
| 53 | + return this.socket(this.getAttribute("relay"), (send, onMessage, done) => { |
| 54 | + let latest = null; |
| 55 | + onMessage(message => { |
| 56 | + if (message[0] === "EVENT" && message[2]?.kind === kind && (!latest || message[2].created_at > latest.created_at)) latest = message[2]; |
| 57 | + if (message[0] === "EOSE") done(latest); |
| 58 | + }); |
| 59 | + send(["REQ", "lists", {kinds: [kind], authors: [this.getAttribute("pubkey")], limit: 1}]); |
| 60 | + }); |
| 61 | + } |
| 62 | + |
| 63 | + entries(event, tag) { |
| 64 | + return (event?.tags || []).filter(t => t[0] === tag).map(t => t[1]); |
| 65 | + } |
| 66 | + |
| 67 | + async check(row) { |
| 68 | + this.say(row, "checking…"); |
| 69 | + const event = await this.current(row); |
| 70 | + if (!event) { this.say(row, "no list published here yet"); return; } |
| 71 | + const listed = this.entries(event, row.dataset.tag).some(v => this.normal(v) === this.normal(this.value(row))); |
| 72 | + this.say(row, listed ? "listed" : "not listed, " + this.entries(event, row.dataset.tag).length + " other entries"); |
| 73 | + } |
| 74 | + |
| 75 | + async add(row) { |
| 76 | + if (!window.nostr?.signEvent) throw Error("connect a signer first"); |
| 77 | + this.say(row, "reading…"); |
| 78 | + const existing = await this.current(row); |
| 79 | + const value = this.value(row); |
| 80 | + const tags = (existing?.tags || []).filter(t => t.length); |
| 81 | + if (!this.entries(existing, row.dataset.tag).some(v => this.normal(v) === this.normal(value))) tags.push([row.dataset.tag, value]); |
| 82 | + const event = await window.nostr.signEvent({kind: Number(row.dataset.kind), created_at: Math.floor(Date.now() / 1000), content: existing?.content || "", tags}); |
| 83 | + const targets = [this.getAttribute("relay")]; |
| 84 | + for (const t of tags) if ((t[0] === "r" || t[0] === "relay") && /^wss?:\/\//i.test(t[1]) && !targets.includes(t[1])) targets.push(t[1]); |
| 85 | + let accepted = 0; |
| 86 | + const failures = []; |
| 87 | + for (const url of targets) { |
| 88 | + try { |
| 89 | + await this.socket(url, (send, onMessage, done) => { |
| 90 | + onMessage(message => { if (message[0] === "OK" && message[1] === event.id) message[2] ? done(true) : done(null, Error(message[3] || "rejected")); }); |
| 91 | + send(["EVENT", event]); |
| 92 | + }, 6000); |
| 93 | + accepted++; |
| 94 | + } catch (err) { |
| 95 | + failures.push(url.replace(/^wss?:\/\//, "")); |
| 96 | + } |
| 97 | + } |
| 98 | + if (!accepted) throw Error("no relay accepted the list: " + failures.join(", ")); |
| 99 | + this.say(row, "listed, published to " + accepted + " of " + targets.length + " relays" + (failures.length ? " (failed: " + failures.join(", ") + ")" : "")); |
| 100 | + } |
| 101 | + } |
| 102 | + |
| 103 | customElements.define("rpc-form", RpcForm); |
| 104 | + customElements.define("relay-lists", RelayLists); |
| 105 | customElements.define("signed-form", SignedForm); |
| 106 | customElements.define("publish-list", PublishList); |
| 107 | customElements.define("nostr-key", NostrKey); |
| 108 | diff --git a/internal/webui/public.html b/internal/webui/public.html |
| 109 | index e87a385..b75d99b 100644 |
| 110 | --- a/internal/webui/public.html |
| 111 | +++ b/internal/webui/public.html |
| 112 | @@ -11,6 +11,13 @@ |
| 113 | </table> |
| 114 | {{if .Policy.Description}}<h2>About</h2> |
| 115 | {{markdown .Policy.Description}} |
| 116 | +{{end}}{{if .Connections}}<h2>Connections</h2> |
| 117 | +<table id="rows"> |
| 118 | +<thead><tr><th>connection</th><th>relay</th><th>status</th></tr></thead> |
| 119 | +<tbody> |
| 120 | +{{range .Connections}}{{$c := asMap .}}<tr><td>{{or (str (index $c "label")) (str (index $c "title")) (str (index $c "name")) (str (index $c "template"))}}</td><td><code>{{or (str (index $c "url")) (str (index $c "href")) (str (index $c "relay"))}}</code></td><td>{{or (str (index $c "status")) (str (index $c "visibility")) "configured"}}</td></tr> |
| 121 | +{{end}}</tbody> |
| 122 | +</table> |
| 123 | {{end}}<h2>Recent activity</h2> |
| 124 | {{if .Error}}{{template "alert" .Error}}{{else}}{{template "eventList" .}}{{end}} |
| 125 | </section> |
| 126 | @@ -22,6 +29,19 @@ |
| 127 | {{if .Actor}}<p>Signed in as {{template "nostrKey" .Actor}}.</p> |
| 128 | <p><a href="/manage/people">Manage relay</a> | <a href="/tools">Browser tools</a> | <a href="/inbox">Inbox</a></p> |
| 129 | <p>Use the sign out control in the footer to end this session.</p> |
| 130 | +<h2>Your relay lists</h2> |
| 131 | +<p>Clients find your relays through these lists. Add this relay to each so they use it. What is already listed stays.</p> |
| 132 | +<relay-lists relay="{{wsURL .URL}}" server="{{.URL}}" pubkey="{{.Actor}}"> |
| 133 | +<table id="rows"> |
| 134 | +<tbody> |
| 135 | +<tr data-kind="10002" data-tag="r"><td>Relay list <small>NIP-65</small></td><td>where clients read your notes and send you mentions</td><td><button type="button" name="check">check</button> <button type="button" name="add">add this relay</button></td></tr> |
| 136 | +<tr data-kind="10050" data-tag="relay"><td>DM inbox <small>NIP-17</small></td><td>where people send you private messages</td><td><button type="button" name="check">check</button> <button type="button" name="add">add this relay</button></td></tr> |
| 137 | +<tr data-kind="10007" data-tag="relay"><td>Search relays <small>NIP-51</small></td><td>where clients run your searches</td><td><button type="button" name="check">check</button> <button type="button" name="add">add this relay</button></td></tr> |
| 138 | +<tr data-kind="10063" data-tag="server" data-value="server"><td>Blossom servers <small>BUD-03</small></td><td>where clients upload and look for your files</td><td><button type="button" name="check">check</button> <button type="button" name="add">add this relay</button></td></tr> |
| 139 | +</tbody> |
| 140 | +</table> |
| 141 | +<output role="status"></output> |
| 142 | +</relay-lists> |
| 143 | {{else}}<p>Use a browser extension, or connect Amber on your phone. The relay never sees a private key.</p> |
| 144 | {{template "signerControls" .}} |
| 145 | {{end}}</section> |
| 146 | diff --git a/internal/webui/shell_test.go b/internal/webui/shell_test.go |
| 147 | index 1d21ffc..4d571be 100644 |
| 148 | --- a/internal/webui/shell_test.go |
| 149 | +++ b/internal/webui/shell_test.go |
| 150 | @@ -308,3 +308,43 @@ func TestGuestsSeeSignInInsteadOfManagementPages(t *testing.T) { |
| 151 | } |
| 152 | } |
| 153 | } |
| 154 | + |
| 155 | +type connectionsBackend struct{ fakeBackend } |
| 156 | + |
| 157 | +func (b *connectionsBackend) Query(_ context.Context, method string, _ []json.RawMessage, _ string) (any, error) { |
| 158 | + if method == "listconnections" { |
| 159 | + return []any{map[string]any{"label": "Upstream", "url": "wss://upstream.example", "status": "syncing"}}, nil |
| 160 | + } |
| 161 | + return nil, nil |
| 162 | +} |
| 163 | + |
| 164 | +func TestAccountPageOffersRelayListsAndHomeShowsConnections(t *testing.T) { |
| 165 | + backend := &connectionsBackend{fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}} |
| 166 | + app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }}) |
| 167 | + if err != nil { |
| 168 | + t.Fatal(err) |
| 169 | + } |
| 170 | + recorder := httptest.NewRecorder() |
| 171 | + app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/signin", nil)) |
| 172 | + body := recorder.Body.String() |
| 173 | + for _, want := range []string{`<relay-lists relay="ws://relay.example" server="http://relay.example" pubkey="` + backend.policy.Owner + `">`, `data-kind="10002" data-tag="r"`, `data-kind="10050"`, `data-kind="10007"`, `data-kind="10063" data-tag="server"`, `customElements.define("relay-lists"`} { |
| 174 | + if !strings.Contains(body, want) { |
| 175 | + t.Errorf("account page missing %q", want) |
| 176 | + } |
| 177 | + } |
| 178 | + recorder = httptest.NewRecorder() |
| 179 | + app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil)) |
| 180 | + body = recorder.Body.String() |
| 181 | + if !strings.Contains(body, "<h2>Connections</h2>") || !strings.Contains(body, "wss://upstream.example") || !strings.Contains(body, "syncing") { |
| 182 | + t.Fatalf("home page did not show configured connections: %s", body[:min(400, len(body))]) |
| 183 | + } |
| 184 | + guest, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return "", nil }}) |
| 185 | + if err != nil { |
| 186 | + t.Fatal(err) |
| 187 | + } |
| 188 | + recorder = httptest.NewRecorder() |
| 189 | + guest.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil)) |
| 190 | + if strings.Contains(recorder.Body.String(), "<h2>Connections</h2>") { |
| 191 | + t.Fatal("guests must not see configured connections") |
| 192 | + } |
| 193 | +} |
| 194 | diff --git a/internal/webui/style.css b/internal/webui/style.css |
| 195 | index de95946..2031612 100644 |
| 196 | --- a/internal/webui/style.css |
| 197 | +++ b/internal/webui/style.css |
| 198 | @@ -110,6 +110,8 @@ rpc-form output,signed-form output,publish-list output{display:block;font:12px v |
| 199 | rpc-form output[data-error]{color:var(--alert)} |
| 200 | #content>section>h3{border-bottom:0;margin-top:22px;font-size:15px} |
| 201 | |
| 202 | +relay-lists{display:block}relay-lists #rows td{height:auto;padding:8px 10px;white-space:normal;vertical-align:middle}relay-lists #rows td:last-child{width:230px;text-align:left;color:var(--ink);white-space:nowrap}relay-lists #rows td:first-child{width:170px;color:var(--ink);font-family:var(--sans)}relay-lists output{display:block;font:12px var(--mono);color:var(--muted);white-space:normal}relay-lists button{font-size:12px} |
| 203 | + |
| 204 | /* json-view: structured rendering of any JSON payload. */ |
| 205 | json-view{display:block;font:12px var(--mono)} |
| 206 | json-view>details>summary{cursor:pointer;color:var(--muted)} |
| 207 | diff --git a/internal/webui/templates.go b/internal/webui/templates.go |
| 208 | index 974c59a..70d71b2 100644 |
| 209 | --- a/internal/webui/templates.go |
| 210 | +++ b/internal/webui/templates.go |
| 211 | @@ -82,6 +82,17 @@ func promptPath(path string, query url.Values) string { |
| 212 | return path |
| 213 | } |
| 214 | |
| 215 | +// wsURL turns the relay's public http(s) URL into its websocket form. |
| 216 | +func wsURL(base string) string { |
| 217 | + switch { |
| 218 | + case strings.HasPrefix(base, "https://"): |
| 219 | + return "wss://" + strings.TrimPrefix(base, "https://") |
| 220 | + case strings.HasPrefix(base, "http://"): |
| 221 | + return "ws://" + strings.TrimPrefix(base, "http://") |
| 222 | + } |
| 223 | + return base |
| 224 | +} |
| 225 | + |
| 226 | // short renders a Unix timestamp as a compact UTC stamp for list rows. |
| 227 | func short(value any) string { |
| 228 | seconds := unixSeconds(value) |
| 229 | @@ -125,6 +136,7 @@ func parseTemplates() (*template.Template, error) { |
| 230 | "markdown": renderMarkdown, |
| 231 | "hasPrefix": strings.HasPrefix, |
| 232 | "npub": identityNpub, |
| 233 | + "wsURL": wsURL, |
| 234 | "short": short, |
| 235 | "prompt": promptPath, |
| 236 | "railKind": railKind, |
| 237 | diff --git a/internal/webui/webui.go b/internal/webui/webui.go |
| 238 | index 6ab88ec..93afdce 100644 |
| 239 | --- a/internal/webui/webui.go |
| 240 | +++ b/internal/webui/webui.go |
| 241 | @@ -79,6 +79,9 @@ type PageData struct { |
| 242 | Path string |
| 243 | Readme template.HTML |
| 244 | Tree []any |
| 245 | + // Connections lists the relay's configured connections for signed-in |
| 246 | + // owners and moderators; it stays nil for everyone else. |
| 247 | + Connections []any |
| 248 | } |
| 249 | |
| 250 | func New(backend Backend, options Options) (*App, error) { |
| 251 | @@ -769,6 +772,11 @@ func (a *App) page(writer http.ResponseWriter, request *http.Request) { |
| 252 | data.Feed = feed |
| 253 | } |
| 254 | } |
| 255 | + if tab == "home" && actor != "" { |
| 256 | + if result, err := a.backend.Query(request.Context(), "listconnections", nil, actor); err == nil { |
| 257 | + data.Connections = browseRows(result) |
| 258 | + } |
| 259 | + } |
| 260 | if tab != "" { |
| 261 | data.Title = tab + " | " + a.backend.Slug() |
| 262 | } |