| 1 | diff --git a/internal/webui/approvals.go b/internal/webui/approvals.go |
| 2 | new file mode 100644 |
| 3 | index 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 | +} |
| 161 | diff --git a/internal/webui/approvals_test.go b/internal/webui/approvals_test.go |
| 162 | new file mode 100644 |
| 163 | index 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], ¶ms); 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 + `&repo=tinyrelay&view=issue&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 | +} |
| 293 | diff --git a/internal/webui/browse.html b/internal/webui/browse.html |
| 294 | index 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> |
| 326 | diff --git a/internal/webui/components.js b/internal/webui/components.js |
| 327 | index 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; |
| 498 | diff --git a/internal/webui/page.html b/internal/webui/page.html |
| 499 | index 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}} |
| 528 | diff --git a/internal/webui/style.css b/internal/webui/style.css |
| 529 | index 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} |
| 562 | diff --git a/internal/webui/templates.go b/internal/webui/templates.go |
| 563 | index 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 { |
| 585 | diff --git a/internal/webui/webui.go b/internal/webui/webui.go |
| 586 | index 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": |