A connect-list element loads the configured cards with their catalog entry, lets the owner remove, reorder and retarget each one, and adds from the catalog. Every change saves the whole list with one signed call. The preview fragment, its endpoint and the separate list forms go; JSON editing stays behind a disclosure.
- commit
- bce2c385a5446e762a7bd6b19b6b98aa37b49f32
- parent
- 1654fa73cd1c46b6e6ce3fc6dcb92232f3926ea6
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 2 | +- |
| M | internal/ | 160 | ++++++++++++++++++++++++++++-- |
| M | internal/ | 14 | ++- |
| M | internal/ | 3 | +- |
| M | internal/ | 25 | ----- |
| M | internal/ | 28 | +++-- |
| M | scripts/ | 19 | +-- |
@@ -8,7 +8,7 @@ The vendored file's SHA-256 is:
88 b70458c212d0409f8a6319c286351865d92587292ad4f9bc0dfd1c46ffcad85599 ```1010 11-`/fixi.js` is served locally with no CDN dependency. The page loads it before the existing signer bundle. The Connect page uses Fixi's `fx-action` fragment request for a read-only connection preview; `/connect/fragment` renders server-escaped table HTML, while `/connect.json` remains a JSON API. Mutation forms remain handled by the existing signer bridge because every request body must be serialized once, hashed exactly, signed as NIP-98, and submitted once. Fixi is not allowed to issue those signed mutations or retry them.11+`/fixi.js` is served locally with no CDN dependency. The page loads it before the existing signer bundle. The Sync page uses Fixi's `fx-action` request for the job status stream, which renders server-escaped table HTML; `/connect.json` remains a JSON API for the configured connections. Mutation forms remain handled by the existing signer bridge because every request body must be serialized once, hashed exactly, signed as NIP-98, and submitted once. Fixi is not allowed to issue those signed mutations or retry them.1212 1313 The signer bridge handles NIP-07, NIP-46, Nostr Connect, Amber links, session login and management actions. Fixi updates page links and GET forms within the current tenant without a full reload. Filters, repository refs, browser history and page titles remain available. Downloads, external links and modified clicks retain normal browser behavior. A failed request displays an error in the footer and leaves the current page usable. Navigation and content also work without JavaScript, including on phones.1414 @@ -5,6 +5,9 @@
55 // <rpc-form method="…" [compose="…"] [action="…"] [terms="…"] [json-params]>66 // One signed NIP-86 management call. Children become the form body; the77 // element appends an <output> status line and a <json-view> result.8+// <connect-list>9+// The home page cards: lists, reorders, retargets and removes configured10+// connections and adds from the catalog, saving with setconnections.811 // <signed-form action="…" method="PUT|POST|GET" [download]>912 // A NIP-98 signed upload of one file, or a signed download by name.1013 // <publish-list kind="…" tag="…" [scheme="ws"]>@@ -84,6 +87,24 @@
8487 }]8588 };8689 90+ // rpc makes one signed management call: the body is serialised once,91+ // signed as NIP-98, and sent once. Returns the result or throws the error.92+ const rpc = async (method, params = [], action) => {93+ const body = JSON.stringify({method, params});94+ const endpoint = tiny.localPath(action || "/manage/rpc");95+ const url = new URL(endpoint, location.href).href;96+ const response = await fetch(endpoint, {97+ method: "POST",98+ headers: {"content-type": "application/json", authorization: await tiny.authorization(url, "POST", new TextEncoder().encode(body))},99+ body100+ });101+ const text = await response.text();102+ let payload;103+ try { payload = JSON.parse(text); } catch { payload = undefined; }104+ if (!response.ok || payload?.error) throw Error(payload?.error || text || "Request failed (" + response.status + ")");105+ return payload && "result" in payload ? payload.result : payload ?? text;106+ };107+87108 class RpcForm extends FormElement {88109 params(form) {89110 const compose = composers[this.getAttribute("compose")];@@ -105,20 +126,8 @@
105126 const terms = this.getAttribute("terms");106127 if (terms) params = [params[0], await tiny.sha256hex(new TextEncoder().encode(terms))];107128 }108- const body = JSON.stringify({method, params});109129 this.report("Signing…");110- const endpoint = tiny.localPath(this.getAttribute("action")||"/manage/rpc");111- const url = new URL(endpoint, location.href).href;112- const response = await fetch(endpoint, {113- method: "POST",114- headers: {"content-type": "application/json", authorization: await tiny.authorization(url, "POST", new TextEncoder().encode(body))},115- body116- });117- const text = await response.text();118- let payload;119- try { payload = JSON.parse(text); } catch { payload = undefined; }120- if (!response.ok || payload?.error) throw Error(payload?.error || text || "Request failed (" + response.status + ")");121- const result = payload && "result" in payload ? payload.result : payload ?? text;130+ const result = await rpc(method, params, this.getAttribute("action"));122131 this.report("Done.");123132 this.show(result);124133 }@@ -390,6 +399,130 @@
390399 }391400 }392401 402+ // ConnectList edits the cards on the home page: the configured connections403+ // in order, each with its audience, plus add from the catalog. Every change404+ // saves the whole list with one signed call.405+ class ConnectList extends HTMLElement {406+ connectedCallback() {407+ if (this.bound) return;408+ this.bound = true;409+ this.body = this.querySelector("tbody");410+ this.select = this.querySelector("select[name=template]");411+ this.output = this.querySelector("output");412+ this.rows = [];413+ this.catalog = [];414+ this.addEventListener("click", event => {415+ const button = event.target.closest("button[name]");416+ if (!button) return;417+ button.disabled = true;418+ this.change(button.name, button.closest("tr")).catch(err => this.say("error: " + err.message, true)).finally(() => { button.disabled = false; });419+ });420+ this.addEventListener("change", event => {421+ const select = event.target.closest("select[name=visibility]");422+ if (select) this.change("visibility", select.closest("tr"), select.value).catch(err => this.say("error: " + err.message, true));423+ });424+ this.whenSigned(() => this.load().catch(err => this.say("error: " + err.message, true)));425+ }426+427+ // whenSigned waits briefly for a signer, since a remote one resumes async.428+ whenSigned(fn, tries = 20) {429+ if (window.nostr?.signEvent) return fn();430+ if (tries <= 0) { this.say("connect a signer, then reload this page"); return; }431+ setTimeout(() => this.whenSigned(fn, tries - 1), 500);432+ }433+434+ say(text, error) {435+ this.output.textContent = text;436+ if (error) this.output.dataset.error = "";437+ else delete this.output.dataset.error;438+ }439+440+ async load() {441+ this.say("loading…");442+ [this.rows, this.catalog] = await Promise.all([rpc("listconnections"), rpc("listconnectiontemplates")]);443+ this.render();444+ this.say("");445+ }446+447+ entry(name) { return this.catalog.find(c => c.name === name) || {name, title: name, visibility: "public", available: true}; }448+449+ render() {450+ const audiences = ["public", "members", "owner"];451+ const rows = this.rows.map((row, index) => {452+ const name = row.template || row.name;453+ const c = this.entry(name);454+ const tr = el("tr");455+ tr.dataset.index = index;456+ tr.dataset.template = name;457+ const app = el("td");458+ if (c.app) app.append(el("b", c.app), " ");459+ app.append(c.about || "");460+ if (c.available === false) app.append(" ", el("small", "(feature off, hidden)"));461+ const audience = el("select");462+ audience.name = "visibility";463+ const current = row.visibility === "auth" || row.visibility === "member" ? "members" : row.visibility || c.visibility;464+ for (const value of audiences.includes(current) ? audiences : [current, ...audiences]) {465+ const option = el("option", value);466+ option.value = value;467+ option.selected = value === current;468+ audience.append(option);469+ }470+ const actions = el("td");471+ for (const [label, action] of [["up", "up"], ["down", "down"], ["remove", "remove"]]) {472+ const button = el("button", label);473+ button.type = "button";474+ button.name = action;475+ if ((action === "up" && index === 0) || (action === "down" && index === this.rows.length - 1)) button.disabled = true;476+ actions.append(button, " ");477+ }478+ const cell = el("td");479+ cell.append(audience);480+ tr.append(el("td", c.title || name), app, cell, actions);481+ return tr;482+ });483+ if (!rows.length) {484+ const empty = el("tr");485+ const cell = el("td", "No cards. Add one from the catalog below.");486+ cell.colSpan = 4;487+ empty.append(cell);488+ rows.push(empty);489+ }490+ this.body.replaceChildren(...rows);491+ const used = new Set(this.rows.map(row => row.template || row.name));492+ const options = [el("option", "choose one")];493+ options[0].value = "";494+ for (const c of this.catalog) {495+ if (used.has(c.name)) continue;496+ const option = el("option", c.title + (c.app ? " (" + c.app + ")" : "") + (c.available === false ? ", feature off" : ""));497+ option.value = c.name;498+ options.push(option);499+ }500+ this.select.replaceChildren(...options);501+ }502+503+ async change(action, row, value) {504+ const index = Number(row?.dataset.index);505+ const next = this.rows.map(entry => ({...entry}));506+ switch (action) {507+ case "remove": next.splice(index, 1); break;508+ case "up": if (index > 0) [next[index - 1], next[index]] = [next[index], next[index - 1]]; break;509+ case "down": if (index < next.length - 1) [next[index + 1], next[index]] = [next[index], next[index + 1]]; break;510+ case "visibility": next[index].visibility = value; break;511+ case "add": {512+ const name = this.select.value;513+ if (!name) { this.say("choose a card to add"); return; }514+ next.push({template: name, visibility: this.entry(name).visibility || "public"});515+ break;516+ }517+ default: return;518+ }519+ this.say("saving…");520+ this.rows = await rpc("setconnections", [next]);521+ this.render();522+ this.say("saved");523+ }524+ }525+393526 // ConnectCard copies a command or address, filling {input:name} from the394527 // card's inputs, so a clone command carries the repository name typed.395528 class ConnectCard extends HTMLElement {@@ -413,6 +546,7 @@
413546 414547 customElements.define("rpc-form", RpcForm);415548 customElements.define("connect-card", ConnectCard);549+ customElements.define("connect-list", ConnectList);416550 customElements.define("relay-lists", RelayLists);417551 customElements.define("signed-form", SignedForm);418552 customElements.define("publish-list", PublishList);@@ -101,11 +101,15 @@
101101 <h1>Connect</h1>102102 {{if .Private}}<p>This private relay requires current membership. Connect a signer to sign in.</p>103103 {{end}}<h3>Connections</h3>104-<p><button fx-action="/connect/fragment" fx-method="get" fx-trigger="click" fx-target="#connections-preview" fx-swap="morph">Preview connections</button></p>105-<scroll-box><table id="connections-preview" aria-live="polite"><tbody><tr><td>No preview loaded.</td></tr></tbody></table></scroll-box>106-<rpc-form method="listconnections"><button>List connections</button></rpc-form>107-<rpc-form method="setconnections"><label>JSON connections <textarea name="param">[]</textarea></label><button>Save connections</button></rpc-form>108-<rpc-form method="listconnectiontemplates"><button>List templates</button></rpc-form>104+<p>The cards on the home page, in order. Remove what you will not use; add more from the catalog. Cards whose feature is off stay hidden.</p>105+<connect-list>106+<table id="rows"><thead><tr><th>card</th><th>app</th><th>audience</th><th></th></tr></thead><tbody><tr><td colspan="4">Loading…</td></tr></tbody></table>107+<p><label>Add a card <select name="template"><option value="">choose one</option></select></label> <button type="button" name="add">add</button></p>108+<output role="status"></output>109+</connect-list>110+<details><summary>Edit as JSON</summary>111+<rpc-form method="setconnections"><label>Connections <textarea name="param">[]</textarea></label><button>Save connections</button></rpc-form>112+</details>109113 <h3>Publish signed relay records</h3>110114 <p>These actions open the configured signer and publish owner-authored Nostr records.</p>111115 <publish-list kind="10317" tag="g" scheme="ws">@@ -111,6 +111,7 @@ rpc-form output,signed-form output,publish-list output{display:block;font:12px v
111111 rpc-form output[data-error]{color:var(--alert)}112112 #content>section>h3{border-bottom:0;margin-top:22px;font-size:15px}113113 114+connect-list{display:block}connect-list #rows td{height:auto;padding:8px 10px;white-space:normal;vertical-align:middle}connect-list #rows td:first-child{width:150px}connect-list #rows td:nth-child(2){color:var(--muted)}connect-list #rows td:nth-child(2) b{color:var(--ink);font-weight:normal}connect-list #rows td:nth-child(3){width:110px}connect-list #rows td:last-child{width:190px;white-space:nowrap}connect-list button,connect-list select{font-size:12px}connect-list output{display:block;font:12px var(--mono);color:var(--muted);margin-top:4px}connect-list output[data-error]{color:var(--alert)}114115 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}115116 116117 connect-card{display:block;border-bottom:1px solid var(--line);padding:10px 0;max-width:720px}connect-card h3{margin:0 0 4px;border:0;font-size:15px}connect-card h3 small{font:12px var(--mono);color:var(--muted)}connect-card p{margin:0 0 6px;color:var(--muted)}connect-card label{display:inline-block;font:13px var(--sans);color:var(--muted);margin:0 10px 6px 0}connect-card label input{display:block;font:13px var(--mono);min-width:200px}connect-card a,connect-card button{margin-right:10px;font-size:13px}connect-card output{display:block;font:12px var(--mono);color:var(--muted);overflow-wrap:anywhere}connect-card details img{display:block;margin:6px 0;background:#fff;padding:6px;border:1px solid var(--line)}@@ -194,6 +195,6 @@ html,body{overflow:auto}
194195 #navigation-status:not(:empty),#session-status:not(:empty){position:absolute;bottom:100%;left:0;right:0;padding:6px 12px;background:var(--rail);border-top:1px solid var(--line);white-space:normal}195196 #split,#split>tbody,#split>tbody>tr,#split>tbody>tr>td{display:block;width:auto}196197 #tree{border-right:0;border-bottom:1px solid var(--line)}197-#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}198+#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}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}198199 #grid,#gridhead,h2:has(+#gridhead){display:none}199200 }@@ -167,10 +167,6 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
167167 a.connectJSON(writer, request)168168 return169169 }170- if request.URL.Path == "/connect/fragment" {171- a.connectFragment(writer, request)172- return173- }174170 if strings.HasPrefix(request.URL.Path, "/e/") || strings.HasPrefix(request.URL.Path, "/a/") {175171 if !a.pageFeatureEnabled(request.URL.Path) {176172 http.NotFound(writer, request)@@ -404,27 +400,6 @@ func (a *App) connectJSON(writer http.ResponseWriter, request *http.Request) {
404400 writeJSON(writer, http.StatusOK, result)405401 }406402 407-func (a *App) connectFragment(writer http.ResponseWriter, request *http.Request) {408- actor, actorErr := a.resolveActor(request)409- if actorErr != nil || actor == "" {410- http.Error(writer, "authentication required", http.StatusUnauthorized)411- return412- }413- result, err := a.backend.Query(request.Context(), "listconnections", nil, actor)414- if err != nil {415- http.Error(writer, "connections unavailable", http.StatusNotFound)416- return417- }418- rows := make([][]string, 0)419- for _, row := range browseRows(result) {420- values, _ := row.(map[string]any)421- rows = append(rows, []string{plainString(firstValue(values, "label", "title", "name", "template")), plainString(firstValue(values, "url", "href", "relay")), plainString(firstValue(values, "status", "visibility"))})422- }423- fragment := `<table id="connections-preview" aria-live="polite">` + tableRows([]string{"Label", "Relay", "Status"}, rows) + `</table>`424- writer.Header().Set("content-type", "text/html; charset=utf-8")425- _, _ = writer.Write([]byte(fragment))426-}427-428403 func (a *App) eventPage(writer http.ResponseWriter, request *http.Request) {429404 actor, actorErr := a.resolveActor(request)430405 if actorErr != nil {@@ -313,16 +313,24 @@ func TestJobStatusRequiresActorAndEmitsHTMLSSE(t *testing.T) {
313313 }314314 }315315 316-func TestConnectionFragmentEscapesValues(t *testing.T) {316+func TestConnectPageEditsConnectionsAsList(t *testing.T) {317317 backend := &fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}318- app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return "owner", nil }})318+ app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }})319319 if err != nil {320320 t.Fatal(err)321321 }322322 recorder := httptest.NewRecorder()323- app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/connect/fragment", nil))324- if recorder.Code != http.StatusOK || !strings.Contains(recorder.Header().Get("content-type"), "text/html") || !strings.Contains(recorder.Body.String(), "connections-preview") {325- t.Fatalf("invalid connection fragment: status=%d body=%s", recorder.Code, recorder.Body.String())323+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/connect", nil))324+ body := recorder.Body.String()325+ for _, want := range []string{"<connect-list>", `<select name="template">`, `<button type="button" name="add">`, `method="setconnections"`} {326+ if !strings.Contains(body, want) {327+ t.Errorf("connect page missing %q", want)328+ }329+ }330+ for _, gone := range []string{"connect/fragment", "Preview connections", `method="listconnections"`} {331+ if strings.Contains(body, gone) {332+ t.Errorf("connect page still carries %q", gone)333+ }326334 }327335 }328336 @@ -333,9 +341,9 @@ func TestTenantPrefixRewritesUIEndpoints(t *testing.T) {
333341 t.Fatal(err)334342 }335343 recorder := httptest.NewRecorder()336- app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/r/alice/manage/connect", nil))344+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/r/alice/manage/sync", nil))337345 body := recorder.Body.String()338- for _, want := range []string{`src="/r/alice/fixi.js"`, `src="/r/alice/signer.js"`, `fx-action="/r/alice/connect/fragment"`, `localPath(this.getAttribute("action")||"/manage/rpc")`, `signedSession("/session")`, `replace(/^http/,"ws")+root`} {346+ for _, want := range []string{`src="/r/alice/fixi.js"`, `src="/r/alice/signer.js"`, `fx-action="/r/alice/manage/jobs/status"`, `localPath(action || "/manage/rpc")`, `signedSession("/session")`, `replace(/^http/,"ws")+root`} {339347 if !strings.Contains(body, want) {340348 t.Errorf("tenant prefix missing %q", want)341349 }@@ -348,11 +356,11 @@ func TestTenantPrefixHeaderSurvivesDaemonPathStripping(t *testing.T) {
348356 if err != nil {349357 t.Fatal(err)350358 }351- request := httptest.NewRequest(http.MethodGet, "/manage/connect", nil)352- request.RequestURI = "/r/alice/manage/connect"359+ request := httptest.NewRequest(http.MethodGet, "/manage/sync", nil)360+ request.RequestURI = "/r/alice/manage/sync"353361 recorder := httptest.NewRecorder()354362 app.ServeHTTP(recorder, request)355- if !strings.Contains(recorder.Body.String(), `fx-action="/r/alice/connect/fragment"`) {363+ if !strings.Contains(recorder.Body.String(), `fx-action="/r/alice/manage/jobs/status"`) {356364 t.Fatal("daemon prefix header was not applied")357365 }358366 }@@ -59,29 +59,24 @@ async page => {
5959 check(current.length > 0 && current.every(x => x.form && x.output && !x.nested), 'detached or nested forms: '+JSON.stringify(current));6060 }6161 await page.waitForFunction(() => !!window.nostr?.signEvent);62- const list = page.locator('rpc-form[method=listconnections]');62+ await page.waitForFunction(() => document.querySelector('connect-list #rows tbody tr[data-index]') !== null);63+ check(await page.locator('connect-list select[name=template] option').count() > 1, 'connect list offers no catalog cards');64+ await clickNav('/people');65+ const list = page.locator('rpc-form[method=listmembers]');6366 let submissions = 0;6467 const count = request => { if (request.method()==='POST' && request.url().endsWith('/manage/rpc')) submissions++; };6568 page.on('request', count);6669 try {6770 await list.evaluate(node => { node.form.requestSubmit(); node.form.requestSubmit(); });68- await page.waitForFunction(() => document.querySelector('rpc-form[method=listconnections] output')?.textContent === 'Done.');71+ await page.waitForFunction(() => document.querySelector('rpc-form[method=listmembers] output')?.textContent === 'Done.');6972 check(submissions===1, 'duplicate form submissions sent '+submissions+' signed requests');7073 } finally { page.off('request',count); }71- await page.getByRole('button', {name:'Preview connections',exact:true}).click();72- await page.waitForFunction(() => document.querySelector('#connections-preview thead') !== null);74+ await clickNav('/connect');75+ await page.waitForFunction(() => document.querySelector('connect-list #rows tbody tr[data-index]') !== null);7376 const result = await forms();7477 await page.screenshot({path:'output/playwright/ux/connect-desktop.png'});7578 return result;7679 });77- await run('refused previews show an error without destroying the table', async () => {78- await page.evaluate(async () => { await fetch(window.tiny.localPath('/session/logout'), {method:'POST'}); });79- try {80- await page.getByRole('button', {name:'Preview connections',exact:true}).click();81- await page.waitForFunction(() => document.querySelector('#navigation-status')?.textContent.includes('Sign in again'));82- check(await page.locator('#connections-preview').count()===1, 'refused preview removed table');83- } finally { await page.evaluate(async () => { await window.tiny.signedFetch('/session','POST',''); }); }84- });8580 await run('job status stream closes when leaving the page', async () => {8681 await clickNav('/sync');8782 await page.locator('#watch-jobs').click();