Mark one primary action per view and destructive actions as danger, show access, state and agent markers as badges, label rail groups, render errors as sentences, hide forms guests and restricted keys cannot use, and drop explanatory paragraphs across the pages.
- commit
- d9651de022c4c2283f82eb390a00bde09f477cc8
- parent
- 660e78b96c421647fa7ebe3a6cf06ad10e88a789
- author
- Dami <dami.etoile@gmail.com>
| M | tinyclient/ | 12 | ++++---- |
| M | tinyclient/ | 8 | +++--- |
| M | tinyclient/ | 19 | +++++++------- |
| M | tinyclient/ | 2 | +- |
| M | tinyclient/ | 14 | ++++------ |
| M | tinyclient/ | 2 | +- |
| M | tinyclient/ | 41 | +++++++++++++++++------------- |
| M | tinyclient/ | 41 | +++++++++++++++--------------- |
| M | tinyclient/ | 2 | +- |
| M | tinyclient/ | 26 | +++++++------------ |
| M | tinyclient/ | 8 | +++--- |
| M | tinyclient/ | 10 | ++++---- |
| M | tinyclient/ | 5 | +-- |
| M | tinyclient/ | 2 | +- |
| M | tinyclient/ | 2 | +- |
@@ -96,9 +96,9 @@ func TestAgentsPageRendersTableCardsActivityAndGrantForm(t *testing.T) {
9696 `<tr><th>sites</th><td><code>` + agentSite + `</code> 30 days encrypted<br><code>*</code></td></tr>`,9797 `<tr><th>sites</th><td>none</td></tr>`,9898 `<textarea name="sites" placeholder="npub1... ttl=30 encrypted * ttl=7"></textarea>`,99- `<td data-state="active">active | `,100- `<td data-state="paused">paused</td>`,101- `<td data-state="revoked">revoked | `,99+ `<td data-state="active"><status-badge kind="active">active</status-badge> `,100+ `<td data-state="paused"><status-badge kind="paused">paused</status-badge></td>`,101+ `<td data-state="revoked"><status-badge kind="revoked">revoked</status-badge> `,102102 `<a href="#agent-` + agentRevoked + `">` + shortID(agentRevoked) + `</a>`,103103 `<agent-card id="agent-` + agentActive + `">`,104104 `<h3>hermes <small><span data-state="active">active</span> | signed by you | expires `,@@ -107,7 +107,7 @@ func TestAgentsPageRendersTableCardsActivityAndGrantForm(t *testing.T) {
107107 `<textarea name="repos" placeholder="owner pubkey:identifier:propose owner pubkey:identifier:read owner pubkey:identifier:maintain">`,108108 `<th>rate</th><td>60 events per minute</td>`,109109 `<rpc-form method="pauseagent" refresh><input type="hidden" name="param" value=""` + agentActive + `""><button>Pause</button></rpc-form>`,110- `<rpc-form method="revokeagent" refresh><input type="hidden" name="param" value=""` + agentActive + `""><button>Revoke</button></rpc-form>`,110+ `<rpc-form method="revokeagent" refresh><input type="hidden" name="param" value=""` + agentActive + `""><button data-danger>Revoke</button></rpc-form>`,111111 `<rpc-form method="resumeagent" refresh><input type="hidden" name="param" value=""` + agentPaused + `""><button>Resume</button></rpc-form>`,112112 `<h3>Recent activity of hermes</h3>`,113113 `<ol id="events">`,@@ -123,7 +123,7 @@ func TestAgentsPageRendersTableCardsActivityAndGrantForm(t *testing.T) {
123123 `<agent-callback data-state="paused"><code>worker.example:8443</code> kinds 9 | <span data-state="paused" title="paused after 20 failures: HTTP 500">paused</span> <rpc-form method="resumecallback" refresh><input type="hidden" name="param" value=""cb-paused""><button>Resume</button></rpc-form>`,124124 `<tr><th>callbacks</th><td>none</td></tr>`,125125 `<code>/mcp</code> 2026-07-28`,126- `<rpc-form method="pauseallagents" refresh><button>Pause all agents</button></rpc-form>`,126+ `<rpc-form method="pauseallagents" refresh><button data-danger>Pause all agents</button></rpc-form>`,127127 `<rpc-form method="resumeallagents" refresh><button>Resume all agents</button></rpc-form>`,128128 } {129129 if !strings.Contains(body, want) {@@ -247,7 +247,7 @@ func TestAgentsPageEditPrefillsTheGrantForm(t *testing.T) {
247247 `<textarea name="sites" placeholder="npub1... ttl=30 encrypted * ttl=7">` + agentSite + ` ttl=30 encrypted248248 *</textarea>`,249249 `placeholder="1, 1111, 1621" value="9, 1111, 1621">`,250- `<button>Sign the replacement</button>`,250+ `<button data-primary>Sign the replacement</button>`,251251 `&edit=` + agentActive + `#grant">edit</a>`,252252 } {253253 if !strings.Contains(body, marker) {@@ -65,12 +65,12 @@ func TestApprovalsPageRendersRequestsForTheOwner(t *testing.T) {
6565 `<nostr-name pubkey="` + asker + `"`, "in repository tinyrelay", "expires <time",6666 "<p>Publish release notes 1.4</p>", "<p>Publish release notes 1.4 to the articles feed as drafted?</p>",6767 "<pre>about 30617:" + backend.policy.Owner + ":tinyrelay | kind 1621 dddddddddddd</pre>",68- `<nostr-react event="` + strings.Repeat("1", 64) + `" pubkey="` + asker + `" kind="1111"><button name="reaction" value="+">Approve</button> <button name="reaction" value="-">Deny</button></nostr-react>`,68+ `<nostr-react event="` + strings.Repeat("1", 64) + `" pubkey="` + asker + `" kind="1111"><button name="reaction" value="+" data-primary>Approve</button> <button name="reaction" value="-" data-danger>Deny</button></nostr-react>`,6969 `<nostr-compose kind="1111" root="` + strings.Repeat("1", 64) + `" root-pubkey="` + asker + `" root-kind="1111" coordinate="30617:` + backend.policy.Owner + `:tinyrelay">`,7070 `<a href="/repo?owner=` + backend.policy.Owner + `&repo=tinyrelay&view=issue&id=` + strings.Repeat("d", 64) + `">open</a>`,7171 `<approval-item id="approval-` + strings.Repeat("2", 64) + `" data-type="question"`, "in #build", `<a href="/e/` + strings.Repeat("2", 64) + `">open</a>`,72- "<h3>Answered</h3>", `<tr id="approval-` + strings.Repeat("3", 64) + `" data-state="answered">`, "approved | <time", `data-state="expired"><td>`, "<td>expired</td>",73- "<h4>Waiting</h4>", "<th>open</th><td>2</td>", "<th>oldest</th><td><time", "<h4>Devices</h4>", "<p><i></i>device 1 | approvals, mentions</p>", "Answers from the phone need the remembered signer.",72+ "<h3>Answered</h3>", `<tr id="approval-` + strings.Repeat("3", 64) + `" data-state="answered">`, `<status-badge kind="approved">approved</status-badge> <time`, `data-state="expired"><td>`, `<td><status-badge kind="expired">expired</status-badge></td>`,73+ "<h4>Waiting</h4>", "<th>open</th><td>2</td>", "<th>oldest</th><td><time", "<h4>Devices</h4>", "<p><i></i>device 1 | approvals, mentions</p>", "<p><i></i>device 1 | ",7474 `<a href="/approvals" aria-current="page">/approvals</a>`,7575 } {7676 if !strings.Contains(body, marker) {@@ -134,7 +134,7 @@ func TestApprovalsPageAsksGuestsToSignIn(t *testing.T) {
134134 if strings.Contains(body, "<approval-item") || strings.Contains(body, "auth-required") || strings.Contains(body, "role=\"alert\"") {135135 t.Fatalf("guest page leaks requests or the error:\n%s", body)136136 }137- if !strings.Contains(body, "Sign in to see which devices are notified.") || !strings.Contains(body, "<th>open</th><td>0</td>") {137+ if !strings.Contains(body, "Sign in to see which devices are notified.") || strings.Contains(body, "<h4>Waiting</h4>") {138138 t.Fatalf("guest panel:\n%s", body)139139 }140140 }@@ -4,7 +4,7 @@
44 {{else if .Feed}}<table id="rows">55 <thead><tr><th>owner</th><th>repository</th><th>head</th></tr></thead>66 <tbody>7-{{range .Feed}}{{$r := asMap .}}<tr><td>{{template "nostrKey" (str (index $r "owner"))}}</td><td><a href="/repo?owner={{urlquery (str (index $r "owner"))}}&repo={{urlquery (str (index $r "identifier"))}}&view=home">{{str (index $r "identifier")}}</a>{{if index $r "private"}} <small>private</small>{{end}}</td><td>{{shortID (index $r "head")}}</td></tr>7+{{range .Feed}}{{$r := asMap .}}<tr><td>{{template "nostrKey" (str (index $r "owner"))}}</td><td><a href="/repo?owner={{urlquery (str (index $r "owner"))}}&repo={{urlquery (str (index $r "identifier"))}}&view=home">{{str (index $r "identifier")}}</a>{{if index $r "private"}} <small>private</small>{{end}}</td><td>{{refName (index $r "head")}}</td></tr>88 {{end}}</tbody>99 </table>1010 {{else}}<p>No repositories were reported.</p>@@ -14,9 +14,9 @@
1414 1515 {{define "files"}}{{$data := asMap .Event}}{{$view := or (str (index $data "view")) (.Query.Get "view") "library"}}<section id="file-library" data-pubkey="{{.Actor}}">1616 <h1>Files</h1>17-<nav id="file-views" aria-label="File collections"><a href="/files"{{if eq $view "library"}} aria-current="page"{{end}}>My files</a><a href="/files?view=sites"{{if eq $view "sites"}} aria-current="page"{{end}}>Sites</a><a href="/files?view=rooms"{{if eq $view "rooms"}} aria-current="page"{{end}}>Rooms</a>{{if index $data "can_manage"}}<a href="/files?view=storage"{{if eq $view "storage"}} aria-current="page"{{end}}>Storage</a>{{end}}</nav>18-<p id="file-context">{{if eq $view "sites"}}Published assets, organized by site.{{else if eq $view "rooms"}}Attachments from rooms you can access.{{else if eq $view "storage"}}All stored files, newest first.{{else}}Your uploads, folders and files shared with relay members.{{end}}</p>19-<form method="get" action="/files" id="file-search"><input type="hidden" name="view" value="{{$view}}">{{with .Query.Get "path"}}<input type="hidden" name="path" value="{{.}}">{{end}}<label>Search <input name="q" value="{{.Query.Get "q"}}" placeholder="Name, type or hash" autocomplete="off"></label> <button>Search</button>{{if .Query.Get "q"}} <a href="{{browsePageURL "/files" .Query "q" ""}}">Clear</a>{{end}}</form>17+{{if not (authRequired .Error)}}<nav id="file-views" aria-label="File collections"><a href="/files"{{if eq $view "library"}} aria-current="page"{{end}}>My files</a><a href="/files?view=sites"{{if eq $view "sites"}} aria-current="page"{{end}}>Sites</a><a href="/files?view=rooms"{{if eq $view "rooms"}} aria-current="page"{{end}}>Rooms</a>{{if index $data "can_manage"}}<a href="/files?view=storage"{{if eq $view "storage"}} aria-current="page"{{end}}>Storage</a>{{end}}</nav>18+<p id="file-context">{{if eq $view "sites"}}Published assets, organized by site.{{else if eq $view "rooms"}}Attachments from rooms you can access.{{else if eq $view "storage"}}All stored files, newest first.{{end}}</p>19+<form method="get" action="/files" id="file-search"><input type="hidden" name="view" value="{{$view}}">{{with .Query.Get "path"}}<input type="hidden" name="path" value="{{.}}">{{end}}<label>Search <input name="q" value="{{.Query.Get "q"}}" placeholder="Name, type or hash" autocomplete="off"></label> <button>Search</button>{{if .Query.Get "q"}} <a href="{{browsePageURL "/files" .Query "q" ""}}">Clear</a>{{end}}</form>{{end}}2020 {{with index $data "breadcrumbs"}}<nav id="file-breadcrumbs" aria-label="Folder path">{{range $i, $raw := .}}{{$crumb := asMap $raw}}{{if $i}} / {{end}}<a href="{{str (index $crumb "open")}}">{{str (index $crumb "name")}}</a>{{end}}</nav>{{end}}2121 {{if .Error}}{{template "alert" .Error}}2222 {{else if .Feed}}<table id="rows">@@ -50,26 +50,25 @@
5050 <tr><th>size</th><td>{{fileSize (index $file "size")}}</td></tr>5151 {{with index $file "blossom_uri"}}<tr><th>blossom</th><td><code>{{.}}</code></td></tr>{{end}}5252 <tr><th>download</th><td><a fx-ignore href="/files/raw?hash={{urlquery (str (index $file "sha256"))}}">raw</a></td></tr>53-</table></details>{{if .Actor}}<details id="manage-file"><summary>Manage file</summary><rpc-form method="deleteblob"><input type="hidden" name="param" value="{{$hash}}"><button>Delete file</button></rpc-form></details>{{end}}</section>53+</table></details>{{if .Actor}}<details id="manage-file"><summary>Manage file</summary><rpc-form method="deleteblob"><input type="hidden" name="param" value="{{$hash}}"><button data-danger>Delete file</button></rpc-form></details>{{end}}</section>5454 {{end}}{{end}}5555 5656 {{define "approvals"}}<section>5757 <h1>Approvals</h1>58-<p>Requests that wait for your decision. Review requests, answer questions and approve proposed changes.</p>5958 {{if not .Actor}}<p><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to see the requests addressed to your key.</p>6059 {{else if .Error}}{{template "alert" .Error}}6160 {{else}}{{$open := approvalViews .Event .URL "open"}}{{$done := approvalViews .Event .URL "answered" "expired"}}{{if $open}}{{range $item := $open}}<approval-item id="approval-{{$item.ID}}" data-type="{{$item.Type}}" data-state="{{$item.State}}">6261 <header>{{template "nostrKey" $item.Asker}} in {{$item.Where}} | <time datetime="{{datetime $item.CreatedAt}}">{{when $item.CreatedAt}}</time>{{if $item.Expires}} | expires <time datetime="{{datetime $item.Expires}}">{{when $item.Expires}}</time>{{end}}</header>6362 <p>{{$item.Title}}</p>{{with $item.Body}}<p>{{.}}</p>{{end}}{{with $item.About}}<pre>{{.}}</pre>{{end}}64-{{with $item.Grant}}<agent-grant-request request="{{$item.ID}}" data-review="{{.Raw}}"{{if .Error}} data-stale="true"{{end}}><h4>Proposed grant change</h4><table><thead><tr><th>scope</th><th>current</th><th>proposed</th></tr></thead><tbody>{{range .Rows}}{{if ne .Before .After}}<tr><th>{{.Label}}</th><td>{{.Before}}</td><td>{{.After}}</td></tr>{{end}}{{end}}</tbody></table>{{with .Error}}<p role="alert">{{.}}</p>{{else}}<button type="button" data-grant-approve disabled>Approve and grant</button>{{end}}<button type="button" data-grant-deny disabled>Deny</button></agent-grant-request>{{end}}65-<footer>{{if and (ne $item.Type "question") (ne $item.Type "grant")}}<nostr-react event="{{$item.ID}}" pubkey="{{$item.Asker}}" kind="{{$item.Kind}}"><button name="reaction" value="+">{{if eq $item.Type "wiki-proposal"}}Accept{{else if eq $item.Type "wiki-merge"}}Accept{{else}}Approve{{end}}</button> <button name="reaction" value="-">{{if eq $item.Type "wiki-proposal"}}Reject{{else if eq $item.Type "wiki-merge"}}Reject{{else}}Deny{{end}}</button></nostr-react> {{end}}<details><summary>Reply</summary><nostr-compose kind="1111" root="{{$item.ID}}" root-pubkey="{{$item.Asker}}" root-kind="{{$item.Kind}}"{{with $item.Coordinate}} coordinate="{{.}}"{{end}}><label>Reply <textarea name="content" required></textarea></label><button>Sign and reply</button></nostr-compose></details> <a href="{{$item.Open}}">open</a></footer>63+{{with $item.Grant}}<agent-grant-request request="{{$item.ID}}" data-review="{{.Raw}}"{{if .Error}} data-stale="true"{{end}}><h4>Proposed grant change</h4><table><thead><tr><th>scope</th><th>current</th><th>proposed</th></tr></thead><tbody>{{range .Rows}}{{if ne .Before .After}}<tr><th>{{.Label}}</th><td>{{.Before}}</td><td>{{.After}}</td></tr>{{end}}{{end}}</tbody></table>{{with .Error}}<p role="alert">{{.}}</p>{{else}}<button type="button" data-grant-approve data-primary disabled>Approve and grant</button>{{end}}<button type="button" data-grant-deny data-danger disabled>Deny</button></agent-grant-request>{{end}}64+<footer>{{if and (ne $item.Type "question") (ne $item.Type "grant")}}<nostr-react event="{{$item.ID}}" pubkey="{{$item.Asker}}" kind="{{$item.Kind}}"><button name="reaction" value="+" data-primary>{{if eq $item.Type "wiki-proposal"}}Accept{{else if eq $item.Type "wiki-merge"}}Accept{{else}}Approve{{end}}</button> <button name="reaction" value="-" data-danger>{{if eq $item.Type "wiki-proposal"}}Reject{{else if eq $item.Type "wiki-merge"}}Reject{{else}}Deny{{end}}</button></nostr-react> {{end}}<details><summary>Reply</summary><nostr-compose kind="1111" root="{{$item.ID}}" root-pubkey="{{$item.Asker}}" root-kind="{{$item.Kind}}"{{with $item.Coordinate}} coordinate="{{.}}"{{end}}><label>Reply <textarea name="content" required></textarea></label><button>Sign and reply</button></nostr-compose></details> <a href="{{$item.Open}}">open</a></footer>6665 </approval-item>6766 {{end}}{{else}}<p>Nothing waits for your decision.</p>6867 {{end}}{{if $done}}<h3>Answered</h3>6968 <table id="rows">7069 <thead><tr><th>asker</th><th>request</th><th>answer</th></tr></thead>7170 <tbody>72-{{range $item := $done}}<tr id="approval-{{$item.ID}}" data-state="{{$item.State}}"><td>{{template "nostrName" $item.Asker}}</td><td><a href="{{$item.Open}}">{{$item.Title}}</a>{{if eq $item.Decision "replied"}}{{with $item.AnswerContent}} <small>{{.}}</small>{{end}}{{end}}</td><td>{{if eq $item.State "expired"}}expired{{else}}{{$item.Decision}} | <time datetime="{{datetime $item.AnsweredAt}}">{{when $item.AnsweredAt}}</time>{{end}}</td></tr>71+{{range $item := $done}}<tr id="approval-{{$item.ID}}" data-state="{{$item.State}}"><td>{{template "nostrName" $item.Asker}}</td><td><a href="{{$item.Open}}">{{$item.Title}}</a>{{if eq $item.Decision "replied"}}{{with $item.AnswerContent}} <small>{{.}}</small>{{end}}{{end}}</td><td>{{if eq $item.State "expired"}}<status-badge kind="expired">expired</status-badge>{{else}}<status-badge kind="{{$item.Decision}}">{{$item.Decision}}</status-badge> <time datetime="{{datetime $item.AnsweredAt}}">{{when $item.AnsweredAt}}</time>{{end}}</td></tr>7372 {{end}}</tbody>7473 </table>7574 {{end}}{{with (asMap .Event).next_cursor}}<p id="pages"><a href="{{browsePageURL "/approvals" $.Query "cursor" .}}">next page</a></p>@@ -105,7 +104,7 @@
105104 <label>Lightning address <input name="lud16" value="{{str (index $p "lud16")}}" placeholder="you@wallet.example"></label>106105 <label>Also publish to <textarea name="relays" placeholder="wss://relay.example">{{range index $data "relays"}}{{.}}107106 {{end}}</textarea></label>108-<button>Sign and publish</button>107+<button data-primary>Sign and publish</button>109108 <noscript><p>Saving needs JavaScript and a connected signer. You can also publish a kind 0 event from any client with this relay in its list.</p></noscript>110109 </profile-form>111110 {{with index $data "event"}}{{$e := asMap .}}<p><small>Current profile published <time datetime="{{datetime (index $e "created_at")}}">{{when (index $e "created_at")}}</time>; fields this form does not show are kept as they are.</small></p>{{else}}<p><small>This relay holds no profile for your key yet.</small></p>{{end}}@@ -3,6 +3,6 @@
33 {{define "chatActivityCards"}}{{if .Error}}<p role="status">{{.Error}}</p>{{else}}{{range .Items}}<article id="activity-{{.ID}}" data-activity="{{.Type}}" data-state="{{.State}}" data-event="{{.ID}}">44 {{if eq .Type "approval"}}{{if ne .State "open"}}<details data-task="{{.ID}}"><summary><strong>{{.Title}}</strong> <small>{{if .Decision}}{{.Decision}}{{else}}{{.State}}{{end}}</small></summary>{{end}}<header><strong>{{if eq .Interaction "approval"}}Approval request{{else if eq .Interaction "confirmation"}}Confirmation{{else if .Question}}Question{{else}}Approval request{{end}}</strong> <small>{{if .Decision}}{{.Decision}}{{else}}{{.State}}{{end}}</small></header><p>{{.Title}}</p>{{if ne .Title .Content}}<details><summary>Details</summary><p>{{.Content}}</p></details>{{end}}55 <p><small>from {{template "nostrName" .Author}}</small> <a href="/approvals?id={{.ID}}">Open request</a></p>6-{{with .Answer}}<blockquote>{{.}}</blockquote>{{end}}{{if .CanDecide}}<chat-decision event="{{.ID}}" pubkey="{{.Author}}" kind="{{.Kind}}" room="{{.Room}}" actor="{{$.Actor}}" expires="{{.Expires}}" interaction="{{.Interaction}}" selection="{{if .Multiple}}multiple{{else if .Native}}{{if .Options}}single{{else}}text{{end}}{{end}}"{{if .Freeform}} freeform{{end}}{{if .Question}} question{{end}}{{if .Native}} native{{end}}{{if .Multiple}} multiple{{end}}>{{if .Native}}{{$multiple := .Multiple}}{{$freeform := .Freeform}}{{if .Options}}<fieldset><legend>{{if $multiple}}Choose one or more options{{else}}Choose an option{{end}}</legend>{{range .Options}}<label><input type="{{if $multiple}}checkbox{{else}}radio{{end}}" name="option" value="{{.ID}}"> {{.Label}}</label>{{end}}</fieldset>{{end}}{{if $freeform}}<label>{{if .Options}}Add details or write your own answer{{else}}Your answer{{end}} <textarea name="custom-answer" maxlength="8000" rows="2"></textarea></label>{{end}}<button type="submit">Submit</button>{{else if .Question}}<label>Your answer <textarea name="content" required maxlength="8000" rows="2"></textarea></label><button type="submit">Reply</button>{{else}}<button type="submit" name="reaction" value="+">Approve</button><button type="submit" name="reaction" value="-">Deny</button>{{end}}</chat-decision><noscript><p>Answering requires JavaScript and a connected signer.</p></noscript>{{end}}{{if ne .State "open"}}</details>{{end}}6+{{with .Answer}}<blockquote>{{.}}</blockquote>{{end}}{{if .CanDecide}}<chat-decision event="{{.ID}}" pubkey="{{.Author}}" kind="{{.Kind}}" room="{{.Room}}" actor="{{$.Actor}}" expires="{{.Expires}}" interaction="{{.Interaction}}" selection="{{if .Multiple}}multiple{{else if .Native}}{{if .Options}}single{{else}}text{{end}}{{end}}"{{if .Freeform}} freeform{{end}}{{if .Question}} question{{end}}{{if .Native}} native{{end}}{{if .Multiple}} multiple{{end}}>{{if .Native}}{{$multiple := .Multiple}}{{$freeform := .Freeform}}{{if .Options}}<fieldset><legend>{{if $multiple}}Choose one or more options{{else}}Choose an option{{end}}</legend>{{range .Options}}<label><input type="{{if $multiple}}checkbox{{else}}radio{{end}}" name="option" value="{{.ID}}"> {{.Label}}</label>{{end}}</fieldset>{{end}}{{if $freeform}}<label>{{if .Options}}Add details or write your own answer{{else}}Your answer{{end}} <textarea name="custom-answer" maxlength="8000" rows="2"></textarea></label>{{end}}<button type="submit" data-primary>Submit</button>{{else if .Question}}<label>Your answer <textarea name="content" required maxlength="8000" rows="2"></textarea></label><button type="submit" data-primary>Reply</button>{{else}}<button type="submit" name="reaction" value="+" data-primary>Approve</button><button type="submit" name="reaction" value="-" data-danger>Deny</button>{{end}}</chat-decision><noscript><p>Answering requires JavaScript and a connected signer.</p></noscript>{{end}}{{if ne .State "open"}}</details>{{end}}77 {{else}}<details id="activity-details-{{.ID}}" data-task="{{.ID}}"><summary><strong>{{.Title}}</strong> <small data-status="{{.Status}}">{{.Status}}</small></summary><p><small>{{template "nostrName" .Author}}</small></p><div>{{chatMarkdown .Content}}</div>{{with .Result}}{{if .Content}}<h4>Result</h4><div>{{chatMarkdown .Content}}</div>{{end}}<p><a href="/e/{{.ID}}">Open result</a></p>{{end}}<p><a href="/e/{{.ID}}">Open task</a></p></details>{{end}}88 </article>{{end}}{{end}}{{end}}@@ -1,8 +1,7 @@
11 {{define "chat"}}<section id="chat">22 <h1>Chat</h1>3-<p>Direct conversations and group rooms.</p>43 <h2 id="new-chat">Direct chats</h2>5-{{if .Actor}}<direct-start><label>Start a conversation <input name="peer" required placeholder="npub or public key" autocomplete="off" spellcheck="false"></label><button>New chat</button></direct-start>4+{{if .Actor}}<direct-start><label>Start a conversation <input name="peer" required placeholder="npub or public key" autocomplete="off" spellcheck="false"></label><button data-primary>New chat</button></direct-start>65 <direct-chats actor="{{.Actor}}" relay="{{wsURL .URL}}"><p>Connect your signer to open your encrypted conversations.</p></direct-chats>76 <noscript><p>Direct chats need JavaScript and a signer that supports NIP-44 encryption. Group rooms are available below.</p></noscript>87 {{else}}<p><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to open your direct chats.</p>{{end}}@@ -11,14 +10,13 @@
1110 </section>{{end}}1211 1312 {{define "direct"}}<section id="direct-chat">14-<header><h1>{{template "nostrName" .View}}</h1><p>Private conversation. Only you and this person can read these messages.</p></header>13+<header><h1>{{template "nostrName" .View}}</h1></header>1514 {{if .Actor}}<direct-thread actor="{{.Actor}}" peer="{{.View}}" relay="{{wsURL .URL}}"><p>Connect your signer to decrypt this conversation.</p></direct-thread>16-<direct-compose actor="{{.Actor}}" peer="{{.View}}" relay="{{wsURL .URL}}"><label for="direct-content">Message</label><textarea id="direct-content" name="content" rows="2" required title="Enter to send; Shift+Enter for a new line" placeholder="Write a private message…"></textarea><button type="submit">Send</button></direct-compose>15+<direct-compose actor="{{.Actor}}" peer="{{.View}}" relay="{{wsURL .URL}}"><label for="direct-content">Message</label><textarea id="direct-content" name="content" rows="2" required title="Enter to send; Shift+Enter for a new line" placeholder="Write a private message…"></textarea><button type="submit" data-primary>Send</button></direct-compose>1716 <noscript><p>Reading and sending direct messages needs JavaScript and a signer that supports NIP-44 encryption.</p></noscript>1817 {{else}}<p><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to open this private conversation.</p>{{end}}1918 </section>{{end}}2019 21-{{define "panel-chat"}}<section><h4>Direct messages</h4><p>Encrypted for you and the recipient. Your signer opens messages delivered to this relay.</p>22-{{if .Actor}}<direct-settings actor="{{.Actor}}" relay="{{wsURL .URL}}"></direct-settings><p><small>Add this relay to your message relay list so other Nostr clients know where to reach you.</small></p>{{end}}</section>23-{{if .Actor}}<section><h4>Notifications</h4><push-toggle></push-toggle><p><small>Chat and approval notices on this device.</small></p><noscript><p>Enabling notifications needs JavaScript.</p></noscript></section>{{end}}24-<section><h4>Group rooms</h4><p>Open rooms are visible to everyone. Members-only rooms are visible to their members and relay moderators.</p><a href="/chat#new-room">Create a room</a></section>{{end}}20+{{define "panel-chat"}}<section><h4>Direct messages</h4>{{if .Actor}}<direct-settings actor="{{.Actor}}" relay="{{wsURL .URL}}"></direct-settings><p><small>Add this relay to your message relay list.</small></p>{{end}}</section>21+{{if .Actor}}<section><h4>Notifications</h4><push-toggle></push-toggle><noscript><p>Enabling notifications needs JavaScript.</p></noscript></section>{{end}}22+{{if .Actor}}<section><h4>Group rooms</h4><a href="/chat#new-room">Create a room</a></section>{{end}}{{end}}@@ -174,7 +174,7 @@
174174 if (this.bound) return;175175 this.bound = true;176176 this.innerHTML =177- '<form><label>Files <input type="file" name="file" multiple></label><input type="file" name="folder" webkitdirectory multiple hidden><p data-drop><button type="button" data-pick-folder>Choose folder</button> or drop files or a folder here</p><label>Who can open <select name="access"><option value="public">Anyone with the link</option><option value="members">Relay members</option></select></label><details data-advanced><summary>Advanced</summary><label><input type="checkbox" name="encrypt"> Encrypt with a secret link</label><p><small>Only people with the complete link can decrypt it. Saved in this browser for your account.</small></p><label data-background hidden><input type="checkbox" name="background"> Continue in the background</label></details><button>Upload</button></form><progress max="1" value="0" hidden aria-label="Upload progress"></progress><output role="status"></output><p data-controls hidden><button type="button" data-cancel disabled>Cancel</button> <button type="button" data-retry disabled>Retry</button></p><div data-share hidden><p><a data-open>Open file</a> <button type="button" data-copy-link>Copy secret link</button></p><label>Secret link <input data-share-url readonly></label></div>';177+ '<form><label>Files <input type="file" name="file" multiple></label><input type="file" name="folder" webkitdirectory multiple hidden><p data-drop><button type="button" data-pick-folder>Choose folder</button> or drop files or a folder here</p><label>Who can open <select name="access"><option value="public">Anyone with the link</option><option value="members">Relay members</option></select></label><details data-advanced><summary>Advanced</summary><label><input type="checkbox" name="encrypt"> Encrypt with a secret link</label><p><small>Only people with the complete link can decrypt it. Saved in this browser for your account.</small></p><label data-background hidden><input type="checkbox" name="background"> Continue in the background</label></details><button data-primary>Upload</button></form><progress max="1" value="0" hidden aria-label="Upload progress"></progress><output role="status"></output><p data-controls hidden><button type="button" data-cancel disabled>Cancel</button> <button type="button" data-retry disabled>Retry</button></p><div data-share hidden><p><a data-open>Open file</a> <button type="button" data-copy-link>Copy secret link</button></p><label>Secret link <input data-share-url readonly></label></div>';178178 this.out = this.querySelector("output");179179 const form = this.querySelector("form");180180 const files = form.elements.namedItem("file");@@ -1,3 +1,10 @@
1+{{define "manage-restricted"}}<section id="manage-restricted">2+<h1>Manage</h1>3+<p>Only the owner and moderators manage this relay.</p>4+<p><a href="/">Back to the relay</a></p>5+</section>6+{{end}}7+18 {{define "people"}}<section>29 <h1>People</h1>310 <h3>Members</h3>@@ -7,12 +14,11 @@
714 <label>Name <input name="member-name"></label>815 <label>Note <input name="member-note"></label>916 <label>Role <select name="member-role"><option value="member">Member</option><option value="moderator">Moderator</option></select></label>10-<button>Save member</button>17+<button data-primary>Save member</button>1118 </rpc-form>12-<rpc-form method="removemember"><label>Pubkey <input name="param" required></label><button>Remove member</button></rpc-form>13-<rpc-form method="removesubtree"><label>Member pubkey <input name="param" required></label><button>Remove invite subtree</button></rpc-form>19+<rpc-form method="removemember"><label>Pubkey <input name="param" required></label><button data-danger>Remove member</button></rpc-form>20+<rpc-form method="removesubtree"><label>Member pubkey <input name="param" required></label><button data-danger>Remove invite subtree</button></rpc-form>1421 {{if .Review}}<h3>Access requests</h3>15-<p><small>People who asked to join without an invite. Approving adds them as members.</small></p>1622 {{with pendingRequests .Requests}}<table id="requests"><thead><tr><th>person</th><th>reason</th><th>asked</th><th></th></tr></thead><tbody>1723 {{range .}}{{$r := asMap .}}{{$pk := str (index $r "pubkey")}}<tr><td>{{template "nostrName" $pk}}</td><td>{{with str (index $r "reason")}}{{.}}{{else}}<small>no reason given</small>{{end}}</td><td><time datetime="{{datetime (index $r "requested_at")}}">{{short (index $r "requested_at")}}</time></td><td><rpc-form method="approvejoin" refresh><input type="hidden" name="param" value=""{{$pk}}""><button>Approve</button></rpc-form><rpc-form method="denyjoin" refresh><input type="hidden" name="param" value=""{{$pk}}""><button>Deny</button></rpc-form></td></tr>1824 {{end}}</tbody></table>{{else}}<p>No pending requests.</p>{{end}}@@ -28,7 +34,7 @@
2834 <h3>Claims</h3>2935 <rpc-form method="listclaims"><button>List claims</button></rpc-form>3036 <rpc-form method="createclaim"><label>JSON claim <input name="param" value="{}"></label><button>Create claim</button></rpc-form>31-<rpc-form method="deleteclaim"><label>Claim code <input name="param" required></label><button>Delete claim</button></rpc-form>37+<rpc-form method="deleteclaim"><label>Claim code <input name="param" required></label><button data-danger>Delete claim</button></rpc-form>3238 <h3>Lists</h3>3339 <rpc-form method="listallowedpubkeys"><button>Allowed pubkeys</button></rpc-form>3440 <rpc-form method="listbannedpubkeys"><button>Banned pubkeys</button></rpc-form>@@ -37,10 +43,9 @@
3743 3844 {{define "agents"}}<section>3945 <h1>Agents</h1>40-<p>Agents are members with their own keys. Each one acts under a grant you sign, and stays the author of everything it does.</p>4146 {{template "alert" .Error}}4247 <table id="rows"><thead><tr><th>agent</th><th>scope</th><th>owner</th><th>state</th></tr></thead><tbody>43-{{range .Feed}}{{$a := asMap .}}{{$pk := str (index $a "pubkey")}}<tr><td><a href="#agent-{{$pk}}">{{agentLabel $a}}</a></td><td title="{{agentScope $a}}">{{agentScope $a}}</td><td>{{template "nostrName" (str (index $a "owner"))}}</td><td data-state="{{agentState $a}}">{{agentState $a}}{{with agentSince $a}} | {{.}}{{end}}</td></tr>48+{{range .Feed}}{{$a := asMap .}}{{$pk := str (index $a "pubkey")}}<tr><td><a href="#agent-{{$pk}}">{{agentLabel $a}}</a></td><td title="{{agentScope $a}}">{{agentScope $a}}</td><td>{{template "nostrName" (str (index $a "owner"))}}</td><td data-state="{{agentState $a}}"><status-badge kind="{{agentState $a}}">{{agentState $a}}</status-badge>{{with agentSince $a}} <small>{{.}}</small>{{end}}</td></tr>4449 {{else}}<tr><td colspan="4">No agents yet. Sign a grant below to add one.</td></tr>4550 {{end}}</tbody></table>4651 {{range .Feed}}{{$a := asMap .}}{{$pk := str (index $a "pubkey")}}{{$state := agentState $a}}{{$scope := asMap (index $a "scope")}}@@ -59,7 +64,7 @@
5964 {{with index $a "lastEvent"}}{{if when .}}<tr><th>last event</th><td><time datetime="{{datetime .}}">{{when .}}</time></td></tr>{{end}}{{end}}6065 <tr><th>callbacks</th><td>{{range $c := callbacksFor $.Callbacks $pk}}{{$cm := asMap $c}}{{$cs := callbackState $c}}{{$cid := str (index $cm "id")}}<agent-callback data-state="{{$cs}}"><code>{{str (index $cm "host")}}</code> kinds {{callbackKinds $c}} | <span data-state="{{$cs}}" title="{{str (index $cm "lastStatus")}}">{{$cs}}</span>{{with index $cm "lastDelivery"}}{{if when .}} | delivered <time datetime="{{datetime .}}">{{when .}}</time>{{end}}{{end}} {{if eq $cs "paused"}}<rpc-form method="resumecallback" refresh><input type="hidden" name="param" value=""{{$cid}}""><button>Resume</button></rpc-form>{{else}}<rpc-form method="pausecallback" refresh><input type="hidden" name="param" value=""{{$cid}}""><button>Pause</button></rpc-form>{{end}}<rpc-form method="removecallback" refresh><input type="hidden" name="param" value=""{{$cid}}""><button>Remove</button></rpc-form></agent-callback>{{else}}none{{end}}</td></tr>6166 </table>62-<p>{{if eq $state "paused"}}<rpc-form method="resumeagent" refresh><input type="hidden" name="param" value=""{{$pk}}""><button>Resume</button></rpc-form>{{else if eq $state "active"}}<rpc-form method="pauseagent" refresh><input type="hidden" name="param" value=""{{$pk}}""><button>Pause</button></rpc-form>{{end}}{{if ne $state "revoked"}}<rpc-form method="revokeagent" refresh><input type="hidden" name="param" value=""{{$pk}}""><button>Revoke</button></rpc-form>{{end}}<a href="/manage/agents?agent={{$pk}}">recent activity</a>{{if ne $state "revoked"}} <a href="/manage/agents?agent={{$pk}}&edit={{$pk}}#grant">edit</a>{{end}}</p>67+<p>{{if eq $state "paused"}}<rpc-form method="resumeagent" refresh><input type="hidden" name="param" value=""{{$pk}}""><button>Resume</button></rpc-form>{{else if eq $state "active"}}<rpc-form method="pauseagent" refresh><input type="hidden" name="param" value=""{{$pk}}""><button>Pause</button></rpc-form>{{end}}{{if ne $state "revoked"}}<rpc-form method="revokeagent" refresh><input type="hidden" name="param" value=""{{$pk}}""><button data-danger>Revoke</button></rpc-form>{{end}}<a href="/manage/agents?agent={{$pk}}">recent activity</a>{{if ne $state "revoked"}} <a href="/manage/agents?agent={{$pk}}&edit={{$pk}}#grant">edit</a>{{end}}</p>6368 </agent-card>6469 {{end}}{{with asMap .Event}}{{$agent := asMap (index . "agent")}}6570 <h3>Recent activity of {{agentLabel $agent}}</h3>@@ -69,8 +74,8 @@
6974 {{else}}<p>No events from this agent yet.</p>7075 {{end}}{{end}}7176 {{$edit := agentByKey .Feed (.Query.Get "edit")}}{{$es := asMap (index (asMap $edit) "scope")}}<h3 id="grant">{{if $edit}}Replace the grant for {{agentLabel $edit}}{{else}}New agent{{end}}</h3>72-<p>{{if $edit}}The form holds the current grant. Signing it publishes a new kind 30392 event for the same key, which replaces the old grant at once.{{else}}The grant is a kind 30392 event signed by your key. The agent's own key signs everything it publishes.{{end}}</p>73-<agent-grant>77+{{if $edit}}<p>Signing publishes a replacement grant for the same key.</p>78+{{end}}<agent-grant>7479 <label>Name <input name="name" maxlength="64" required placeholder="release-notes"{{with $edit}} value="{{agentLabel .}}"{{end}}></label>7580 <label>Key <select name="key"><option value="generate">generate here, show once</option><option value="paste"{{if $edit}} selected{{end}}>paste a public key</option></select></label>7681 <label>Public key <input name="pubkey" pattern="[0-9a-f]{64}" placeholder="64 hex characters"{{with $edit}} value="{{str (index (asMap .) "pubkey")}}"{{end}}></label>@@ -81,7 +86,7 @@
8186 <label>Kinds <input name="kinds" placeholder="1, 1111, 1621"{{with $edit}} value="{{scopeList . "kinds"}}"{{end}}></label>8287 <label>Rate per minute <input name="rate" type="number" min="1" max="600" value="{{if $edit}}{{str (index $es "rate")}}{{else}}60{{end}}"></label>8388 <label>Expires <input name="expires" type="date" value="{{if $edit}}{{dateOf (index (asMap $edit) "expires")}}{{else}}{{dateAfter 90}}{{end}}" required></label>84-<button>{{if $edit}}Sign the replacement{{else}}Sign grant{{end}}</button>89+<button data-primary>{{if $edit}}Sign the replacement{{else}}Sign grant{{end}}</button>8590 <noscript><p>Signing a grant needs JavaScript and a connected signer. You can also publish the kind 30392 event from any client.</p></noscript>8691 </agent-grant>8792 </section>@@ -101,13 +106,13 @@
101106 <rpc-form method="searchevents"><label>JSON filter <input name="param" value="{}"></label><button>Search events</button></rpc-form>102107 <rpc-form method="banevent"><label>Event ID <input name="param" required></label><button>Hide event</button></rpc-form>103108 <rpc-form method="allowevent"><label>Event ID <input name="param" required></label><button>Allow event</button></rpc-form>104-<rpc-form method="deleteevent"><label>Event ID <input name="param" required></label><button>Delete event</button></rpc-form>109+<rpc-form method="deleteevent"><label>Event ID <input name="param" required></label><button data-danger>Delete event</button></rpc-form>105110 <h3>Pins</h3>106111 <rpc-form method="listpins"><button>List pins</button></rpc-form>107112 <rpc-form method="pinevent"><label>Event ID or address <input name="param" required></label><button>Pin</button></rpc-form>108113 <rpc-form method="unpinevent"><label>Event ID or address <input name="param" required></label><button>Unpin</button></rpc-form>109114 <h3>Pubkeys</h3>110-<rpc-form method="banpubkey"><label>Pubkey <input name="param" required></label><button>Ban</button></rpc-form>115+<rpc-form method="banpubkey"><label>Pubkey <input name="param" required></label><button data-danger>Ban</button></rpc-form>111116 </section>112117 {{end}}113118 @@ -151,7 +156,7 @@
151156 <button>Save directory identity</button>152157 </rpc-form>153158 <h3>Ownership</h3>154-<rpc-form method="transferowner"><label>New owner pubkey <input name="param" required></label><button>Transfer ownership</button></rpc-form>159+<rpc-form method="transferowner"><label>New owner pubkey <input name="param" required></label><button data-danger>Transfer ownership</button></rpc-form>155160 </section>156161 {{end}}157162 @@ -195,7 +200,7 @@
195200 <h3>Files</h3>196201 <signed-form action="/upload" method="PUT"><label>Upload file <input type="file" name="file" required></label><button>Upload</button></signed-form>197202 <rpc-form method="listblobs"><button>List blobs</button></rpc-form>198-<rpc-form method="deleteblob"><label>Blob hash <input name="param" required></label><button>Delete blob</button></rpc-form>203+<rpc-form method="deleteblob"><label>Blob hash <input name="param" required></label><button data-danger>Delete blob</button></rpc-form>199204 <rpc-form method="listsites"><button>List sites</button></rpc-form>200205 <h3>Storage</h3>201206 <rpc-form method="storagestats"><button>Storage stats</button></rpc-form>@@ -208,14 +213,14 @@
208213 <rpc-form method="listdumps"><button>List dumps</button></rpc-form>209214 <rpc-form method="dumpnow"><button>Write dump</button></rpc-form>210215 <signed-form action="/dumps/" method="GET" download><label>Dump name <input name="name" required></label><button>Download dump</button></signed-form>211-<rpc-form method="deletedump"><label>Dump name <input name="param" required></label><button>Delete dump</button></rpc-form>216+<rpc-form method="deletedump"><label>Dump name <input name="param" required></label><button data-danger>Delete dump</button></rpc-form>212217 <h3>Backups</h3>213218 <rpc-form method="listbackups"><button>List backups</button></rpc-form>214219 <rpc-form method="backupnow"><button>Create backup</button></rpc-form>215220 <signed-form action="/backups/" method="GET" download><label>Backup name <input name="name" required></label><button>Download backup</button></signed-form>216221 <signed-form action="/backups/preview" method="POST"><label>Backup archive <input type="file" name="file" accept=".backup.json,application/json" required></label><button>Preview backup</button></signed-form>217222 <signed-form action="/backups/restore" method="POST"><label>Backup archive <input type="file" name="file" accept=".backup.json,application/json" required></label><button>Restore backup</button></signed-form>218-<rpc-form method="deletebackup"><label>Backup name <input name="param" required></label><button>Delete backup</button></rpc-form>223+<rpc-form method="deletebackup"><label>Backup name <input name="param" required></label><button data-danger>Delete backup</button></rpc-form>219224 <h3>Import</h3>220225 <signed-form action="/import" method="PUT"><label>Event JSONL <input type="file" name="file" accept=".jsonl,.json,application/json" required></label><button>Import events</button></signed-form>221226 </section>@@ -229,7 +234,7 @@
229234 <rpc-form method="listjobs"><button>List jobs</button></rpc-form>230235 <rpc-form method="addjob"><label>JSON job <input name="param" value="{}"></label><button>Add job</button></rpc-form>231236 <rpc-form method="runjob"><label>Job ID <input name="param" required></label><button>Run job</button></rpc-form>232-<rpc-form method="removejob"><label>Job ID <input name="param" required></label><button>Remove job</button></rpc-form>237+<rpc-form method="removejob"><label>Job ID <input name="param" required></label><button data-danger>Remove job</button></rpc-form>233238 <h3>Pull and delivery</h3>234239 <rpc-form method="pullfrom">235240 <label>Relay URL <input name="param" type="url" required></label>@@ -66,6 +66,7 @@ then feature modules are loaded for the current page. */}}
6666 {{else if eq .Tab "views"}}{{template "views" .}}6767 {{else if eq .Tab "health"}}{{template "health" .}}6868 {{else if eq .Tab "owner"}}{{template "owner" .}}69+{{else if eq .Tab "manage-restricted"}}{{template "manage-restricted" .}}6970 {{else if eq .Tab "repos"}}{{template "repos" .}}7071 {{else if eq .Tab "repo"}}{{template "repo" .}}7172 {{else if eq .Tab "files"}}{{template "files" .}}@@ -117,16 +118,16 @@ then feature modules are loaded for the current page. */}}
117118 <nav aria-label="Social">{{$view := socialView .Query}}{{range socialViews}}<a href="{{.URL}}"{{if and (eq $.Tab "social") (eq $view.Key .Key)}} aria-current="page"{{end}}>/{{.Key}}</a>{{end}}</nav>118119 <nav aria-label="Social profile"><a href="/social/profile"{{if eq .Tab "social-profile"}} aria-current="page"{{end}}>/profile</a></nav>119120 {{else if eq $kind "manage"}}<header><span><a href="/">{{.Slug}}</a>/<b>manage</b></span></header>120-{{range $group := navGroups manageItems}}<nav>{{range $item := $group}}<a href="{{$item.Href}}"{{if eq $item.Tab $.Tab}} aria-current="page"{{end}}>{{$item.Label}}</a>{{end}}</nav>{{end}}121+{{range $i, $group := navGroups manageItems}}<nav{{with navGroupLabel "manage" $i}} data-label="{{.}}"{{end}}>{{range $item := $group}}<a href="{{$item.Href}}"{{if eq $item.Tab $.Tab}} aria-current="page"{{end}}>{{$item.Label}}</a>{{end}}</nav>{{end}}121122 {{else if eq $kind "rooms"}}<header><span><a href="/">{{.Slug}}</a>/<a href="/chat"><b>chat</b></a></span></header>122123 <nav aria-label="Chat"><a href="/chat"{{if eq .Tab "chat"}} aria-current="page"{{end}}>All conversations</a></nav>123124 {{if .Actor}}<h4>Direct chats</h4><direct-chats actor="{{.Actor}}" relay="{{wsURL .URL}}" compact><p>Connect your signer to see direct chats.</p></direct-chats>124125 <p><a href="/chat#new-chat">+ new chat</a></p>{{end}}125126 <h4>Rooms</h4>126127 <nav id="room-list">{{range .Rooms}}{{$r := asMap .}}{{$id := str (index $r "id")}}{{$picture := socialMediaURL (str (index $r "picture"))}}<a href="/rooms/{{$id}}"{{if eq $id $.View}} aria-current="page"{{end}}><room-avatar picture="{{$picture}}" fallback="{{roomAvatarURL $.URL $id}}" aria-hidden="true"><img src="{{if $picture}}{{$picture}}{{else}}{{roomAvatarURL $.URL $id}}{{end}}" alt="" width="24" height="24" loading="lazy"></room-avatar>{{or (str (index $r "name")) $id}}{{with age (index $r "last_message_at")}} <small>{{.}}</small>{{end}}</a>{{else}}<a href="/chat">Browse rooms</a>{{end}}</nav>127-<p><a href="/chat#new-room">+ new room</a></p>128+{{if .Actor}}<p><a href="/chat#new-room">+ new room</a></p>{{end}}128129 {{else}}<header><span><a id="mark" href="/" title="{{.Slug}}"><i>t</i><i>i</i><i>n</i><i>y</i></a></span></header>129-{{range $group := navGroups relayItems}}<nav>{{range $item := $group}}<a href="{{$item.Href}}"{{if eq $item.Tab $.Tab}} aria-current="page"{{end}}>{{$item.Label}}</a>{{end}}</nav>{{end}}{{if .Actor}}<p id="personal-nav"><a href="/account"{{if eq .Tab "account"}} aria-current="page"{{end}}>account</a></p>{{end}}130+{{range $i, $group := navGroups relayItems}}<nav{{with navGroupLabel "relay" $i}} data-label="{{.}}"{{end}}>{{range $item := $group}}<a href="{{$item.Href}}"{{if eq $item.Tab $.Tab}} aria-current="page"{{end}}>{{$item.Label}}</a>{{end}}</nav>{{end}}{{if .Actor}}<p id="personal-nav"><a href="/account"{{if eq .Tab "account"}} aria-current="page"{{end}}>/account</a></p>{{end}}130131 {{end}}<div id="mobile-actions">{{if .Actor}}<a href="/account">Account</a>{{end}}<page-link url="{{pageAddress .URL .Path .Query}}" title="Copy the page address"><button type="button">Copy page link</button></page-link></div><p id="rail-tools">{{if eq $kind "repo"}}<a href="/repos">← all repositories</a>{{else if or (eq $kind "manage") (eq $kind "rooms") (eq $kind "social")}}<a href="/">← relay</a>{{end}}<button id="theme" type="button" aria-label="Switch theme">theme</button></p>{{end}}131132 132133 {{/* The panel holds facts and filters for the current page. */}}@@ -149,55 +150,53 @@ then feature modules are loaded for the current page. */}}
149150 150151 {{define "panel-relay"}}<section><h4>About</h4><table><tr><th>identity</th><td>{{template "nostrKey" .Identity}}</td></tr><tr><th>reads</th><td>{{.Policy.Reads}}</td></tr><tr><th>writes</th><td>{{.Policy.Writes}}</td></tr><tr><th>search</th><td>{{.Policy.Features.Search}}</td></tr>{{if .Policy.Features.Grasp}}<tr><th>git</th><td>GRASP-01</td></tr>{{end}}{{if .Policy.Features.Files}}<tr><th>files</th><td>Blossom</td></tr>{{end}}</table></section>151152 <section><h4>Actions</h4><ul>{{if .Actor}}<li><a href="/profile">profile</a></li>{{else}}<li><a href="{{signinURL .Base .Path .Query}}">sign in</a></li>{{end}}<li><a href="/.well-known/nostr.json">NIP-05 names</a></li>{{if .Policy.Features.Pages}}<li><a href="/feed.xml">Atom feed</a></li>{{end}}<li><a href="/terms">terms</a></li></ul></section>152-<section><h4>Relay</h4><table><tr><th>url</th><td><code>{{.URL}}</code></td></tr></table></section>{{end}}153+{{end}}153154 154155 {{define "panel-signin"}}<section><h4>Session</h4><table><tr><th>status</th><td>{{if .Actor}}signed in{{else}}guest{{end}}</td></tr><tr><th>method</th><td>NIP-98</td></tr></table></section>155156 <section><h4>After sign in</h4><ul><li>join chats</li><li>manage members and policy</li><li>upload files</li><li>push repositories</li></ul></section>{{end}}156157 157-{{define "panel-account"}}<section><h4>Personal</h4><table><tr><th>identity</th><td>{{template "nostrKey" .Actor}}</td></tr><tr><th>profile</th><td><a href="/profile">edit</a></td></tr></table></section><section><h4>Relay</h4><p><small>Your relay lists and chat preferences apply to this account.</small></p></section>{{end}}158+{{define "panel-account"}}<section><h4>Personal</h4><table><tr><th>identity</th><td>{{template "nostrKey" .Actor}}</td></tr><tr><th>profile</th><td><a href="/profile">edit</a></td></tr></table></section>{{end}}158159 159-{{define "panel-repos"}}<section><h4>Hosting</h4><table><tr><th>server</th><td><code>{{.URL}}</code></td></tr><tr><th>protocol</th><td>GRASP-01</td></tr></table></section>160-<section><h4>Publish</h4><p><small>Announce with ngit and push with plain git. Every push needs the owner's signed state.</small></p></section>{{end}}160+{{define "panel-repos"}}<section><h4>Hosting</h4><table><tr><th>server</th><td><code>{{.URL}}</code></td></tr><tr><th>protocol</th><td>GRASP-01</td></tr></table></section>{{end}}161161 162162 {{define "panel-files"}}<section><h4>Storage</h4><table><tr><th>protocol</th><td>Blossom</td></tr>{{if .Policy.Features.Files}}<tr><th>uploads</th><td>members</td></tr>{{end}}</table></section>163-{{if .Actor}}<section><h4>Upload</h4><file-upload pubkey="{{.Actor}}"></file-upload><p><small>Members-only files require membership to view or download.</small></p><details><summary>Import from URL</summary><file-mirror action="/mirror"></file-mirror></details><noscript><p>Uploads need JavaScript and a connected signer.</p></noscript></section>{{end}}{{end}}163+{{if and .Actor (not (restricted .Error))}}<section><h4>Upload</h4><file-upload pubkey="{{.Actor}}"></file-upload><details><summary>Import from URL</summary><file-mirror action="/mirror"></file-mirror></details><noscript><p>Uploads need JavaScript and a connected signer.</p></noscript></section>{{end}}{{end}}164164 165-{{define "panel-approvals"}}{{$counts := approvalCounts .Event}}{{$data := asMap .Event}}<section><h4>Waiting</h4><table><tr><th>open</th><td>{{index $counts "open"}}</td></tr><tr><th>oldest</th><td>{{with index $data "oldest_open"}}<time datetime="{{datetime .}}">{{when .}}</time>{{else}}none{{end}}</td></tr><tr><th>answered</th><td>{{index $counts "answered"}}</td></tr><tr><th>expired</th><td>{{index $counts "expired"}}</td></tr></table></section>166-<section><h4>Devices</h4>{{if .Actor}}{{range $index, $device := approvalDevices .Event}}<p><i></i>device {{add $index 1}} | {{$device}}</p>{{else}}<p>No device receives notifications for this key. Enable them in Chat.</p>{{end}}<p><small>Answers from the phone need the remembered signer.</small></p>{{else}}<p>Sign in to see which devices are notified.</p>{{end}}</section>{{end}}165+{{define "panel-approvals"}}{{$counts := approvalCounts .Event}}{{$data := asMap .Event}}{{if .Actor}}<section><h4>Waiting</h4><table><tr><th>open</th><td>{{index $counts "open"}}</td></tr><tr><th>oldest</th><td>{{with index $data "oldest_open"}}<time datetime="{{datetime .}}">{{when .}}</time>{{else}}none{{end}}</td></tr><tr><th>answered</th><td>{{index $counts "answered"}}</td></tr><tr><th>expired</th><td>{{index $counts "expired"}}</td></tr></table></section>{{end}}166+<section><h4>Devices</h4>{{if .Actor}}{{range $index, $device := approvalDevices .Event}}<p><i></i>device {{add $index 1}} | {{$device}}</p>{{else}}<p>No device receives notifications for this key. Enable them in Chat.</p>{{end}}{{else}}<p>Sign in to see which devices are notified.</p>{{end}}</section>{{end}}167167 168-{{define "panel-feed"}}<section><h4>Filters</h4><form method="get" action="/{{.Tab}}"><label for="filter-kinds">kinds</label><select id="filter-kinds" name="kinds"><option value="">notes and articles</option><option value="1"{{if eq (.Query.Get "kinds") "1"}} selected{{end}}>1 notes</option><option value="30023"{{if eq (.Query.Get "kinds") "30023"}} selected{{end}}>30023 articles</option><option value="7"{{if eq (.Query.Get "kinds") "7"}} selected{{end}}>7 reactions</option><option value="all"{{if eq (.Query.Get "kinds") "all"}} selected{{end}}>everything</option></select><label for="filter-author">author</label><input id="filter-author" name="author" placeholder="64 hex pubkey" value="{{.Query.Get "author"}}"><label for="filter-since">since</label><input id="filter-since" name="since" type="date" value="{{.Query.Get "since"}}">{{if eq .Tab "search"}}<label for="filter-q">search</label><input id="filter-q" name="q" value="{{.Query.Get "q"}}">{{end}}<button>apply</button></form></section>168+{{define "panel-feed"}}<section><h4>Filters</h4><form method="get" action="/{{.Tab}}"><label for="filter-kinds">kinds</label><select id="filter-kinds" name="kinds"><option value="">notes and articles</option><option value="1"{{if eq (.Query.Get "kinds") "1"}} selected{{end}}>1 notes</option><option value="30023"{{if eq (.Query.Get "kinds") "30023"}} selected{{end}}>30023 articles</option><option value="7"{{if eq (.Query.Get "kinds") "7"}} selected{{end}}>7 reactions</option><option value="all"{{if eq (.Query.Get "kinds") "all"}} selected{{end}}>everything</option></select><label for="filter-author">author</label><input id="filter-author" name="author" placeholder="64 hex pubkey" value="{{.Query.Get "author"}}"><label for="filter-since">since</label><input id="filter-since" name="since" type="date" value="{{.Query.Get "since"}}">{{if eq .Tab "search"}}<label for="filter-q">search</label><input id="filter-q" name="q" value="{{.Query.Get "q"}}">{{end}}<button>Apply</button></form></section>169169 {{if .Actor}}<section><h4>You</h4><table><tr><th>key</th><td>{{template "nostrKey" .Actor}}</td></tr></table></section>{{end}}170170 {{if and .Actor (eq .Tab "inbox")}}<section><h4>Notifications</h4><push-toggle></push-toggle><p><small>Relay notices for this key, on this device only.</small></p>{{if .Owner}}<rpc-form method="notifytest"><button>Send a test</button></rpc-form>{{end}}<noscript><p>Enabling notifications needs JavaScript.</p></noscript></section>{{end}}{{end}}171171 172172 {{define "panel-repo"}}{{$r := asMap .Event}}{{with index $r "repository"}}{{$r = asMap .}}{{end}}<section><h4>About</h4><table><tr><th>owner</th><td>{{template "nostrKey" (str (index $r "owner"))}}</td></tr><tr><th>visibility</th><td>{{if index $r "private"}}private{{else}}public{{end}}</td></tr>{{with index $r "commit"}}<tr><th>head</th><td><code title="{{.}}">{{shortID .}}</code></td></tr>{{end}}</table></section>173173 {{with index $r "maintainers"}}<section><h4>Maintainers</h4><ul id="maintainers">{{range .}}{{$m := asMap .}}<li>{{template "nostrName" (str (index $m "pubkey"))}}{{$role := str (index $m "role")}}{{if eq $role "agent"}} <small>agent{{with str (index $m "name")}} {{.}}{{end}}</small>{{else if eq $role "owner"}} <small>owner</small>{{end}}</li>{{end}}</ul></section>174174 {{end}}<section><h4>Clone</h4><ul>{{range index $r "clone"}}<li><code>{{.}}</code></li>{{else}}<li>no clone URL advertised</li>{{end}}</ul></section>175-{{with index $r "refs"}}<section><h4>Refs</h4><form method="get" action="/repo"><input type="hidden" name="owner" value="{{$.Query.Get "owner"}}"><input type="hidden" name="repo" value="{{$.Query.Get "repo"}}"><input type="hidden" name="path" value="{{$.Query.Get "path"}}"><input type="hidden" name="id" value="{{$.Query.Get "id"}}"><input type="hidden" name="view" value="{{repoView $.Query}}"><label for="ref-select">branch or tag</label><select id="ref-select" name="ref"><option value="">default branch</option>{{range $name, $oid := .}}<option value="{{$name}}"{{if eq ($.Query.Get "ref") $name}} selected{{end}}>{{$name}}</option>{{end}}</select><button>go</button></form></section>{{end}}{{end}}175+{{with index $r "refs"}}<section><h4>Refs</h4><form method="get" action="/repo"><input type="hidden" name="owner" value="{{$.Query.Get "owner"}}"><input type="hidden" name="repo" value="{{$.Query.Get "repo"}}"><input type="hidden" name="path" value="{{$.Query.Get "path"}}"><input type="hidden" name="id" value="{{$.Query.Get "id"}}"><input type="hidden" name="view" value="{{repoView $.Query}}"><label for="ref-select">branch or tag</label><select id="ref-select" name="ref"><option value="">default branch</option>{{range $name, $oid := .}}<option value="{{$name}}"{{if eq ($.Query.Get "ref") $name}} selected{{end}}>{{$name}}</option>{{end}}</select><button>Go</button></form></section>{{end}}{{end}}176176 177177 {{define "panel-manage"}}<section><h4>Policy</h4><table><tr><th>name</th><td>{{.Policy.Name}}</td></tr><tr><th>reads</th><td>{{.Policy.Reads}}</td></tr><tr><th>writes</th><td>{{.Policy.Writes}}</td></tr><tr><th>owner</th><td>{{template "nostrKey" .Policy.Owner}}</td></tr></table></section>178178 {{if .Review}}<section><h4>People</h4><table><tr><th>requests</th><td>{{len (pendingRequests .Requests)}} pending</td></tr></table></section>179-{{end}}<section><h4>Session</h4><table><tr><th>you</th><td>{{if .Actor}}{{template "nostrKey" .Actor}}{{else}}guest{{end}}</td></tr><tr><th>role</th><td>{{if .Owner}}owner{{else if .Actor}}member{{else}}none{{end}}</td></tr></table></section>180-<section><h4>Result</h4><p><small>Each form shows its result below the form. Every call is signed by your connected signer.</small></p></section>{{end}}179+{{end}}<section><h4>Session</h4><table><tr><th>you</th><td>{{if .Actor}}{{template "nostrKey" .Actor}}{{else}}guest{{end}}</td></tr><tr><th>role</th><td>{{if .Owner}}owner{{else if .Review}}moderator{{else if .Actor}}member{{else}}none{{end}}</td></tr></table><p><small>Actions need a connected signer.</small></p></section>{{end}}181180 182181 {{define "panel-agents"}}{{$counts := agentCounts .Feed}}{{$callbacks := callbackCounts .Callbacks}}<section><h4>Agents</h4><table><tr><th>active</th><td>{{index $counts "active"}}</td></tr><tr><th>paused</th><td>{{index $counts "paused"}}</td></tr><tr><th>revoked</th><td>{{index $counts "revoked"}}</td></tr><tr><th>callbacks</th><td>{{index $callbacks "active"}} active, {{index $callbacks "paused"}} paused</td></tr></table></section>183182 <section><h4>Machine access</h4><table><tr><th>mcp</th><td><code>/mcp</code> 2026-07-28</td></tr><tr><th>auth</th><td>NIP-98 per request</td></tr><tr><th>rest</th><td><code>/events</code> <code>/query</code></td></tr></table></section>184-<section><h4>Kill switch</h4><rpc-form method="pauseallagents" refresh><button>Pause all agents</button></rpc-form><rpc-form method="resumeallagents" refresh><button>Resume all agents</button></rpc-form><p><small>Pausing keeps grants and rejects writes until resumed.</small></p></section>183+<section><h4>Kill switch</h4><rpc-form method="pauseallagents" refresh><button data-danger>Pause all agents</button></rpc-form><rpc-form method="resumeallagents" refresh><button>Resume all agents</button></rpc-form><p><small>Paused agents keep their grants.</small></p></section>185184 <section><h4>Session</h4><table><tr><th>you</th><td>{{if .Actor}}{{template "nostrKey" .Actor}}{{else}}guest{{end}}</td></tr><tr><th>role</th><td>{{if .Owner}}owner{{else if .Actor}}member{{else}}none{{end}}</td></tr></table></section>{{end}}186185 {{define "panel-rooms"}}{{if eq .Tab "rooms"}}<section><h4>Rooms</h4><table><tr><th>rooms</th><td>{{len .Rooms}}</td></tr><tr><th>protocol</th><td>NIP-29</td></tr></table></section>187186 <section><h4>Actions</h4><ul>{{if .Actor}}<li><a href="/chat#new-room">new room</a></li>{{else}}<li><a href="{{signinURL .Base .Path .Query}}">sign in</a></li>{{end}}</ul></section>188187 {{else if .Error}}<section><h4>Room</h4><table><tr><th>id</th><td><code>{{.View}}</code></td></tr></table></section>189-{{else}}{{$data := asMap .Event}}{{$room := asMap (index $data "room")}}{{$id := str (index $room "id")}}{{$role := str (index $room "role")}}<section><h4>Room</h4><table><tr><th>id</th><td><code>{{$id}}</code></td></tr><tr><th>access</th><td>{{if eq (str (index $room "access")) "members"}}members only{{else}}open{{end}}</td></tr><tr><th>created</th><td>{{short (index $room "created_at")}}</td></tr>{{if $role}}<tr><th>you</th><td>{{$role}}</td></tr>{{end}}</table></section>190-<section><h4>Members</h4><ul id="members">{{range roomMembers .Event}}{{$m := asMap .}}<li{{if index $m "agent"}} data-agent{{with str (index $m "operator")}} data-operator="{{.}}"{{end}}{{end}}>{{template "nostrName" (str (index $m "pubkey"))}} <small>{{if index $m "agent"}}{{if index $m "operator"}}{{template "nostrName" (str (index $m "operator"))}}'s agent{{else}}agent{{end}}{{else}}{{str (index $m "role")}}{{end}}</small></li>{{else}}<li>{{str (index $room "members")}} members</li>{{end}}</ul></section>188+{{else}}{{$data := asMap .Event}}{{$room := asMap (index $data "room")}}{{$id := str (index $room "id")}}{{$role := str (index $room "role")}}<section><h4>Room</h4><table><tr><th>id</th><td><code>{{$id}}</code></td></tr><tr><th>access</th><td><status-badge kind="{{if eq (str (index $room "access")) "members"}}members{{else}}open{{end}}">{{if eq (str (index $room "access")) "members"}}members only{{else}}open{{end}}</status-badge></td></tr><tr><th>created</th><td>{{short (index $room "created_at")}}</td></tr>{{if $role}}<tr><th>you</th><td><status-badge>{{$role}}</status-badge></td></tr>{{end}}</table></section>189+<section><h4>Members</h4><ul id="members">{{range roomMembers .Event}}{{$m := asMap .}}<li{{if index $m "agent"}} data-agent{{with str (index $m "operator")}} data-operator="{{.}}"{{end}}{{end}}>{{template "nostrName" (str (index $m "pubkey"))}} {{if index $m "agent"}}<status-badge kind="agent">{{if index $m "operator"}}{{template "nostrName" (str (index $m "operator"))}}'s agent{{else}}agent{{end}}</status-badge>{{else}}<small>{{str (index $m "role")}}</small>{{end}}</li>{{else}}<li>{{str (index $room "members")}} members</li>{{end}}</ul></section>191190 {{if eq .Tab "thread"}}<section><h4>Actions</h4><ul><li><a href="/rooms/{{$id}}">open room</a></li></ul></section>192191 {{else}}193-{{if .Actor}}<section><h4>Actions</h4>{{if roomAdmin $role}}<details><summary>invite member</summary><room-action room="{{$id}}" kind="9000"><label>pubkey <input name="pubkey" placeholder="npub or 64 hex" required></label><label>role <select name="role"><option value="member">member</option><option value="admin">admin</option>{{if eq $role "owner"}}<option value="owner">owner</option>{{end}}</select></label><button>Add member</button></room-action></details>194-<details><summary>room settings</summary><room-action room="{{$id}}" kind="9002"><label>name <input name="name" value="{{str (index $room "name")}}" required maxlength="200"></label><label>about <input name="about" value="{{str (index $room "about")}}" maxlength="2000"></label><label>picture <input name="picture" value="{{str (index $room "picture")}}" placeholder="https://"></label><label>access <select name="access"><option value="open"{{if ne (str (index $room "access")) "members"}} selected{{end}}>open</option><option value="members"{{if eq (str (index $room "access")) "members"}} selected{{end}}>members only</option></select></label><button>Save</button></room-action></details>195-{{end}}{{if $role}}<room-action room="{{$id}}" kind="9022"><button>Leave room</button></room-action>{{else}}<room-action room="{{$id}}" kind="9021"><button>Join room</button></room-action>{{end}}<noscript><p>Room actions need JavaScript and a connected signer.</p></noscript></section>192+{{if .Actor}}<section><h4>Actions</h4>{{if roomAdmin $role}}<details><summary>Invite member</summary><room-action room="{{$id}}" kind="9000"><label>pubkey <input name="pubkey" placeholder="npub or 64 hex" required></label><label>role <select name="role"><option value="member">member</option><option value="admin">admin</option>{{if eq $role "owner"}}<option value="owner">owner</option>{{end}}</select></label><button data-primary>Add member</button></room-action></details>193+<details><summary>Room settings</summary><room-action room="{{$id}}" kind="9002"><label>name <input name="name" value="{{str (index $room "name")}}" required maxlength="200"></label><label>about <input name="about" value="{{str (index $room "about")}}" maxlength="2000"></label><label>picture <input name="picture" value="{{str (index $room "picture")}}" placeholder="https://"></label><label>access <select name="access"><option value="open"{{if ne (str (index $room "access")) "members"}} selected{{end}}>open</option><option value="members"{{if eq (str (index $room "access")) "members"}} selected{{end}}>members only</option></select></label><button>Save</button></room-action></details>194+{{end}}{{if $role}}<room-action room="{{$id}}" kind="9022"><button data-danger>Leave room</button></room-action>{{else}}<room-action room="{{$id}}" kind="9021"><button data-primary>Join room</button></room-action>{{end}}<noscript><p>Room actions need JavaScript and a connected signer.</p></noscript></section>196195 {{end}}{{end}}{{end}}{{end}}197196 198197 {{/* Shared partials. */}}199198 200-{{define "alert"}}{{if .}}<p role="alert">{{.}}</p>{{end}}{{end}}199+{{define "alert"}}{{if .}}<p role="alert">{{friendlyError .}}</p>{{end}}{{end}}201200 202201 {{define "nostrKey"}}<nostr-key hex="{{.}}" title="{{.}}">{{shortID .}}</nostr-key>{{end}}203202 {{/* nostrName shows a person: the vendored nostr-name element swaps the short id for the profile name published on this relay. */}}@@ -44,7 +44,7 @@ func TestProfilePagePrefillsTheFormAndOffersItFromHome(t *testing.T) {
4444 `<textarea name="about" maxlength="2000">keeps <cats></textarea>`,4545 `<input name="lud16" value="dami@wallet.example"`,4646 "wss://relay.one\nwss://relay.two\n</textarea>",47- `<button>Sign and publish</button>`,47+ `<button data-primary>Sign and publish</button>`,4848 "Current profile published <time",4949 } {5050 if !strings.Contains(body, marker) {@@ -6,18 +6,16 @@
66 {{if .Policy.LanguageTags}}<tr><th>languages</th><td>{{join .Policy.LanguageTags ", "}}</td></tr>{{end}}77 {{if .Policy.RelayCountries}}<tr><th>countries</th><td>{{join .Policy.RelayCountries ", "}}</td></tr>{{end}}88 <tr><th>policies</th><td>{{if .Policy.PostingPolicy}}<a href="{{.Policy.PostingPolicy}}">posting</a> | {{end}}{{if .Policy.PrivacyPolicy}}<a href="{{.Policy.PrivacyPolicy}}">privacy</a> | {{end}}<a href="/terms">terms</a></td></tr>9-<tr><th>reads</th><td>{{.Policy.Reads}}</td></tr>10-<tr><th>writes</th><td>{{.Policy.Writes}}</td></tr>119 </table>1210 {{if .Policy.Description}}<h2>About</h2>1311 {{markdown .Policy.Description}}1412 {{end}}{{if .Connections}}<h2>Connect</h2>15-<p>Open this relay in an app. Each card is a way in; sign in to see the ones made for members.</p>13+<p>Open this relay in an app.</p>1614 {{range .Connections}}{{$c := asMap .}}<connect-card>1715 <h3>{{str (index $c "title")}} <small>{{str (index $c "app")}}{{with str (index $c "where")}}, {{.}}{{end}}{{if ne (str (index $c "visibility")) "public"}} | {{str (index $c "visibility")}}{{end}}</small></h3>1816 <p>{{str (index $c "about")}}</p>1917 {{range index $c "inputs"}}{{$i := asMap .}}<label>{{str (index $i "Label")}} <input name="{{str (index $i "Name")}}" placeholder="{{str (index $i "Placeholder")}}" pattern="{{str (index $i "Pattern")}}"></label>20-{{end}}<p>{{range index $c "links"}}{{$l := asMap .}}{{if str (index $l "href")}}<a href="{{str (index $l "href")}}">{{str (index $l "label")}}</a> {{else}}<button type="button" data-copy="{{str (index $l "copy")}}">{{str (index $l "label")}}</button> {{end}}{{end}}{{if index $c "hidden"}}<small>sign in for {{str (index $c "hidden")}} more</small>{{end}}<output role="status"></output></p>18+{{end}}<p>{{range index $c "links"}}{{$l := asMap .}}{{if str (index $l "href")}}<a href="{{safeURL (str (index $l "href"))}}">{{str (index $l "label")}}</a> {{else}}<button type="button" data-copy="{{str (index $l "copy")}}">{{str (index $l "label")}}</button> {{end}}{{end}}{{if index $c "hidden"}}<small>sign in for {{str (index $c "hidden")}} more</small>{{end}}<output role="status"></output></p>2119 {{with str (index $c "qr")}}<details><summary>QR code</summary><img src="/qr.svg?text={{urlquery .}}" alt="QR code" width="200" height="200"></details>{{end}}2220 </connect-card>2321 {{end}}{{end}}@@ -38,22 +36,21 @@
3836 3937 {{define "account"}}<section id="account">4038 <h1>Account</h1>41-<p>Manage your profile, chat preferences and relay lists.</p>4239 <table id="account-identity"><tbody><tr><th>signed in as</th><td>{{template "nostrName" .Actor}}</td></tr><tr><th>profile</th><td><a href="/profile">Edit your profile</a></td></tr></tbody></table>4340 <h2>Chat preferences</h2>4441 <chat-preferences actor="{{.Actor}}"><label><input type="checkbox" name="share_presence"{{if .SharePresence}} checked{{end}} disabled> Share presence and typing</label><p>Show when you are online or typing in chat.</p><output role="status"></output></chat-preferences><noscript><p>Changing preferences requires JavaScript and a connected signer.</p></noscript>4542 <h2>Your relay lists</h2>46-<p>Choose where other Nostr clients find your notes, messages and files. Existing entries stay.</p>43+<p>Where other clients find your notes, messages and files.</p>4744 <relay-lists relay="{{wsURL .URL}}" server="{{.URL}}" pubkey="{{.Actor}}">4845 <table id="rows"><tbody>49-<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>50-<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>51-<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>52-<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>46+<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>47+<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>48+<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>49+<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>5350 </tbody></table><output role="status"></output></relay-lists>5451 </section>{{end}}5552 56-{{define "signerControls"}}<p><button id="session-login" type="button">{{if .Actor}}Use connected signer{{else}}Sign in with connected signer{{end}}</button></p>53+{{define "signerControls"}}<p><button id="session-login" type="button" data-primary>{{if .Actor}}Use connected signer{{else}}Sign in with connected signer{{end}}</button></p>5754 <h2>Phone signer</h2>5855 <p>Scan the QR code with Amber and approve the connection and sign-in request. You will return to the page you opened.</p>5956 <p><label><input type="checkbox" id="remember-signer"> Remember this device</label> <small>Keeps the phone signer connected after the app closes. Sign out to forget it.</small></p>@@ -69,14 +66,12 @@
6966 7067 {{define "inbox"}}<section>7168 <h1>Inbox</h1>72-<p>Events addressed to this signed-in key, subject to the same private-event gates as websocket queries.</p>7369 {{if .Error}}{{template "alert" .Error}}{{else}}{{template "eventList" .}}{{end}}7470 </section>7571 {{end}}7672 7773 {{define "outbox"}}<section>7874 <h1>Outbox</h1>79-<p>Events authored by this signed-in key.</p>8075 {{if .Error}}{{template "alert" .Error}}{{else}}{{template "eventList" .}}{{end}}8176 </section>8277 {{end}}@@ -97,7 +92,6 @@
9792 9893 {{define "articles"}}<section>9994 <h1>Articles</h1>100-<p>Long-form NIP-23 events published on this relay.</p>10195 {{if .Error}}{{template "alert" .Error}}{{else}}{{template "eventList" .}}{{end}}10296 <p><a href="/feed.xml">Atom feed</a> | <a href="/articles.json">JSON feed</a></p>10397 </section>@@ -117,14 +111,14 @@
117111 {{end}}118112 119113 {{define "invite"}}<section>120-<h1>Join this relay</h1>114+<h1>Join {{or .Policy.Name .Slug}}</h1>121115 <p>This invite is claimed with one signature from your Nostr signer. The relay never receives a private key.</p>122116 {{if .Policy.JoinTerms}}<h2>Before you join</h2>123117 <pre>{{.Policy.JoinTerms}}</pre>124118 {{end}}<rpc-form method="claiminvite" action="/api/invites/claim" terms="{{.Policy.JoinTerms}}">125119 <input type="hidden" name="param" value="{{.Invite}}">126120 {{if .Policy.JoinTerms}}<label><input type="checkbox" name="termsAgreement" required> I agree to these relay terms.</label>127-{{end}}<button>Join relay</button>121+{{end}}<button data-primary>Join relay</button>128122 </rpc-form>129123 </section>130124 {{end}}@@ -9,13 +9,13 @@ establishes the initial state. */}}
99 {{else if .Feed}}<table id="rows">1010 <thead><tr><th>room</th><th>access</th><th>members</th><th>last message</th></tr></thead>1111 <tbody>12-{{range .Feed}}{{$r := asMap .}}{{$id := str (index $r "id")}}{{$picture := socialMediaURL (str (index $r "picture"))}}<tr><td><a href="/rooms/{{$id}}"><room-avatar picture="{{$picture}}" fallback="{{roomAvatarURL $.URL $id}}" aria-hidden="true"><img src="{{if $picture}}{{$picture}}{{else}}{{roomAvatarURL $.URL $id}}{{end}}" alt="" width="32" height="32" loading="lazy"></room-avatar>{{or (str (index $r "name")) $id}}</a></td><td>{{if eq (str (index $r "access")) "members"}}members only{{else}}open{{end}}{{with str (index $r "about")}} <small>{{.}}</small>{{end}}</td><td>{{str (index $r "members")}}</td><td>{{with index $r "last_message_at"}}<time datetime="{{datetime .}}">{{when .}}</time>{{else}}no messages{{end}}</td></tr>12+{{range .Feed}}{{$r := asMap .}}{{$id := str (index $r "id")}}{{$picture := socialMediaURL (str (index $r "picture"))}}<tr><td><a href="/rooms/{{$id}}"><room-avatar picture="{{$picture}}" fallback="{{roomAvatarURL $.URL $id}}" aria-hidden="true"><img src="{{if $picture}}{{$picture}}{{else}}{{roomAvatarURL $.URL $id}}{{end}}" alt="" width="32" height="32" loading="lazy"></room-avatar>{{or (str (index $r "name")) $id}}</a></td><td><status-badge kind="{{if eq (str (index $r "access")) "members"}}members{{else}}open{{end}}">{{if eq (str (index $r "access")) "members"}}members only{{else}}open{{end}}</status-badge>{{with str (index $r "about")}} <small>{{.}}</small>{{end}}</td><td>{{str (index $r "members")}}</td><td>{{with index $r "last_message_at"}}<time datetime="{{datetime .}}">{{when .}}</time>{{else}}no messages{{end}}</td></tr>1313 {{end}}</tbody>1414 </table>1515 {{else}}<p>No rooms yet.</p>1616 {{end}}{{with (asMap .Event).next_cursor}}<p id="pages"><a href="{{browsePageURL "/chat" $.Query "cursor" .}}">next page</a></p>17-{{end}}{{if .Actor}}<h2 id="new-room">New room</h2>18-<room-create><label>Name <input name="name" required maxlength="200" placeholder="build"></label><label>Id <input name="id" maxlength="64" pattern="[a-z0-9_-]{1,64}" placeholder="from the name"></label><label>Access <select name="access"><option value="open">open</option><option value="members">members only</option></select></label><label>About <textarea name="about" maxlength="2000"></textarea></label><button>Create room</button></room-create>17+{{end}}{{if and .Actor (not (restricted .Error))}}<h2 id="new-room">New room</h2>18+<room-create><label>Name <input name="name" required maxlength="200" placeholder="build"></label><label>Id <input name="id" maxlength="64" pattern="[a-z0-9_-]{1,64}" placeholder="from the name"></label><label>Access <select name="access"><option value="open">open</option><option value="members">members only</option></select></label><label>About <textarea name="about" maxlength="2000"></textarea></label><button data-primary>Create room</button></room-create>1919 <noscript><p>Creating a room needs JavaScript and a connected signer.</p></noscript>2020 {{else}}<p><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to create a room.</p>2121 {{end}}@@ -27,7 +27,7 @@ establishes the initial state. */}}
2727 {{if not .Actor}}<p><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to read this room.</p>{{end}}2828 </section>2929 {{else}}{{$data := asMap .Event}}{{$room := asMap (index $data "room")}}{{$id := str (index $room "id")}}{{$picture := socialMediaURL (str (index $room "picture"))}}<section id="room" data-viewer="{{.Actor}}">30-<h1><room-avatar picture="{{$picture}}" fallback="{{roomAvatarURL $.URL $id}}" aria-hidden="true"><img src="{{if $picture}}{{$picture}}{{else}}{{roomAvatarURL $.URL $id}}{{end}}" alt="" width="40" height="40" loading="lazy"></room-avatar>{{or (str (index $room "name")) $id}} <small>{{if eq (str (index $room "access")) "members"}}members only{{else}}open room{{end}}</small></h1>30+<h1><room-avatar picture="{{$picture}}" fallback="{{roomAvatarURL $.URL $id}}" aria-hidden="true"><img src="{{if $picture}}{{$picture}}{{else}}{{roomAvatarURL $.URL $id}}{{end}}" alt="" width="40" height="40" loading="lazy"></room-avatar>{{or (str (index $room "name")) $id}} <status-badge kind="{{if eq (str (index $room "access")) "members"}}members{{else}}open{{end}}">{{if eq (str (index $room "access")) "members"}}members only{{else}}open{{end}}</status-badge></h1>3131 {{with str (index $room "about")}}<p>{{.}}</p>{{end}}3232 {{with index $data "next_cursor"}}<p id="pages"><a href="/rooms/{{$id}}?cursor={{urlquery (str .)}}">load earlier</a></p>{{end}}3333 {{if .Query.Get "cursor"}}<p id="newest"><a href="/rooms/{{$id}}">newest messages</a></p>{{end}}@@ -132,7 +132,7 @@ func roomsPage(t *testing.T, app *App, path string) string {
132132 func TestRoomsListRendersRoomsRailAndCreateForm(t *testing.T) {133133 app, backend := roomsApp(t, roomOwner)134134 body := roomsPage(t, app, "/chat")135- for _, want := range []string{`<table id="rows">`, `</room-avatar>General</a>`, `</room-avatar>Build</a>`, `members only <small>Where Build happens.</small>`, `<room-create>`, `<a href="/chat"><b>chat</b></a>`, `<nav id="room-list"><a href="/rooms/general"><room-avatar`, `<a href="/chat#new-room">+ new room</a>`, `<p id="rail-tools"><a href="/">← relay</a>`, `<h4>Rooms</h4>`, `<td>2</td>`} {135+ for _, want := range []string{`<table id="rows">`, `</room-avatar>General</a>`, `</room-avatar>Build</a>`, `<status-badge kind="members">members only</status-badge> <small>Where Build happens.</small>`, `<room-create>`, `<a href="/chat"><b>chat</b></a>`, `<nav id="room-list"><a href="/rooms/general"><room-avatar`, `<a href="/chat#new-room">+ new room</a>`, `<p id="rail-tools"><a href="/">← relay</a>`, `<h4>Rooms</h4>`, `<td>2</td>`} {136136 if !strings.Contains(body, want) {137137 t.Errorf("rooms list missing %q", want)138138 }@@ -158,7 +158,7 @@ func TestRoomPageRendersMessagesOldestFirstWithMarkers(t *testing.T) {
158158 app, backend := roomsApp(t, roomOwner)159159 body := roomsPage(t, app, "/rooms/general")160160 for _, want := range []string{161- `</room-avatar>General <small>open room</small></h1>`, `<p>Where General happens.</p>`,161+ `</room-avatar>General <status-badge kind="open">open</status-badge></h1>`, `<p>Where General happens.</p>`,162162 `<a href="/rooms/general?cursor=cursor-1">load earlier</a>`,163163 `<room-message id="msg-` + roomHello + `" data-id="` + roomHello + `" data-kind="9" data-updated-at="1757203650" data-pubkey="` + roomAgent + `" data-agent data-edited>`,164164 `<nostr-avatar pubkey="` + roomAgent + `" aria-hidden="true"><img src="` + avatarURL(roomAgent) + `" alt="" width="32" height="32" loading="lazy"></nostr-avatar>`,@@ -176,7 +176,7 @@ func TestRoomPageRendersMessagesOldestFirstWithMarkers(t *testing.T) {
176176 `<room-live room="general"></room-live>`,177177 `<room-compose room="general" pubkey="` + roomOwner + `">`,178178 `<noscript><p>Sending needs JavaScript and a connected signer.</p></noscript>`,179- `<ul id="members"><li><nostr-name pubkey="` + roomOwner + `"`, `<li data-agent data-operator="` + roomOwner + `"><nostr-name pubkey="` + roomAgent + `"`, `<small><nostr-name pubkey="` + roomOwner + `" title="` + roomOwner + `">` + shortID(roomOwner) + `</nostr-name>'s agent</small>`,179+ `<ul id="members"><li><nostr-name pubkey="` + roomOwner + `"`, `<li data-agent data-operator="` + roomOwner + `"><nostr-name pubkey="` + roomAgent + `"`, `<status-badge kind="agent"><nostr-name pubkey="` + roomOwner + `" title="` + roomOwner + `">` + shortID(roomOwner) + `</nostr-name>'s agent</status-badge>`,180180 `<room-action room="general" kind="9000">`, `<room-action room="general" kind="9002">`, `<room-action room="general" kind="9022">`,181181 `<nav id="room-list"><a href="/rooms/general" aria-current="page"><room-avatar`,182182 `» <page-link url="http://relay.example/rooms/general" title="Copy the page address">rooms/general</page-link></span>`,@@ -205,7 +205,7 @@ func TestRoomPageRendersMessagesOldestFirstWithMarkers(t *testing.T) {
205205 func TestMembersOnlyRoomIsHiddenFromGuests(t *testing.T) {206206 guestApp, _ := roomsApp(t, "")207207 body := roomsPage(t, guestApp, "/rooms/build")208- if !strings.Contains(body, `<p role="alert">auth-required: this room is members-only</p>`) || strings.Contains(body, "<room-message") || strings.Contains(body, "<room-compose") || !strings.Contains(body, `<a href="/signin?next=%2Frooms%2Fbuild">Sign in</a> to read this room.`) {208+ if !strings.Contains(body, `<p role="alert">This room is members-only. Sign in to read it.</p>`) || strings.Contains(body, "<room-message") || strings.Contains(body, "<room-compose") || !strings.Contains(body, `<a href="/signin?next=%2Frooms%2Fbuild">Sign in</a> to read this room.`) {209209 t.Fatalf("guest saw a members-only room: %s", body[strings.Index(body, `id="content"`):])210210 }211211 if !strings.Contains(body, `<h1>#build</h1>`) || strings.Contains(body, "Where Build happens") {@@ -213,7 +213,7 @@ func TestMembersOnlyRoomIsHiddenFromGuests(t *testing.T) {
213213 }214214 app, _ := roomsApp(t, roomOwner)215215 body = roomsPage(t, app, "/rooms/build")216- if !strings.Contains(body, `</room-avatar>Build <small>members only</small></h1>`) || !strings.Contains(body, `id="msg-`+roomHello) {216+ if !strings.Contains(body, `</room-avatar>Build <status-badge kind="members">members only</status-badge></h1>`) || !strings.Contains(body, `id="msg-`+roomHello) {217217 t.Fatal("owner did not see the members-only room")218218 }219219 }@@ -112,7 +112,6 @@
112112 {{define "panel-social"}}113113 {{if eq .Tab "social-thread"}}<section><h4>Conversation</h4><p>Follow the discussion or add a reply of your own.</p><p><a href="/social">Back to feed</a></p></section>114114 {{else if eq .Tab "social-profile"}}<section><h4>Profile</h4>{{if .View}}<p>This person’s posts, newest first.</p><p><a href="{{podcastFeedURL .Query}}">Subscribe to podcasts</a></p>{{else}}<p>Open your profile or look up another person.</p>{{end}}{{if .Actor}}<p><a href="/social/profile">Your profile</a></p>{{end}}<p><a href="/social">Browse the feed</a></p></section>115-{{else}}{{$view := socialView .Query}}<section><h4>{{$view.Label}}</h4><p>{{$view.Summary}}</p><p>Newest first. Replies and reactions stay with their posts.</p></section>116-<section><h4>Find a post</h4><form action="/social" method="get"><input type="hidden" name="kind" value="{{$view.Key}}"><label>Search <input name="q" type="search" value="{{.Query.Get "q"}}" placeholder="Words or a title"></label><label>Author <input name="author" value="{{.Query.Get "author"}}" placeholder="Public key"></label><button>Search</button> <a href="{{$view.URL}}">Clear</a></form></section>{{end}}115+{{else}}{{$view := socialView .Query}}<section><h4>Find a post</h4><form action="/social" method="get"><input type="hidden" name="kind" value="{{$view.Key}}"><label>Search <input name="q" type="search" value="{{.Query.Get "q"}}" placeholder="Words or a title"></label><label>Author <input name="author" value="{{.Query.Get "author"}}" placeholder="Public key"></label><button>Search</button> <a href="{{$view.URL}}">Clear</a></form></section>{{end}}117116 <section><h4>Follow the feed</h4><p><a href="/social.xml">Atom</a> | <a href="/social.json">JSON Feed</a></p></section>118-{{if not .Actor}}<section><h4>Join in</h4><p><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to post, reply and react.</p></section>{{end}}{{end}}117+{{end}}@@ -46,7 +46,7 @@
4646 <label>Name <input name="name" value="{{$d}}" required autocomplete="off" spellcheck="false"></label>4747 <label>Summary <input name="summary" value="{{str (index $w.Version "summary")}}" autocomplete="off"></label>4848 <label>Content <textarea name="content" rows="16" placeholder="Djot markup: # heading, *emphasis*, [[Page Name]]">{{str (index $w.Version "content")}}</textarea></label>49-<button name="action" value="publish">Publish</button>{{if and $w.Author (ne $w.Author .Actor)}} <button name="action" value="propose">Propose to {{shortID $w.Author}}</button>{{end}}49+<button name="action" value="publish" data-primary>Publish</button>{{if and $w.Author (ne $w.Author .Actor)}} <button name="action" value="propose">Propose to {{shortID $w.Author}}</button>{{end}}5050 </wiki-compose><noscript><p>JavaScript and a connected signer are required to publish.</p></noscript>{{end}}5151 5252 {{/* wikiCompare shows the proposed version of a merge request above the current one. */}}@@ -264,7 +264,7 @@ func TestWikiGuestAccessFollowsTheReadsPolicy(t *testing.T) {
264264 b := &wikiBackend{fakeBackend: fakeBackend{policy: members}, private: true}265265 for _, path := range []string{"/wiki", "/wiki/release-notes-1-4"} {266266 body := wikiGet(t, wikiApp(t, b, ""), path)267- wantAll(t, path, body, `<p role="alert">auth-required: sign in to read this relay</p>`, `browsing as guest`)267+ wantAll(t, path, body, `<p role="alert">Sign in to read this relay.</p>`, `browsing as guest`)268268 wantNone(t, path, body, "<wiki-article>", "<merge-request>", "Release notes 1.4", "<wiki-compose")269269 body = wikiGet(t, wikiApp(t, b, wikiOther), path)270270 wantAll(t, path, body, "Release notes 1.4")