tinyrelaycommit

Commit 361beb5a8ad9

browse files at this commit | history

1diff --git a/internal/webui/approvals.go b/internal/webui/approvals.go
2new file mode 100644
3index 0000000..18a2b7e
4--- /dev/null
5+++ b/internal/webui/approvals.go
6@@ -0,0 +1,154 @@
7+package webui
8+
9+import (
10+ "context"
11+ "encoding/json"
12+ "strings"
13+)
14+
15+// approvalView is one request as the Approvals page renders it. The backend
16+// answers browseapprovals with typed items; this flattens what the template
17+// needs so the markup stays plain.
18+type approvalView struct {
19+ ID, Kind, Type, State, Asker string
20+ CreatedAt, Expires int64
21+ // Where the request was made: the room, the repository or the relay.
22+ Where string
23+ // Title is the subject, or the content when there is no subject; Body is
24+ // the content when a subject is present.
25+ Title, Body string
26+ // About summarizes the a or e reference in one mono line, and Open is
27+ // the relay path that shows it.
28+ About, Open, Coordinate string
29+ Decision string
30+ AnsweredAt int64
31+ AnswerContent string
32+}
33+
34+// approvalViews returns the items in the given states, in the order the
35+// backend listed them.
36+func approvalViews(value any, base string, states ...string) []approvalView {
37+ var views []approvalView
38+ for _, raw := range collaborationSlice(value, "items") {
39+ item := valueMap(raw)
40+ state := plainString(item["state"])
41+ wanted := len(states) == 0
42+ for _, s := range states {
43+ wanted = wanted || s == state
44+ }
45+ if !wanted {
46+ continue
47+ }
48+ views = append(views, approvalViewFrom(item, base))
49+ }
50+ return views
51+}
52+
53+func approvalViewFrom(item map[string]any, base string) approvalView {
54+ 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"])}
55+ subject, content := strings.TrimSpace(plainString(item["subject"])), strings.TrimSpace(plainString(item["content"]))
56+ view.Title, view.Body = subject, content
57+ if subject == "" {
58+ view.Title, view.Body = content, ""
59+ }
60+ view.Where = "the relay"
61+ view.Open = "/e/" + view.ID
62+ if about := valueMap(item["about"]); len(about) > 0 {
63+ coordinate, referenced, kind := plainString(about["coordinate"]), plainString(about["event"]), plainString(about["kind"])
64+ var parts []string
65+ if coordinate != "" {
66+ parts = append(parts, "about "+coordinate)
67+ if fields := strings.SplitN(coordinate, ":", 3); len(fields) == 3 && fields[0] == "30617" {
68+ view.Coordinate = coordinate
69+ view.Where = "repository " + fields[2]
70+ }
71+ }
72+ if referenced != "" {
73+ label := "event " + shortID(referenced)
74+ if kind != "" {
75+ label = "kind " + kind + " " + shortID(referenced)
76+ }
77+ parts = append(parts, label)
78+ }
79+ view.About = strings.Join(parts, " | ")
80+ if url := plainString(about["url"]); url != "" {
81+ view.Open = relayPath(url, base)
82+ }
83+ }
84+ if room := plainString(item["room"]); room != "" {
85+ view.Where = "#" + room
86+ }
87+ if answer := valueMap(item["answer"]); len(answer) > 0 {
88+ view.Decision, view.AnsweredAt, view.AnswerContent = plainString(answer["decision"]), unixSeconds(answer["created_at"]), strings.TrimSpace(plainString(answer["content"]))
89+ }
90+ return view
91+}
92+
93+// includeApproval makes sure the request a notification opened is on the
94+// page: when the listing does not carry it, the single request is read and
95+// placed first. A request that cannot be read leaves the listing as it is.
96+func (a *App) includeApproval(ctx context.Context, actor string, result any, id string) any {
97+ page := valueMap(result)
98+ items := collaborationSlice(result, "items")
99+ for _, item := range items {
100+ if plainString(valueMap(item)["id"]) == id {
101+ return result
102+ }
103+ }
104+ raw, _ := json.Marshal(map[string]any{"id": id})
105+ detail, err := a.backend.Query(ctx, "browseapproval", []json.RawMessage{raw}, actor)
106+ if err != nil {
107+ return result
108+ }
109+ item := valueMap(detail)["item"]
110+ if item == nil {
111+ return result
112+ }
113+ page["items"] = append([]any{item}, items...)
114+ return page
115+}
116+
117+// relayPath turns an absolute relay URL into the path the page links to, so
118+// tenant prefixes are applied once when the page is written.
119+func relayPath(url, base string) string {
120+ base = strings.TrimRight(base, "/")
121+ if base != "" && strings.HasPrefix(url, base+"/") {
122+ return strings.TrimPrefix(url, base)
123+ }
124+ if i := strings.Index(url, "://"); i >= 0 {
125+ if slash := strings.Index(url[i+3:], "/"); slash >= 0 {
126+ return url[i+3+slash:]
127+ }
128+ return "/"
129+ }
130+ return url
131+}
132+
133+// approvalCounts reads the counts object the backend returns beside the
134+// items, defaulting every state to zero.
135+func approvalCounts(value any) map[string]int {
136+ counts := map[string]int{"open": 0, "answered": 0, "expired": 0}
137+ encoded, err := json.Marshal(valueMap(value)["counts"])
138+ if err == nil {
139+ _ = json.Unmarshal(encoded, &counts)
140+ }
141+ return counts
142+}
143+
144+// approvalDevices lists the caller's devices as "categories" strings.
145+func approvalDevices(value any) []string {
146+ var devices []string
147+ for _, raw := range collaborationSlice(value, "devices") {
148+ var categories []string
149+ encoded, err := json.Marshal(valueMap(raw)["categories"])
150+ if err == nil {
151+ _ = json.Unmarshal(encoded, &categories)
152+ }
153+ if len(categories) == 0 {
154+ devices = append(devices, "no categories")
155+ continue
156+ }
157+ devices = append(devices, strings.Join(categories, ", "))
158+ }
159+ return devices
160+}
161diff --git a/internal/webui/approvals_test.go b/internal/webui/approvals_test.go
162new file mode 100644
163index 0000000..d9a60af
164--- /dev/null
165+++ b/internal/webui/approvals_test.go
166@@ -0,0 +1,126 @@
167+package webui
168+
169+import (
170+ "context"
171+ "encoding/json"
172+ "errors"
173+ "net/http"
174+ "net/http/httptest"
175+ "strings"
176+ "testing"
177+
178+ "github.com/FelineStateMachine/tinyrelay/internal/policy"
179+)
180+
181+type approvalsBackend struct {
182+ *fakeBackend
183+ calls []string
184+}
185+
186+func (b *approvalsBackend) Query(_ context.Context, method string, params []json.RawMessage, actor string) (any, error) {
187+ b.calls = append(b.calls, method)
188+ b.params = params
189+ if actor == "" {
190+ return nil, errors.New("auth-required: sign in to see requests addressed to you")
191+ }
192+ asker := strings.Repeat("c", 64)
193+ 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",
194+ "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)}}
195+ 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"}
196+ 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}}
197+ 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"}
198+ switch method {
199+ case "browseapprovals":
200+ 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"}}}}, nil
201+ case "browseapproval":
202+ var q map[string]any
203+ _ = json.Unmarshal(params[0], &q)
204+ if q["id"] != strings.Repeat("5", 64) {
205+ return nil, errors.New("not found: request")
206+ }
207+ 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{}}, nil
208+ }
209+ return map[string]any{}, nil
210+}
211+
212+func TestApprovalsPageRendersRequestsForTheOwner(t *testing.T) {
213+ backend := &approvalsBackend{fakeBackend: &fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}
214+ app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }})
215+ if err != nil {
216+ t.Fatal(err)
217+ }
218+ recorder := httptest.NewRecorder()
219+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/approvals", nil))
220+ body := recorder.Body.String()
221+ if recorder.Code != http.StatusOK {
222+ t.Fatalf("status %d: %s", recorder.Code, body)
223+ }
224+ var params map[string]any
225+ if err := json.Unmarshal(backend.params[0], &params); err != nil || params["state"] != "all" || params["limit"] != 50.0 {
226+ t.Fatalf("browse params %s %v", backend.params[0], err)
227+ }
228+ asker := strings.Repeat("c", 64)
229+ for _, marker := range []string{
230+ `<approval-item id="approval-` + strings.Repeat("1", 64) + `" data-type="approve" data-state="open">`,
231+ `<nostr-key hex="` + asker + `"`, "in repository tinyrelay", "expires <time",
232+ "<p>Publish release notes 1.4</p>", "<p>Publish release notes 1.4 to the articles feed as drafted?</p>",
233+ "<pre>about 30617:" + backend.policy.Owner + ":tinyrelay | kind 1621 dddddddddddd</pre>",
234+ `<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>`,
235+ `<nostr-compose kind="1111" root="` + strings.Repeat("1", 64) + `" root-pubkey="` + asker + `" root-kind="1111" coordinate="30617:` + backend.policy.Owner + `:tinyrelay">`,
236+ `<a href="/repo?owner=` + backend.policy.Owner + `&amp;repo=tinyrelay&amp;view=issue&amp;id=` + strings.Repeat("d", 64) + `">open</a>`,
237+ `<approval-item id="approval-` + strings.Repeat("2", 64) + `" data-type="question"`, "in #build", `<a href="/e/` + strings.Repeat("2", 64) + `">open</a>`,
238+ "<h3>Answered</h3>", `<tr id="approval-` + strings.Repeat("3", 64) + `" data-state="answered">`, "approved | <time", `data-state="expired"><td>`, "<td>expired</td>",
239+ "<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.",
240+ `<a href="/approvals" aria-current="page">/approvals</a>`,
241+ } {
242+ if !strings.Contains(body, marker) {
243+ t.Fatalf("missing %q in\n%s", marker, body)
244+ }
245+ }
246+ // The question type offers no decision buttons, only a reply.
247+ question := body[strings.Index(body, `id="approval-`+strings.Repeat("2", 64)):]
248+ question = question[:strings.Index(question, "</approval-item>")]
249+ if strings.Contains(question, "<nostr-react") || !strings.Contains(question, `<nostr-compose kind="1111" root="`+strings.Repeat("2", 64)+`" root-pubkey="`+asker+`" root-kind="9">`) {
250+ t.Fatalf("question controls:\n%s", question)
251+ }
252+ if strings.Contains(body, "class=") {
253+ t.Fatal("page carries class attributes")
254+ }
255+ rail := body[strings.Index(body, `id="railbox"`):strings.Index(body, `id="rail-tools"`)]
256+ if strings.Index(rail, "/inbox") > strings.Index(rail, "/approvals") || strings.Index(rail, "/approvals") > strings.Index(rail, "/repos") {
257+ t.Fatalf("rail order:\n%s", rail)
258+ }
259+
260+ // A notification opens one request; when it has left the first page it
261+ // is read on its own and shown first.
262+ recorder = httptest.NewRecorder()
263+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/approvals?id="+strings.Repeat("5", 64)+"&answer=approve", nil))
264+ body = recorder.Body.String()
265+ if !strings.Contains(strings.Join(backend.calls, ","), "browseapprovals,browseapproval") {
266+ t.Fatalf("calls %v", backend.calls)
267+ }
268+ first := strings.Index(body, `id="approval-`+strings.Repeat("5", 64)+`"`)
269+ if first < 0 || first > strings.Index(body, `id="approval-`+strings.Repeat("1", 64)+`"`) || !strings.Contains(body, "<p>Older request</p><p>Body</p>") {
270+ t.Fatalf("focused request missing or not first:\n%s", body)
271+ }
272+}
273+
274+func TestApprovalsPageAsksGuestsToSignIn(t *testing.T) {
275+ backend := &approvalsBackend{fakeBackend: &fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}
276+ app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return "", nil }})
277+ if err != nil {
278+ t.Fatal(err)
279+ }
280+ recorder := httptest.NewRecorder()
281+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/approvals", nil))
282+ body := recorder.Body.String()
283+ if recorder.Code != http.StatusOK || !strings.Contains(body, `<a href="/signin">Sign in</a> to see the requests addressed to your key.`) {
284+ t.Fatalf("guest page %d:\n%s", recorder.Code, body)
285+ }
286+ if strings.Contains(body, "<approval-item") || strings.Contains(body, "auth-required") || strings.Contains(body, "role=\"alert\"") {
287+ t.Fatalf("guest page leaks requests or the error:\n%s", body)
288+ }
289+ if !strings.Contains(body, "Sign in to see which devices are notified.") || !strings.Contains(body, "<th>open</th><td>0</td>") {
290+ t.Fatalf("guest panel:\n%s", body)
291+ }
292+}
293diff --git a/internal/webui/browse.html b/internal/webui/browse.html
294index 8dfc3dd..09810b3 100644
295--- a/internal/webui/browse.html
296+++ b/internal/webui/browse.html
297@@ -50,6 +50,28 @@
298 {{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>
299 {{end}}{{end}}
300
301+{{define "approvals"}}<section>
302+<h1>Approvals</h1>
303+<p>Requests that wait for your decision. Each answer is a signed reaction; a reply opens the thread.</p>
304+{{if not .Actor}}<p><a href="/signin">Sign in</a> to see the requests addressed to your key.</p>
305+{{else if .Error}}{{template "alert" .Error}}
306+{{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}}">
307+<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>
308+<p>{{$item.Title}}</p>{{with $item.Body}}<p>{{.}}</p>{{end}}{{with $item.About}}<pre>{{.}}</pre>{{end}}
309+<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>
310+</approval-item>
311+{{end}}{{else}}<p>Nothing waits for your decision.</p>
312+{{end}}{{if $done}}<h3>Answered</h3>
313+<table id="rows">
314+<thead><tr><th>asker</th><th>request</th><th>answer</th></tr></thead>
315+<tbody>
316+{{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>
317+{{end}}</tbody>
318+</table>
319+{{end}}{{with (asMap .Event).next_cursor}}<p id="pages"><a href="{{browsePageURL "/approvals" $.Query "cursor" .}}">next page</a></p>
320+{{end}}<noscript><p>Approve and Deny need JavaScript and a connected signer.</p></noscript>{{end}}</section>
321+{{end}}
322+
323 {{define "sites"}}<section>
324 <h1>Sites</h1>
325 <p>Static sites published to this relay as signed manifests. Each site is served on its own host.</p>
326diff --git a/internal/webui/components.js b/internal/webui/components.js
327index 3709700..9b63c1a 100644
328--- a/internal/webui/components.js
329+++ b/internal/webui/components.js
330@@ -27,9 +27,16 @@
331 // <file-tools hash="…" type="…" size="…">
332 // Shares one stored file: copies its Blossom URI or share link, decrypts
333 // a share link fragment and sends a random-key file as a NIP-17 message.
334-// <nostr-compose kind="…" coordinate="30617:…" [root="…" root-pubkey="…" root-kind="…"] [parent="…" …]>
335+// <nostr-compose kind="…" [coordinate="30617:…"] [root="…" root-pubkey="…" root-kind="…"] [parent="…" …]>
336 // Signs a NIP-34 issue, pull request, NIP-22 reply or status event and
337-// posts it to the relay.
338+// posts it to the relay. A reply outside a repository omits the coordinate.
339+// <nostr-react event="…" pubkey="…" [kind="…"]>
340+// Answers a request for a decision with a signed kind 7 reaction: the
341+// Approve button sends +, Deny sends -. The prompt method asks for a
342+// visible confirmation first, which the answer link from a notification uses.
343+// <approval-item id="approval-…">
344+// One request on the Approvals page. When the page opens with the item's
345+// id and an answer in the query, it scrolls into view and offers that answer.
346 // <share-link [url="…"] [title="…"]>
347 // The device share sheet for the page; empty without browser support.
348 // <push-toggle>
349@@ -341,12 +348,14 @@
350 const kind = Number(mode === "status" ? value("status") : mode);
351 if (![1621, 1618, 1111, 1630, 1631, 1632, 1633].includes(kind)) throw Error("Choose a valid event type.");
352 const coordinate = this.getAttribute("coordinate") || "";
353- if (!/^30617:[0-9a-f]{64}:.+$/.test(coordinate)) throw Error("The repository address is missing.");
354- const tags = [["a", coordinate]];
355+ // A reply to a request for a decision may sit outside any repository.
356+ if (coordinate ? !/^30617:[0-9a-f]{64}:.+$/.test(coordinate) : kind !== 1111) throw Error("The repository address is missing.");
357+ const tags = coordinate ? [["a", coordinate]] : [];
358 if (kind === 1111 || kind >= 1630) {
359 const root = this.getAttribute("root"), pubkey = this.getAttribute("root-pubkey");
360 const rootKind = this.getAttribute("root-kind");
361- if (!isHex64(root) || !isHex64(pubkey) || !["1617", "1618", "1621"].includes(rootKind)) throw Error("The conversation address is missing.");
362+ const rootKinds = coordinate ? ["1617", "1618", "1621"] : ["1111", "9", "11"];
363+ if (!isHex64(root) || !isHex64(pubkey) || !rootKinds.includes(rootKind)) throw Error("The conversation address is missing.");
364 if (kind === 1111) {
365 const parent = this.getAttribute("parent") || root;
366 const parentPubkey = this.getAttribute("parent-pubkey") || pubkey;
367@@ -553,10 +562,102 @@
368 }
369 }
370
371+ // NostrReact answers a request for a decision. The reaction names the
372+ // request with e and the asker with p, so the asker's own subscription
373+ // sees the answer. Nothing is signed without a press: the buttons submit
374+ // the form, and prompt() asks once more before signing on behalf of a
375+ // notification action.
376+ const publishSigned = async unsigned => {
377+ if (!window.nostr?.signEvent) throw Error("Connect a signer first.");
378+ const expected = JSON.stringify(unsigned);
379+ const event = await window.nostr.signEvent(JSON.parse(expected));
380+ const actual = event && JSON.stringify({kind: event.kind, created_at: event.created_at, tags: event.tags, content: event.content});
381+ if (actual !== expected || !window.NostrSigner?.verifyEvent(event)) throw Error("The signer returned an invalid or changed event.");
382+ const response = await tiny.signedFetch("/events", "POST", JSON.stringify(event), {contentType: "application/json"});
383+ const result = await response.json();
384+ if (!response.ok || result.accepted !== true) throw Error(result.error || result.message || "The relay rejected the event.");
385+ return event;
386+ };
387+ const decisions = {"+": "approve", "-": "deny"};
388+ class NostrReact extends FormElement {
389+ connectedCallback() {
390+ if (this.form) return;
391+ super.connectedCallback();
392+ if (this.listening) return;
393+ this.listening = true;
394+ this.addEventListener("click", event => {
395+ const button = event.target.closest("button[name=answer]");
396+ if (button) this.choice = button.value;
397+ });
398+ }
399+
400+ async submit(form, choice = this.choice) {
401+ const id = this.getAttribute("event"), pubkey = this.getAttribute("pubkey");
402+ if (!isHex64(id) || !isHex64(pubkey)) throw Error("The request address is missing.");
403+ if (!(choice in decisions)) throw Error("Choose Approve or Deny.");
404+ const tags = [["e", id, "", pubkey], ["p", pubkey]];
405+ const kind = this.getAttribute("kind");
406+ if (/^\d+$/.test(kind || "")) tags.push(["k", kind]);
407+ this.report("Signing…");
408+ await publishSigned({kind: 7, created_at: Math.floor(Date.now() / 1000), tags, content: choice});
409+ this.choice = null;
410+ this.report(choice === "+" ? "Approved." : "Denied.");
411+ await tiny.navigate?.((globalThis.location?.href || "").split("?", 1)[0]);
412+ }
413+
414+ // prompt shows the confirmation for one answer; the signature waits for
415+ // the confirm button. Without a signer it waits for one to connect.
416+ prompt(answer) {
417+ const choice = Object.keys(decisions).find(key => decisions[key] === answer);
418+ if (!choice) return;
419+ if (!window.nostr?.signEvent) {
420+ this.report("Connect a signer to answer this request.");
421+ document.addEventListener("tiny:signer", () => this.prompt(answer), {once: true});
422+ return;
423+ }
424+ const question = el("span", (answer === "approve" ? "Approve" : "Deny") + " this request? ");
425+ const confirm = el("button", "Confirm");
426+ confirm.type = "button";
427+ const cancel = el("button", "Cancel");
428+ cancel.type = "button";
429+ confirm.addEventListener("click", () => {
430+ this.busy(true);
431+ confirm.disabled = cancel.disabled = true;
432+ this.submit(this.form, choice).catch(err => this.report("Error: " + err.message, true)).finally(() => this.busy(false));
433+ });
434+ cancel.addEventListener("click", () => this.report(""));
435+ this.output.replaceChildren(question, confirm, " ", cancel);
436+ delete this.output.dataset.error;
437+ confirm.focus();
438+ }
439+ }
440+
441+ // ApprovalItem reacts to the query a notification action opens the page
442+ // with: id names the request and answer is approve, deny or reply.
443+ class ApprovalItem extends HTMLElement {
444+ connectedCallback() {
445+ if (this.bound) return;
446+ this.bound = true;
447+ const params = new URLSearchParams(location.search);
448+ if (this.id !== "approval-" + params.get("id")) return;
449+ this.setAttribute("data-focus", "");
450+ this.scrollIntoView?.({block: "center"});
451+ const answer = params.get("answer");
452+ if (answer === "reply") {
453+ const details = this.querySelector("details");
454+ if (details) details.open = true;
455+ this.querySelector("textarea")?.focus();
456+ } else if (answer === "approve" || answer === "deny") {
457+ // The reaction element must have wrapped its form first.
458+ queueMicrotask(() => this.querySelector("nostr-react")?.prompt(answer));
459+ }
460+ }
461+ }
462+
463 // PushToggle opts this browser into relay notifications. Nothing happens
464 // until the button is pressed: permission is requested then, and the
465 // device subscription is registered with a signed request.
466- const pushCategories = [["messages", "private messages"], ["replies", "replies to you"], ["mentions", "mentions"], ["relay", "relay notices"]];
467+ const pushCategories = [["messages", "private messages"], ["replies", "replies to you"], ["mentions", "mentions"], ["approvals", "requests for a decision"], ["relay", "relay notices"]];
468 class PushToggle extends HTMLElement {
469 connectedCallback() {
470 if (this.bound) return;
471@@ -997,6 +1098,8 @@
472 customElements.define("publish-list", PublishList);
473 customElements.define("nostr-compose", NostrCompose);
474 customElements.define("agent-grant", AgentGrant);
475+ customElements.define("nostr-react", NostrReact);
476+ customElements.define("approval-item", ApprovalItem);
477 customElements.define("push-toggle", PushToggle);
478 customElements.define("share-link", ShareLink);
479 customElements.define("nostr-key", NostrKey);
480@@ -1027,7 +1130,7 @@
481 if (root && !(url.pathname === root || url.pathname.startsWith(root + "/"))) return null;
482 return root ? url.pathname.slice(root.length) || "/" : url.pathname;
483 };
484- 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 || "");
485+ 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 || "");
486 let navigationSerial = 0, activeAbort;
487 const streams = new Set();
488 const closeStreams = () => {
489@@ -1042,7 +1145,7 @@
490 document.addEventListener("fx:sse:close", event => streams.delete(event.detail.cfg));
491 window.addEventListener("pagehide", closeStreams);
492 const repairComponents = () => {
493- document.querySelectorAll("rpc-form,signed-form,publish-list,agent-grant").forEach(node => {
494+ document.querySelectorAll("rpc-form,signed-form,publish-list,agent-grant,nostr-react").forEach(node => {
495 if (node.form?.isConnected) return;
496 node.form = null;
497 node.output = null;
498diff --git a/internal/webui/page.html b/internal/webui/page.html
499index e2a449c..4860704 100644
500--- a/internal/webui/page.html
501+++ b/internal/webui/page.html
502@@ -53,6 +53,7 @@
503 {{else if eq .Tab "repo"}}{{template "repo" .}}
504 {{else if eq .Tab "files"}}{{template "files" .}}
505 {{else if eq .Tab "file"}}{{template "file" .}}
506+{{else if eq .Tab "approvals"}}{{template "approvals" .}}
507 {{else if eq .Tab "event"}}{{template "event" .}}
508 {{else if eq .Tab "view"}}{{template "view" .}}
509 {{else if eq .Tab "invite"}}{{template "invite" .}}
510@@ -94,6 +95,7 @@
511 {{else if or (eq .Tab "inbox") (eq .Tab "outbox") (eq .Tab "search") (eq .Tab "articles")}}{{template "panel-feed" .}}
512 {{else if eq .Tab "repos"}}{{template "panel-repos" .}}
513 {{else if or (eq .Tab "files") (eq .Tab "file")}}{{template "panel-files" .}}
514+{{else if eq .Tab "approvals"}}{{template "panel-approvals" .}}
515 {{else if eq .Tab "signin"}}{{template "panel-signin" .}}
516 {{else}}{{template "panel-relay" .}}
517 {{end}}{{end}}
518@@ -111,6 +113,9 @@
519 {{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>
520 {{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}}
521
522+{{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>
523+<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}}
524+
525 {{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>
526 {{if .Actor}}<section><h4>You</h4><table><tr><th>key</th><td>{{template "nostrKey" .Actor}}</td></tr></table></section>{{end}}
527 {{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}}
528diff --git a/internal/webui/style.css b/internal/webui/style.css
529index 2eb0b71..a8c5513 100644
530--- a/internal/webui/style.css
531+++ b/internal/webui/style.css
532@@ -20,7 +20,7 @@ pre{white-space:pre-wrap;overflow-wrap:anywhere}
533 img{max-width:100%}
534
535 /* Selection: chrome is not selectable, content and values are. */
536-#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}
537+#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}
538
539 /* Shell: a table with three columns; each column scrolls on its own. */
540 #topbar{display:none}
541@@ -168,6 +168,20 @@ nostr-event>pre{margin:0;white-space:pre-wrap;font:14px/1.45 var(--sans)}
542 nostr-event>details{margin-top:6px;font:12px var(--mono)}nostr-event>details>summary{cursor:pointer;color:var(--muted)}
543 nostr-key{font-family:var(--mono);cursor:copy}
544
545+/* approval-item: one request that waits for a decision, in the open rhythm of nostr-event. */
546+approval-item{display:block;border-bottom:1px solid var(--line);padding:10px 0;max-width:720px}
547+approval-item>header{font:12px var(--mono);color:var(--muted)}approval-item>header nostr-key{color:var(--ink);font-weight:500}
548+approval-item>p{margin:4px 0 6px}
549+approval-item>pre{background:var(--rail);border:1px solid var(--line);padding:8px 10px;font-size:12px;margin:6px 0}
550+approval-item>footer{font:12px var(--mono);color:var(--muted)}approval-item>footer button{margin-right:6px;font-size:13px}
551+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}
552+approval-item>footer details[open]{display:block;margin:6px 0}approval-item>footer nostr-compose{border-bottom:0;padding:6px 0 0}
553+approval-item[data-focus]{background:var(--soft);padding-left:10px;padding-right:10px;border:1px solid var(--line)}
554+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)}
555+nostr-react output button{margin:0 6px 0 0;font-size:12px}
556+#rows tr[data-state=expired] td:last-child{color:var(--bark)}
557+#panel p i{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--g3);margin-right:6px}
558+
559 /* Repository: tree with a preview pane. */
560 #split{width:100%;border-collapse:collapse;table-layout:fixed;margin:0}
561 #split>tbody>tr>td{vertical-align:top;padding:0;border:0}
562diff --git a/internal/webui/templates.go b/internal/webui/templates.go
563index 0c6abcc..2380653 100644
564--- a/internal/webui/templates.go
565+++ b/internal/webui/templates.go
566@@ -102,7 +102,7 @@ type navItem struct {
567
568 // relayNav: entry points, then conversation, then collaborative artifacts,
569 // then what the relay publishes and stores, then what is yours.
570-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}}
571+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}}
572
573 // manageNav: who is here, what they may do, what the relay is, what it does
574 // over time, and how it is doing.
575@@ -250,6 +250,9 @@ func parseTemplates() (*template.Template, error) {
576 "agentCounts": agentCounts,
577 "scopeList": scopeList,
578 "dateAfter": dateAfter,
579+ "approvalViews": approvalViews,
580+ "approvalCounts": approvalCounts,
581+ "approvalDevices": approvalDevices,
582 }
583 tmpl, err := template.New("webui").Funcs(funcs).ParseFS(templateFS, "*.html")
584 if err != nil {
585diff --git a/internal/webui/webui.go b/internal/webui/webui.go
586index 19d5ba1..a10f734 100644
587--- a/internal/webui/webui.go
588+++ b/internal/webui/webui.go
589@@ -158,7 +158,7 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
590 http.Redirect(writer, request, requestPrefix(request)+"/manage/health", http.StatusMovedPermanently)
591 return
592 }
593- 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") {
594+ 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") {
595 a.browse(writer, request)
596 return
597 }
598@@ -522,6 +522,8 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
599 method = "browsefiles"
600 case "/file":
601 method = "browsefile"
602+ case "/approvals":
603+ method = "browseapprovals"
604 case "/manage/health":
605 method = "browsestatus"
606 }
607@@ -569,6 +571,12 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
608 if query["hash"] == "" {
609 query["hash"] = request.URL.Query().Get("sha")
610 }
611+ case "browseapprovals":
612+ state := request.URL.Query().Get("state")
613+ if state == "" {
614+ state = "all"
615+ }
616+ query = map[string]any{"cursor": query["cursor"], "limit": query["limit"], "state": state}
617 case "browsestatus":
618 query = map[string]any{}
619 }
620@@ -587,6 +595,11 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
621 if method == "browserepo" && pageQuery.Get("view") == "" {
622 pageQuery.Set("view", "tree")
623 }
624+ if method == "browseapprovals" && pageQuery.Get("id") != "" {
625+ // A notification opens one request; show it even when it has left
626+ // the first page.
627+ result = a.includeApproval(request.Context(), actor, result, pageQuery.Get("id"))
628+ }
629 data := PageData{Tab: browseTab(path), Feed: browseRows(result), Event: result, Query: pageQuery}
630 data.Title = browseTitle(path, pageQuery, result, a.backend.Slug())
631 if method == "browserepo" && pageQuery.Get("view") == "home" {
632@@ -597,7 +610,7 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
633 }
634
635 func browseTitle(path string, query url.Values, result any, slug string) string {
636- label := map[string]string{"/repos": "Repositories", "/files": "Files", "/file": "File"}[path]
637+ label := map[string]string{"/repos": "Repositories", "/files": "Files", "/file": "File", "/approvals": "Approvals"}[path]
638 if path == "/repo" {
639 label = query.Get("repo")
640 if label == "" {
641@@ -659,6 +672,8 @@ func browseTab(path string) string {
642 return "files"
643 case "/file":
644 return "file"
645+ case "/approvals":
646+ return "approvals"
647 default:
648 return "health"
649 }
650@@ -1064,6 +1079,8 @@ func tabForPath(path string) string {
651 return "files"
652 case "file":
653 return "file"
654+ case "approvals":
655+ return "approvals"
656 case "signin", "sites":
657 return path
658 case "people", "agents", "moderation", "rules", "identity", "connect", "data", "sync", "views", "health", "owner":

About

ownerc01503e8035f
visibilitypublic
head361beb5a8ad9

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs