- commit
- 361beb5a8ad9517ad535e21f550299b1a0f4f4ec
- parent
- f9f2a9a9c04086aec81e55d05ca43c317169f94b
- author
- Dami <dami.etoile@gmail.com>
| A | internal/ | 154 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 126 | +++++++++++++++++++++++++ |
| M | internal/ | 22 | ++++ |
| M | internal/ | 119 | ++++++++++++++++++++++-- |
| M | internal/ | 5 | + |
| M | internal/ | 16 | +++- |
| M | internal/ | 5 | +- |
| M | internal/ | 21 | ++++- |
@@ -0,0 +1,154 @@
1+package webui2+3+import (4+ "context"5+ "encoding/json"6+ "strings"7+)8+9+// approvalView is one request as the Approvals page renders it. The backend10+// answers browseapprovals with typed items; this flattens what the template11+// needs so the markup stays plain.12+type approvalView struct {13+ ID, Kind, Type, State, Asker string14+ CreatedAt, Expires int6415+ // Where the request was made: the room, the repository or the relay.16+ Where string17+ // Title is the subject, or the content when there is no subject; Body is18+ // the content when a subject is present.19+ Title, Body string20+ // About summarizes the a or e reference in one mono line, and Open is21+ // the relay path that shows it.22+ About, Open, Coordinate string23+ Decision string24+ AnsweredAt int6425+ AnswerContent string26+}27+28+// approvalViews returns the items in the given states, in the order the29+// backend listed them.30+func approvalViews(value any, base string, states ...string) []approvalView {31+ var views []approvalView32+ for _, raw := range collaborationSlice(value, "items") {33+ item := valueMap(raw)34+ state := plainString(item["state"])35+ wanted := len(states) == 036+ for _, s := range states {37+ wanted = wanted || s == state38+ }39+ if !wanted {40+ continue41+ }42+ views = append(views, approvalViewFrom(item, base))43+ }44+ return views45+}46+47+func approvalViewFrom(item map[string]any, base string) approvalView {48+ view := approvalView{ID: plainString(item["id"]), Kind: plainString(item["kind"]), Type: plainString(item["type"]), State: plainString(item["state"]), Asker: plainString(item["asker"]), CreatedAt: unixSeconds(item["created_at"]), Expires: unixSeconds(item["expires"])}49+ subject, content := strings.TrimSpace(plainString(item["subject"])), strings.TrimSpace(plainString(item["content"]))50+ view.Title, view.Body = subject, content51+ if subject == "" {52+ view.Title, view.Body = content, ""53+ }54+ view.Where = "the relay"55+ view.Open = "/e/" + view.ID56+ if about := valueMap(item["about"]); len(about) > 0 {57+ coordinate, referenced, kind := plainString(about["coordinate"]), plainString(about["event"]), plainString(about["kind"])58+ var parts []string59+ if coordinate != "" {60+ parts = append(parts, "about "+coordinate)61+ if fields := strings.SplitN(coordinate, ":", 3); len(fields) == 3 && fields[0] == "30617" {62+ view.Coordinate = coordinate63+ view.Where = "repository " + fields[2]64+ }65+ }66+ if referenced != "" {67+ label := "event " + shortID(referenced)68+ if kind != "" {69+ label = "kind " + kind + " " + shortID(referenced)70+ }71+ parts = append(parts, label)72+ }73+ view.About = strings.Join(parts, " | ")74+ if url := plainString(about["url"]); url != "" {75+ view.Open = relayPath(url, base)76+ }77+ }78+ if room := plainString(item["room"]); room != "" {79+ view.Where = "#" + room80+ }81+ if answer := valueMap(item["answer"]); len(answer) > 0 {82+ view.Decision, view.AnsweredAt, view.AnswerContent = plainString(answer["decision"]), unixSeconds(answer["created_at"]), strings.TrimSpace(plainString(answer["content"]))83+ }84+ return view85+}86+87+// includeApproval makes sure the request a notification opened is on the88+// page: when the listing does not carry it, the single request is read and89+// placed first. A request that cannot be read leaves the listing as it is.90+func (a *App) includeApproval(ctx context.Context, actor string, result any, id string) any {91+ page := valueMap(result)92+ items := collaborationSlice(result, "items")93+ for _, item := range items {94+ if plainString(valueMap(item)["id"]) == id {95+ return result96+ }97+ }98+ raw, _ := json.Marshal(map[string]any{"id": id})99+ detail, err := a.backend.Query(ctx, "browseapproval", []json.RawMessage{raw}, actor)100+ if err != nil {101+ return result102+ }103+ item := valueMap(detail)["item"]104+ if item == nil {105+ return result106+ }107+ page["items"] = append([]any{item}, items...)108+ return page109+}110+111+// relayPath turns an absolute relay URL into the path the page links to, so112+// tenant prefixes are applied once when the page is written.113+func relayPath(url, base string) string {114+ base = strings.TrimRight(base, "/")115+ if base != "" && strings.HasPrefix(url, base+"/") {116+ return strings.TrimPrefix(url, base)117+ }118+ if i := strings.Index(url, "://"); i >= 0 {119+ if slash := strings.Index(url[i+3:], "/"); slash >= 0 {120+ return url[i+3+slash:]121+ }122+ return "/"123+ }124+ return url125+}126+127+// approvalCounts reads the counts object the backend returns beside the128+// items, defaulting every state to zero.129+func approvalCounts(value any) map[string]int {130+ counts := map[string]int{"open": 0, "answered": 0, "expired": 0}131+ encoded, err := json.Marshal(valueMap(value)["counts"])132+ if err == nil {133+ _ = json.Unmarshal(encoded, &counts)134+ }135+ return counts136+}137+138+// approvalDevices lists the caller's devices as "categories" strings.139+func approvalDevices(value any) []string {140+ var devices []string141+ for _, raw := range collaborationSlice(value, "devices") {142+ var categories []string143+ encoded, err := json.Marshal(valueMap(raw)["categories"])144+ if err == nil {145+ _ = json.Unmarshal(encoded, &categories)146+ }147+ if len(categories) == 0 {148+ devices = append(devices, "no categories")149+ continue150+ }151+ devices = append(devices, strings.Join(categories, ", "))152+ }153+ return devices154+}@@ -0,0 +1,126 @@
1+package webui2+3+import (4+ "context"5+ "encoding/json"6+ "errors"7+ "net/http"8+ "net/http/httptest"9+ "strings"10+ "testing"11+12+ "github.com/FelineStateMachine/tinyrelay/internal/policy"13+)14+15+type approvalsBackend struct {16+ *fakeBackend17+ calls []string18+}19+20+func (b *approvalsBackend) Query(_ context.Context, method string, params []json.RawMessage, actor string) (any, error) {21+ b.calls = append(b.calls, method)22+ b.params = params23+ if actor == "" {24+ return nil, errors.New("auth-required: sign in to see requests addressed to you")25+ }26+ asker := strings.Repeat("c", 64)27+ open := map[string]any{"id": strings.Repeat("1", 64), "kind": 1111, "type": "approve", "asker": asker, "subject": "Publish release notes 1.4", "content": "Publish release notes 1.4 to the articles feed as drafted?", "created_at": 1788876000, "expires": 1788897600, "state": "open",28+ "about": map[string]any{"coordinate": "30617:" + b.policy.Owner + ":tinyrelay", "event": strings.Repeat("d", 64), "kind": "1621", "url": "http://relay.example/repo?owner=" + b.policy.Owner + "&repo=tinyrelay&view=issue&id=" + strings.Repeat("d", 64)}}29+ question := map[string]any{"id": strings.Repeat("2", 64), "kind": 9, "type": "question", "asker": asker, "content": "Mention the edge?", "created_at": 1788876100, "state": "open", "room": "build"}30+ answered := map[string]any{"id": strings.Repeat("3", 64), "kind": 1111, "type": "decide", "asker": asker, "subject": "Close issue 41 as duplicate of 12", "content": "", "created_at": 1788870000, "state": "answered", "answer": map[string]any{"id": strings.Repeat("e", 64), "kind": 7, "author": b.policy.Owner, "decision": "approved", "content": "+", "created_at": 1788871000}}31+ expired := map[string]any{"id": strings.Repeat("4", 64), "kind": 1111, "type": "approve", "asker": asker, "content": "Create room #release", "created_at": 1788860000, "expires": 1788863600, "state": "expired"}32+ switch method {33+ case "browseapprovals":34+ return map[string]any{"items": []any{open, question, answered, expired}, "next_cursor": "", "counts": map[string]int{"open": 2, "answered": 1, "expired": 1}, "oldest_open": 1788876000, "devices": []any{map[string]any{"created_at": 1788800000, "categories": []string{"approvals", "mentions"}}}}, nil35+ case "browseapproval":36+ var q map[string]any37+ _ = json.Unmarshal(params[0], &q)38+ if q["id"] != strings.Repeat("5", 64) {39+ return nil, errors.New("not found: request")40+ }41+ return map[string]any{"item": map[string]any{"id": strings.Repeat("5", 64), "kind": 11, "type": "decide", "asker": asker, "subject": "Older request", "content": "Body", "created_at": 1788000000, "state": "open"}, "answers": []any{}}, nil42+ }43+ return map[string]any{}, nil44+}45+46+func TestApprovalsPageRendersRequestsForTheOwner(t *testing.T) {47+ backend := &approvalsBackend{fakeBackend: &fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}48+ app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }})49+ if err != nil {50+ t.Fatal(err)51+ }52+ recorder := httptest.NewRecorder()53+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/approvals", nil))54+ body := recorder.Body.String()55+ if recorder.Code != http.StatusOK {56+ t.Fatalf("status %d: %s", recorder.Code, body)57+ }58+ var params map[string]any59+ if err := json.Unmarshal(backend.params[0], ¶ms); err != nil || params["state"] != "all" || params["limit"] != 50.0 {60+ t.Fatalf("browse params %s %v", backend.params[0], err)61+ }62+ asker := strings.Repeat("c", 64)63+ for _, marker := range []string{64+ `<approval-item id="approval-` + strings.Repeat("1", 64) + `" data-type="approve" data-state="open">`,65+ `<nostr-key hex="` + asker + `"`, "in repository tinyrelay", "expires <time",66+ "<p>Publish release notes 1.4</p>", "<p>Publish release notes 1.4 to the articles feed as drafted?</p>",67+ "<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="answer" value="+">Approve</button> <button name="answer" value="-">Deny</button></nostr-react>`,69+ `<nostr-compose kind="1111" root="` + strings.Repeat("1", 64) + `" root-pubkey="` + asker + `" root-kind="1111" coordinate="30617:` + backend.policy.Owner + `:tinyrelay">`,70+ `<a href="/repo?owner=` + backend.policy.Owner + `&repo=tinyrelay&view=issue&id=` + strings.Repeat("d", 64) + `">open</a>`,71+ `<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.",74+ `<a href="/approvals" aria-current="page">/approvals</a>`,75+ } {76+ if !strings.Contains(body, marker) {77+ t.Fatalf("missing %q in\n%s", marker, body)78+ }79+ }80+ // The question type offers no decision buttons, only a reply.81+ question := body[strings.Index(body, `id="approval-`+strings.Repeat("2", 64)):]82+ question = question[:strings.Index(question, "</approval-item>")]83+ if strings.Contains(question, "<nostr-react") || !strings.Contains(question, `<nostr-compose kind="1111" root="`+strings.Repeat("2", 64)+`" root-pubkey="`+asker+`" root-kind="9">`) {84+ t.Fatalf("question controls:\n%s", question)85+ }86+ if strings.Contains(body, "class=") {87+ t.Fatal("page carries class attributes")88+ }89+ rail := body[strings.Index(body, `id="railbox"`):strings.Index(body, `id="rail-tools"`)]90+ if strings.Index(rail, "/inbox") > strings.Index(rail, "/approvals") || strings.Index(rail, "/approvals") > strings.Index(rail, "/repos") {91+ t.Fatalf("rail order:\n%s", rail)92+ }93+94+ // A notification opens one request; when it has left the first page it95+ // is read on its own and shown first.96+ recorder = httptest.NewRecorder()97+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/approvals?id="+strings.Repeat("5", 64)+"&answer=approve", nil))98+ body = recorder.Body.String()99+ if !strings.Contains(strings.Join(backend.calls, ","), "browseapprovals,browseapproval") {100+ t.Fatalf("calls %v", backend.calls)101+ }102+ first := strings.Index(body, `id="approval-`+strings.Repeat("5", 64)+`"`)103+ if first < 0 || first > strings.Index(body, `id="approval-`+strings.Repeat("1", 64)+`"`) || !strings.Contains(body, "<p>Older request</p><p>Body</p>") {104+ t.Fatalf("focused request missing or not first:\n%s", body)105+ }106+}107+108+func TestApprovalsPageAsksGuestsToSignIn(t *testing.T) {109+ backend := &approvalsBackend{fakeBackend: &fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}110+ app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return "", nil }})111+ if err != nil {112+ t.Fatal(err)113+ }114+ recorder := httptest.NewRecorder()115+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/approvals", nil))116+ body := recorder.Body.String()117+ if recorder.Code != http.StatusOK || !strings.Contains(body, `<a href="/signin">Sign in</a> to see the requests addressed to your key.`) {118+ t.Fatalf("guest page %d:\n%s", recorder.Code, body)119+ }120+ if strings.Contains(body, "<approval-item") || strings.Contains(body, "auth-required") || strings.Contains(body, "role=\"alert\"") {121+ t.Fatalf("guest page leaks requests or the error:\n%s", body)122+ }123+ if !strings.Contains(body, "Sign in to see which devices are notified.") || !strings.Contains(body, "<th>open</th><td>0</td>") {124+ t.Fatalf("guest panel:\n%s", body)125+ }126+}@@ -50,6 +50,28 @@
5050 {{end}}{{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>5151 {{end}}{{end}}5252 53+{{define "approvals"}}<section>54+<h1>Approvals</h1>55+<p>Requests that wait for your decision. Each answer is a signed reaction; a reply opens the thread.</p>56+{{if not .Actor}}<p><a href="/signin">Sign in</a> to see the requests addressed to your key.</p>57+{{else if .Error}}{{template "alert" .Error}}58+{{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}}">59+<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>60+<p>{{$item.Title}}</p>{{with $item.Body}}<p>{{.}}</p>{{end}}{{with $item.About}}<pre>{{.}}</pre>{{end}}61+<footer>{{if ne $item.Type "question"}}<nostr-react event="{{$item.ID}}" pubkey="{{$item.Asker}}" kind="{{$item.Kind}}"><button name="answer" value="+">Approve</button> <button name="answer" value="-">Deny</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>62+</approval-item>63+{{end}}{{else}}<p>Nothing waits for your decision.</p>64+{{end}}{{if $done}}<h3>Answered</h3>65+<table id="rows">66+<thead><tr><th>asker</th><th>request</th><th>answer</th></tr></thead>67+<tbody>68+{{range $item := $done}}<tr id="approval-{{$item.ID}}" data-state="{{$item.State}}"><td>{{template "nostrKey" $item.Asker}}</td><td><a href="{{$item.Open}}">{{$item.Title}}</a>{{with $item.AnswerContent}} <small>{{.}}</small>{{end}}</td><td>{{if eq $item.State "expired"}}expired{{else}}{{$item.Decision}} | <time datetime="{{datetime $item.AnsweredAt}}">{{when $item.AnsweredAt}}</time>{{end}}</td></tr>69+{{end}}</tbody>70+</table>71+{{end}}{{with (asMap .Event).next_cursor}}<p id="pages"><a href="{{browsePageURL "/approvals" $.Query "cursor" .}}">next page</a></p>72+{{end}}<noscript><p>Approve and Deny need JavaScript and a connected signer.</p></noscript>{{end}}</section>73+{{end}}74+5375 {{define "sites"}}<section>5476 <h1>Sites</h1>5577 <p>Static sites published to this relay as signed manifests. Each site is served on its own host.</p>@@ -27,9 +27,16 @@
2727 // <file-tools hash="…" type="…" size="…">2828 // Shares one stored file: copies its Blossom URI or share link, decrypts2929 // a share link fragment and sends a random-key file as a NIP-17 message.30-// <nostr-compose kind="…" coordinate="30617:…" [root="…" root-pubkey="…" root-kind="…"] [parent="…" …]>30+// <nostr-compose kind="…" [coordinate="30617:…"] [root="…" root-pubkey="…" root-kind="…"] [parent="…" …]>3131 // Signs a NIP-34 issue, pull request, NIP-22 reply or status event and32-// posts it to the relay.32+// posts it to the relay. A reply outside a repository omits the coordinate.33+// <nostr-react event="…" pubkey="…" [kind="…"]>34+// Answers a request for a decision with a signed kind 7 reaction: the35+// Approve button sends +, Deny sends -. The prompt method asks for a36+// visible confirmation first, which the answer link from a notification uses.37+// <approval-item id="approval-…">38+// One request on the Approvals page. When the page opens with the item's39+// id and an answer in the query, it scrolls into view and offers that answer.3340 // <share-link [url="…"] [title="…"]>3441 // The device share sheet for the page; empty without browser support.3542 // <push-toggle>@@ -341,12 +348,14 @@
341348 const kind = Number(mode === "status" ? value("status") : mode);342349 if (![1621, 1618, 1111, 1630, 1631, 1632, 1633].includes(kind)) throw Error("Choose a valid event type.");343350 const coordinate = this.getAttribute("coordinate") || "";344- if (!/^30617:[0-9a-f]{64}:.+$/.test(coordinate)) throw Error("The repository address is missing.");345- const tags = [["a", coordinate]];351+ // A reply to a request for a decision may sit outside any repository.352+ if (coordinate ? !/^30617:[0-9a-f]{64}:.+$/.test(coordinate) : kind !== 1111) throw Error("The repository address is missing.");353+ const tags = coordinate ? [["a", coordinate]] : [];346354 if (kind === 1111 || kind >= 1630) {347355 const root = this.getAttribute("root"), pubkey = this.getAttribute("root-pubkey");348356 const rootKind = this.getAttribute("root-kind");349- if (!isHex64(root) || !isHex64(pubkey) || !["1617", "1618", "1621"].includes(rootKind)) throw Error("The conversation address is missing.");357+ const rootKinds = coordinate ? ["1617", "1618", "1621"] : ["1111", "9", "11"];358+ if (!isHex64(root) || !isHex64(pubkey) || !rootKinds.includes(rootKind)) throw Error("The conversation address is missing.");350359 if (kind === 1111) {351360 const parent = this.getAttribute("parent") || root;352361 const parentPubkey = this.getAttribute("parent-pubkey") || pubkey;@@ -553,10 +562,102 @@
553562 }554563 }555564 565+ // NostrReact answers a request for a decision. The reaction names the566+ // request with e and the asker with p, so the asker's own subscription567+ // sees the answer. Nothing is signed without a press: the buttons submit568+ // the form, and prompt() asks once more before signing on behalf of a569+ // notification action.570+ const publishSigned = async unsigned => {571+ if (!window.nostr?.signEvent) throw Error("Connect a signer first.");572+ const expected = JSON.stringify(unsigned);573+ const event = await window.nostr.signEvent(JSON.parse(expected));574+ const actual = event && JSON.stringify({kind: event.kind, created_at: event.created_at, tags: event.tags, content: event.content});575+ if (actual !== expected || !window.NostrSigner?.verifyEvent(event)) throw Error("The signer returned an invalid or changed event.");576+ const response = await tiny.signedFetch("/events", "POST", JSON.stringify(event), {contentType: "application/json"});577+ const result = await response.json();578+ if (!response.ok || result.accepted !== true) throw Error(result.error || result.message || "The relay rejected the event.");579+ return event;580+ };581+ const decisions = {"+": "approve", "-": "deny"};582+ class NostrReact extends FormElement {583+ connectedCallback() {584+ if (this.form) return;585+ super.connectedCallback();586+ if (this.listening) return;587+ this.listening = true;588+ this.addEventListener("click", event => {589+ const button = event.target.closest("button[name=answer]");590+ if (button) this.choice = button.value;591+ });592+ }593+594+ async submit(form, choice = this.choice) {595+ const id = this.getAttribute("event"), pubkey = this.getAttribute("pubkey");596+ if (!isHex64(id) || !isHex64(pubkey)) throw Error("The request address is missing.");597+ if (!(choice in decisions)) throw Error("Choose Approve or Deny.");598+ const tags = [["e", id, "", pubkey], ["p", pubkey]];599+ const kind = this.getAttribute("kind");600+ if (/^\d+$/.test(kind || "")) tags.push(["k", kind]);601+ this.report("Signing…");602+ await publishSigned({kind: 7, created_at: Math.floor(Date.now() / 1000), tags, content: choice});603+ this.choice = null;604+ this.report(choice === "+" ? "Approved." : "Denied.");605+ await tiny.navigate?.((globalThis.location?.href || "").split("?", 1)[0]);606+ }607+608+ // prompt shows the confirmation for one answer; the signature waits for609+ // the confirm button. Without a signer it waits for one to connect.610+ prompt(answer) {611+ const choice = Object.keys(decisions).find(key => decisions[key] === answer);612+ if (!choice) return;613+ if (!window.nostr?.signEvent) {614+ this.report("Connect a signer to answer this request.");615+ document.addEventListener("tiny:signer", () => this.prompt(answer), {once: true});616+ return;617+ }618+ const question = el("span", (answer === "approve" ? "Approve" : "Deny") + " this request? ");619+ const confirm = el("button", "Confirm");620+ confirm.type = "button";621+ const cancel = el("button", "Cancel");622+ cancel.type = "button";623+ confirm.addEventListener("click", () => {624+ this.busy(true);625+ confirm.disabled = cancel.disabled = true;626+ this.submit(this.form, choice).catch(err => this.report("Error: " + err.message, true)).finally(() => this.busy(false));627+ });628+ cancel.addEventListener("click", () => this.report(""));629+ this.output.replaceChildren(question, confirm, " ", cancel);630+ delete this.output.dataset.error;631+ confirm.focus();632+ }633+ }634+635+ // ApprovalItem reacts to the query a notification action opens the page636+ // with: id names the request and answer is approve, deny or reply.637+ class ApprovalItem extends HTMLElement {638+ connectedCallback() {639+ if (this.bound) return;640+ this.bound = true;641+ const params = new URLSearchParams(location.search);642+ if (this.id !== "approval-" + params.get("id")) return;643+ this.setAttribute("data-focus", "");644+ this.scrollIntoView?.({block: "center"});645+ const answer = params.get("answer");646+ if (answer === "reply") {647+ const details = this.querySelector("details");648+ if (details) details.open = true;649+ this.querySelector("textarea")?.focus();650+ } else if (answer === "approve" || answer === "deny") {651+ // The reaction element must have wrapped its form first.652+ queueMicrotask(() => this.querySelector("nostr-react")?.prompt(answer));653+ }654+ }655+ }656+556657 // PushToggle opts this browser into relay notifications. Nothing happens557658 // until the button is pressed: permission is requested then, and the558659 // device subscription is registered with a signed request.559- const pushCategories = [["messages", "private messages"], ["replies", "replies to you"], ["mentions", "mentions"], ["relay", "relay notices"]];660+ const pushCategories = [["messages", "private messages"], ["replies", "replies to you"], ["mentions", "mentions"], ["approvals", "requests for a decision"], ["relay", "relay notices"]];560661 class PushToggle extends HTMLElement {561662 connectedCallback() {562663 if (this.bound) return;@@ -997,6 +1098,8 @@
9971098 customElements.define("publish-list", PublishList);9981099 customElements.define("nostr-compose", NostrCompose);9991100 customElements.define("agent-grant", AgentGrant);1101+ customElements.define("nostr-react", NostrReact);1102+ customElements.define("approval-item", ApprovalItem);10001103 customElements.define("push-toggle", PushToggle);10011104 customElements.define("share-link", ShareLink);10021105 customElements.define("nostr-key", NostrKey);@@ -1027,7 +1130,7 @@
10271130 if (root && !(url.pathname === root || url.pathname.startsWith(root + "/"))) return null;10281131 return root ? url.pathname.slice(root.length) || "/" : url.pathname;10291132 };1030- const allowedRoute = path => /^(?:\/(?:inbox|outbox|search|articles|private|chat|media|sites|marmot|grasp|terms|signin|connect|tools|repo|repos|file|files)?\/?|\/manage(?:\/(?:people|agents|moderation|rules|identity|connect|data|sync|views|health|owner|status))?\/?|\/(?:invite|e|a)\/.+)$/.test(path || "");1133+ const allowedRoute = path => /^(?:\/(?:inbox|approvals|outbox|search|articles|private|chat|media|sites|marmot|grasp|terms|signin|connect|tools|repo|repos|file|files)?\/?|\/manage(?:\/(?:people|agents|moderation|rules|identity|connect|data|sync|views|health|owner|status))?\/?|\/(?:invite|e|a)\/.+)$/.test(path || "");10311134 let navigationSerial = 0, activeAbort;10321135 const streams = new Set();10331136 const closeStreams = () => {@@ -1042,7 +1145,7 @@
10421145 document.addEventListener("fx:sse:close", event => streams.delete(event.detail.cfg));10431146 window.addEventListener("pagehide", closeStreams);10441147 const repairComponents = () => {1045- document.querySelectorAll("rpc-form,signed-form,publish-list,agent-grant").forEach(node => {1148+ document.querySelectorAll("rpc-form,signed-form,publish-list,agent-grant,nostr-react").forEach(node => {10461149 if (node.form?.isConnected) return;10471150 node.form = null;10481151 node.output = null;@@ -53,6 +53,7 @@
5353 {{else if eq .Tab "repo"}}{{template "repo" .}}5454 {{else if eq .Tab "files"}}{{template "files" .}}5555 {{else if eq .Tab "file"}}{{template "file" .}}56+{{else if eq .Tab "approvals"}}{{template "approvals" .}}5657 {{else if eq .Tab "event"}}{{template "event" .}}5758 {{else if eq .Tab "view"}}{{template "view" .}}5859 {{else if eq .Tab "invite"}}{{template "invite" .}}@@ -94,6 +95,7 @@
9495 {{else if or (eq .Tab "inbox") (eq .Tab "outbox") (eq .Tab "search") (eq .Tab "articles")}}{{template "panel-feed" .}}9596 {{else if eq .Tab "repos"}}{{template "panel-repos" .}}9697 {{else if or (eq .Tab "files") (eq .Tab "file")}}{{template "panel-files" .}}98+{{else if eq .Tab "approvals"}}{{template "panel-approvals" .}}9799 {{else if eq .Tab "signin"}}{{template "panel-signin" .}}98100 {{else}}{{template "panel-relay" .}}99101 {{end}}{{end}}@@ -111,6 +113,9 @@
111113 {{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>112114 {{if .Actor}}<section><h4>Upload</h4><file-upload></file-upload><p><small>Encrypt keeps the contents and key in your browser. Keep the complete share link.</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}}113115 116+{{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>117+<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 under Inbox.</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}}118+114119 {{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>115120 {{if .Actor}}<section><h4>You</h4><table><tr><th>key</th><td>{{template "nostrKey" .Actor}}</td></tr></table></section>{{end}}116121 {{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}}@@ -20,7 +20,7 @@ pre{white-space:pre-wrap;overflow-wrap:anywhere}
2020 img{max-width:100%}2121 2222 /* Selection: chrome is not selectable, content and values are. */23-#rail,#footer,#topbar,#panel h4,#panel label,#panel th,#rows th,#grid,#gridhead,#tree header small,#tree li>span,#preview header,button,summary,rpc-form label,signed-form label,publish-list label,nostr-compose label,file-tools label,file-upload label,nostr-event>header,diff-view caption small{user-select:none;-webkit-user-select:none}23+#rail,#footer,#topbar,#panel h4,#panel label,#panel th,#rows th,#grid,#gridhead,#tree header small,#tree li>span,#preview header,button,summary,rpc-form label,signed-form label,publish-list label,nostr-compose label,file-tools label,file-upload label,nostr-event>header,approval-item>header,diff-view caption small{user-select:none;-webkit-user-select:none}2424 2525 /* Shell: a table with three columns; each column scrolls on its own. */2626 #topbar{display:none}@@ -168,6 +168,20 @@ nostr-event>pre{margin:0;white-space:pre-wrap;font:14px/1.45 var(--sans)}
168168 nostr-event>details{margin-top:6px;font:12px var(--mono)}nostr-event>details>summary{cursor:pointer;color:var(--muted)}169169 nostr-key{font-family:var(--mono);cursor:copy}170170 171+/* approval-item: one request that waits for a decision, in the open rhythm of nostr-event. */172+approval-item{display:block;border-bottom:1px solid var(--line);padding:10px 0;max-width:720px}173+approval-item>header{font:12px var(--mono);color:var(--muted)}approval-item>header nostr-key{color:var(--ink);font-weight:500}174+approval-item>p{margin:4px 0 6px}175+approval-item>pre{background:var(--rail);border:1px solid var(--line);padding:8px 10px;font-size:12px;margin:6px 0}176+approval-item>footer{font:12px var(--mono);color:var(--muted)}approval-item>footer button{margin-right:6px;font-size:13px}177+approval-item>footer details{display:inline-block;vertical-align:top;margin-right:6px}approval-item>footer details>summary{cursor:pointer;color:var(--accent);font-size:13px}178+approval-item>footer details[open]{display:block;margin:6px 0}approval-item>footer nostr-compose{border-bottom:0;padding:6px 0 0}179+approval-item[data-focus]{background:var(--soft);padding-left:10px;padding-right:10px;border:1px solid var(--line)}180+nostr-react{display:inline-block;vertical-align:top}nostr-react form{display:inline}nostr-react output{display:block;font:12px var(--mono);color:var(--muted);margin-top:4px}nostr-react output[data-error]{color:var(--alert)}181+nostr-react output button{margin:0 6px 0 0;font-size:12px}182+#rows tr[data-state=expired] td:last-child{color:var(--bark)}183+#panel p i{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--g3);margin-right:6px}184+171185 /* Repository: tree with a preview pane. */172186 #split{width:100%;border-collapse:collapse;table-layout:fixed;margin:0}173187 #split>tbody>tr>td{vertical-align:top;padding:0;border:0}@@ -102,7 +102,7 @@ type navItem struct {
102102 103103 // relayNav: entry points, then conversation, then collaborative artifacts,104104 // then what the relay publishes and stores, then what is yours.105-var relayNav = []navItem{{"/home", "/", "home", 0}, {"/search", "/search", "search", 0}, {"/inbox", "/inbox", "inbox", 1}, {"/repos", "/repos", "repos", 2}, {"/files", "/files", "files", 3}, {"/articles", "/articles", "articles", 3}, {"/sites", "/sites", "sites", 3}, {"/outbox", "/outbox", "outbox", 4}, {"/manage", "/manage/people", "manage", 4}}105+var relayNav = []navItem{{"/home", "/", "home", 0}, {"/search", "/search", "search", 0}, {"/inbox", "/inbox", "inbox", 1}, {"/approvals", "/approvals", "approvals", 1}, {"/repos", "/repos", "repos", 2}, {"/files", "/files", "files", 3}, {"/articles", "/articles", "articles", 3}, {"/sites", "/sites", "sites", 3}, {"/outbox", "/outbox", "outbox", 4}, {"/manage", "/manage/people", "manage", 4}}106106 107107 // manageNav: who is here, what they may do, what the relay is, what it does108108 // over time, and how it is doing.@@ -250,6 +250,9 @@ func parseTemplates() (*template.Template, error) {
250250 "agentCounts": agentCounts,251251 "scopeList": scopeList,252252 "dateAfter": dateAfter,253+ "approvalViews": approvalViews,254+ "approvalCounts": approvalCounts,255+ "approvalDevices": approvalDevices,253256 }254257 tmpl, err := template.New("webui").Funcs(funcs).ParseFS(templateFS, "*.html")255258 if err != nil {@@ -158,7 +158,7 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
158158 http.Redirect(writer, request, requestPrefix(request)+"/manage/health", http.StatusMovedPermanently)159159 return160160 }161- if request.Method == http.MethodGet && (request.URL.Path == "/repos" || request.URL.Path == "/repo" || request.URL.Path == "/files" || request.URL.Path == "/file" || request.URL.Path == "/manage/health") {161+ if request.Method == http.MethodGet && (request.URL.Path == "/repos" || request.URL.Path == "/repo" || request.URL.Path == "/files" || request.URL.Path == "/file" || request.URL.Path == "/approvals" || request.URL.Path == "/manage/health") {162162 a.browse(writer, request)163163 return164164 }@@ -522,6 +522,8 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
522522 method = "browsefiles"523523 case "/file":524524 method = "browsefile"525+ case "/approvals":526+ method = "browseapprovals"525527 case "/manage/health":526528 method = "browsestatus"527529 }@@ -569,6 +571,12 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
569571 if query["hash"] == "" {570572 query["hash"] = request.URL.Query().Get("sha")571573 }574+ case "browseapprovals":575+ state := request.URL.Query().Get("state")576+ if state == "" {577+ state = "all"578+ }579+ query = map[string]any{"cursor": query["cursor"], "limit": query["limit"], "state": state}572580 case "browsestatus":573581 query = map[string]any{}574582 }@@ -587,6 +595,11 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
587595 if method == "browserepo" && pageQuery.Get("view") == "" {588596 pageQuery.Set("view", "tree")589597 }598+ if method == "browseapprovals" && pageQuery.Get("id") != "" {599+ // A notification opens one request; show it even when it has left600+ // the first page.601+ result = a.includeApproval(request.Context(), actor, result, pageQuery.Get("id"))602+ }590603 data := PageData{Tab: browseTab(path), Feed: browseRows(result), Event: result, Query: pageQuery}591604 data.Title = browseTitle(path, pageQuery, result, a.backend.Slug())592605 if method == "browserepo" && pageQuery.Get("view") == "home" {@@ -597,7 +610,7 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
597610 }598611 599612 func browseTitle(path string, query url.Values, result any, slug string) string {600- label := map[string]string{"/repos": "Repositories", "/files": "Files", "/file": "File"}[path]613+ label := map[string]string{"/repos": "Repositories", "/files": "Files", "/file": "File", "/approvals": "Approvals"}[path]601614 if path == "/repo" {602615 label = query.Get("repo")603616 if label == "" {@@ -659,6 +672,8 @@ func browseTab(path string) string {
659672 return "files"660673 case "/file":661674 return "file"675+ case "/approvals":676+ return "approvals"662677 default:663678 return "health"664679 }@@ -1064,6 +1079,8 @@ func tabForPath(path string) string {
10641079 return "files"10651080 case "file":10661081 return "file"1082+ case "approvals":1083+ return "approvals"10671084 case "signin", "sites":10681085 return path10691086 case "people", "agents", "moderation", "rules", "identity", "connect", "data", "sync", "views", "health", "owner":