The standalone Approvals page now renders the same choice card as the room timeline for Tiny agent questions, approvals and confirmations: single and multiple selection, free-text answers and combined answers. It reuses the chat-decision element so one implementation publishes kind 1111 answers from both pages.
- commit
- e98cef486f70dcbe7d2d88b5aff0dc60577b86e1
- parent
- bdd572c1db4a716eb9761a8fd283bdad422b84d6
- author
- Dami <dami.etoile@gmail.com>
| M | scripts/ | 88 | ++++++++++++++++++++++++++++++ |
| M | tinyclient/ | 27 | +++++++++ |
| M | tinyclient/ | 63 | +++++++++++++++++++++- |
| M | tinyclient/ | 3 | +- |
@@ -174,3 +174,91 @@ test("a notification action opens the answer in the existing window, or a new on
174174 await odd.finished();175175 assert.equal(fresh.opened.at(-1), url);176176 });177+178+// A Tiny agent interaction on the Approvals page is answered by the179+// chat-decision element from chat-activity.js. Its host there is an inert180+// chat-activity whose endpoint is the page itself: read() re-fetches181+// /approvals to confirm the request still waits for this account, and without182+// a Fixi action the host neither polls nor swaps, so refresh() returns at once.183+const activitySource = fs.readFileSync("tinyclient/chat-activity.js", "utf8");184+const owner = "c".repeat(64);185+class Element {186+ constructor(tag = "div") { this.localName = tag; this.childNodes = []; this.attributes = {}; this.parent = null; this.listeners = {}; }187+ get isConnected() { return Boolean(this.parent); }188+ append(...items) { for (const item of items) { if (item instanceof Element) item.parent = this; this.childNodes.push(item); } }189+ replaceChildren(...items) { this.childNodes = []; this.append(...items); }190+ setAttribute(name, value) { this.attributes[name] = String(value); }191+ getAttribute(name) { return this.attributes[name] ?? null; }192+ hasAttribute(name) { return Object.hasOwn(this.attributes, name); }193+ querySelectorAll() { return []; }194+ contains(node) { return node === this || this.childNodes.some(child => child instanceof Element && child.contains(node)); }195+ closest(selector) { return selector === this.localName ? this : this.parent?.closest(selector) || null; }196+ dispatchEvent(event) { this.listeners[event.type]?.(event); return true; }197+ addEventListener(name, fn) { this.listeners[name] = fn; }198+ removeEventListener(name) { delete this.listeners[name]; }199+}200+201+// approvalsPage renders the host and decision as the approvals template does202+// for one native request, with the page fetch answered by `listing`: the203+// chat-decision elements the re-read page still carries.204+function approvalsPage({selection = "single", interaction = "approval", freeform = false, listing = null} = {}) {205+ const document = new Element("document"); document.hidden = false; document.activeElement = null;206+ const definitions = {}, fetched = [], published = [];207+ const signer = {getPublicKey: async () => owner};208+ const tiny = {signer: () => signer, localPath: path => "/r/team" + path, signing: {publish: async event => { published.push(event); return event; }},209+ ui: {FormElement: class extends Element { report(message) { this.message = message; } connectedCallback() { this.form = {}; } }}};210+ const template = new Element("template");211+ document.createElement = tag => tag === "template" ? template : new Element(tag);212+ const sandbox = {document, HTMLElement: Element, customElements: {define: (name, ctor) => definitions[name] = ctor}, tiny,213+ location: {href: "https://tiny.example/r/team/approvals", origin: "https://tiny.example"}, URL, AbortController, setInterval: () => 1, clearInterval() {}, setTimeout, clearTimeout, queueMicrotask,214+ CustomEvent: class { constructor(type, options) { this.type = type; Object.assign(this, options); } }, morph() {},215+ fetch: async (url, options) => { fetched.push({url: String(url), options}); return {ok: true, text: async () => "<html><body><section id=approvals></section></body></html>"}; }, Date, JSON, Error};216+ vm.runInNewContext(activitySource, sandbox);217+ const host = new (definitions["chat-activity"])(); host.localName = "chat-activity"; host.parent = document;218+ host.setAttribute("endpoint", "/approvals"); host.setAttribute("room", "build"); host.setAttribute("actor", owner);219+ const attributes = [["event", request], ["pubkey", asker], ["kind", "9"], ["room", "build"], ["actor", owner], ["expires", "0"], ["interaction", interaction], ["selection", selection], ["native", ""]];220+ if (freeform) attributes.push(["freeform", ""]);221+ if (interaction === "question") attributes.push(["question", ""]);222+ if (selection === "multiple") attributes.push(["multiple", ""]);223+ const decision = new (definitions["chat-decision"])(); decision.localName = "chat-decision";224+ const fresh = new Element("chat-decision");225+ for (const [name, value] of attributes) { decision.setAttribute(name, value); fresh.setAttribute(name, value); }226+ fresh.querySelectorAll = () => selection === "text" ? [] : [{value: "yes"}, {value: "no"}];227+ host.append(decision); host.connectedCallback();228+ template.content = {querySelectorAll: name => name === "chat-decision" ? (listing ?? [fresh]) : []};229+ return {host, decision, fetched, published};230+}231+232+test("a native answer from the Approvals page re-reads the page and publishes the room-tagged NIP-22 answer", async () => {233+ const page = approvalsPage();234+ await page.decision.submit({elements: {option: {value: "yes", checked: true}}});235+ assert.equal(page.fetched.length, 1);236+ assert.equal(page.fetched[0].url, "https://tiny.example/r/team/approvals");237+ assert.equal(page.fetched[0].options.credentials, "same-origin");238+ assert.equal(page.fetched[0].options.cache, "no-store");239+ assert.equal(page.published.length, 1);240+ const event = page.published[0];241+ assert.equal(event.kind, 1111);242+ assert.equal(event.content, "yes");243+ assert.deepEqual(JSON.parse(JSON.stringify(event.tags)), [["h", "build"], ["E", request, "", asker], ["K", "9"], ["P", asker], ["e", request, "", asker], ["k", "9"], ["p", asker]]);244+ assert.equal(page.decision.message, "Answer sent.");245+ // The inert host has no Fixi action: nothing polls the page after the answer.246+ assert.equal(page.host.__fixi, undefined);247+ assert.equal(page.host.loading, undefined);248+});249+250+test("a multiple-choice question with free text publishes the combined answer from the Approvals page", async () => {251+ const page = approvalsPage({selection: "multiple", interaction: "question", freeform: true});252+ await page.decision.submit({elements: {option: [{value: "yes", checked: true}, {value: "no", checked: true}], "custom-answer": {value: " Both hold. "}}});253+ assert.equal(page.published[0].content, '{"choices":["yes","no"],"text":"Both hold."}');254+ const text = approvalsPage({selection: "text", interaction: "question", freeform: true});255+ await text.decision.submit({elements: {"custom-answer": {value: "main"}}});256+ assert.equal(text.published[0].content, "main");257+});258+259+test("a request the re-read Approvals page no longer lists is not answered", async () => {260+ const page = approvalsPage({listing: []});261+ await assert.rejects(page.decision.submit({elements: {option: {value: "yes", checked: true}}}), /no longer waiting/);262+ assert.equal(page.published.length, 0);263+ page.host.disconnectedCallback();264+});@@ -25,6 +25,16 @@ type approvalView struct {
2525 AnsweredAt int642626 AnswerContent string2727 Grant *grantRequestView28+ // Room is the h tag of a room request; the answer to a native29+ // interaction must carry it.30+ Room string31+ // Native marks a Tiny agent interaction, answered with the choices the32+ // room card offers: Options as radio or checkbox inputs by Selection, a33+ // free-text answer when Freeform, and only text when Selection is text.34+ // Question is set for a question request or interaction.35+ Native, Multiple, Freeform, Question bool36+ Interaction, Selection string37+ Options []chatActivityOption2838 }2939 3040 type grantRequestView struct {@@ -87,10 +97,27 @@ func approvalViewFrom(item map[string]any, base string) approvalView {
8797 }8898 if room := plainString(item["room"]); room != "" {8999 view.Where = "#" + room100+ view.Room = room90101 }91102 if answer := valueMap(item["answer"]); len(answer) > 0 {92103 view.Decision, view.AnsweredAt, view.AnswerContent = plainString(answer["decision"]), unixSeconds(answer["created_at"]), strings.TrimSpace(plainString(answer["content"]))93104 }105+ view.Question = view.Type == "question"106+ // The same test the room card applies: a selection with options, or a107+ // text selection on an event tagged tinyagent.108+ rawOptions := collaborationSlice(item, "options")109+ if selection := plainString(item["selection"]); selection != "" && (len(rawOptions) > 0 || eventHasTag(item, "tinyagent")) {110+ view.Native = true111+ view.Interaction, view.Selection = plainString(item["interaction"]), selection112+ view.Question = view.Interaction == "question"113+ view.Multiple = selection == "multiple"114+ view.Freeform = plainString(item["freeform"]) == "true" || selection == "text"115+ for _, rawOption := range rawOptions {116+ option := valueMap(rawOption)117+ view.Options = append(view.Options, chatActivityOption{ID: plainString(option["id"]), Label: plainString(option["label"])})118+ }119+ view.AnswerContent = nativeActivityAnswer(view.AnswerContent, view.Options)120+ }94121 if view.Type == "grant" {95122 raw := valueMap(item["grant"])96123 rows := []grantScopeRow{}@@ -29,9 +29,18 @@ func (b *approvalsBackend) Query(_ context.Context, method string, params []json
2929 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"}3030 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}}3131 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+ // Tiny agent interactions: a multiple-choice question with a free-text33+ // answer, an open-ended text question, a single-choice approval, and an34+ // answered question whose combined answer is shown by option label.35+ native := []any{map[string]any{"id": "ship", "label": "Ship it"}, map[string]any{"id": "hold", "label": "Hold"}}36+ nativeEvent := map[string]any{"tags": []any{[]any{"tinyagent", "1"}, []any{"h", "build"}}}37+ nativeQuestion := map[string]any{"id": strings.Repeat("6", 64), "kind": 9, "type": "question", "asker": asker, "asked": []string{b.policy.Owner}, "subject": "Ship the build?", "content": "The build is green.", "created_at": 1788876200, "expires": 1788897600, "state": "open", "room": "build", "interaction": "question", "selection": "multiple", "freeform": true, "options": native, "event": nativeEvent}38+ nativeText := map[string]any{"id": strings.Repeat("7", 64), "kind": 9, "type": "question", "asker": asker, "asked": []string{b.policy.Owner}, "content": "Which branch?", "created_at": 1788876300, "state": "open", "room": "build", "interaction": "question", "selection": "text", "event": nativeEvent}39+ nativeApproval := map[string]any{"id": strings.Repeat("8", 64), "kind": 9, "type": "approve", "asker": asker, "asked": []string{b.policy.Owner}, "content": "Delete the stale branch?", "created_at": 1788876400, "state": "open", "room": "build", "interaction": "approval", "selection": "single", "options": []any{map[string]any{"id": "yes", "label": "Yes"}, map[string]any{"id": "no", "label": "No"}}, "event": nativeEvent}40+ nativeAnswered := map[string]any{"id": strings.Repeat("9", 64), "kind": 9, "type": "question", "asker": asker, "asked": []string{b.policy.Owner}, "subject": "Ship 1.3?", "created_at": 1788869000, "state": "answered", "room": "build", "interaction": "question", "selection": "multiple", "freeform": true, "options": native, "event": nativeEvent, "answer": map[string]any{"id": strings.Repeat("f", 64), "kind": 1111, "author": b.policy.Owner, "decision": "replied", "content": `{"choices":["ship"],"text":"Go."}`, "created_at": 1788869500}}3241 switch method {3342 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"}}}}, nil43+ return map[string]any{"items": []any{open, question, answered, expired, nativeQuestion, nativeText, nativeApproval, nativeAnswered}, "next_cursor": "", "counts": map[string]int{"open": 5, "answered": 2, "expired": 1}, "oldest_open": 1788876000, "devices": []any{map[string]any{"created_at": 1788800000, "categories": []string{"approvals", "mentions"}}}}, nil3544 case "browseapproval":3645 var q map[string]any3746 _ = json.Unmarshal(params[0], &q)@@ -70,8 +79,16 @@ func TestApprovalsPageRendersRequestsForTheOwner(t *testing.T) {
7079 `<a href="/repos/` + backend.policy.Owner + `/tinyrelay/issues/` + strings.Repeat("d", 64) + `">open</a>`,7180 `<approval-item id="approval-` + strings.Repeat("2", 64) + `" data-type="question"`, "in #build", `<a href="/e/` + strings.Repeat("2", 64) + `">open</a>`,7281 "<h3>Answered</h3>", `<tr id="approval-` + strings.Repeat("3", 64) + `" data-state="answered">`, `<status-badge kind="approved">approved</status-badge> <time`, `data-state="expired"><td>`, `<td><status-badge kind="expired">expired</status-badge></td>`,73- "<h4>Waiting</h4>", "<th>open</th><td>2</td>", "<th>oldest</th><td><time", "<h4>Devices</h4>", "<p><i></i>device 1 | approvals, mentions</p>", "<p><i></i>device 1 | ",82+ "<h4>Waiting</h4>", "<th>open</th><td>5</td>", "<th>oldest</th><td><time", "<h4>Devices</h4>", "<p><i></i>device 1 | approvals, mentions</p>", "<p><i></i>device 1 | ",7483 `<a href="/approvals" aria-current="page">/approvals</a>`,84+ // A native interaction renders the room card's choices in a85+ // chat-decision whose host re-reads this page, and shows an answer86+ // by option label.87+ `<chat-activity endpoint="/approvals" room="build" actor="` + backend.policy.Owner + `"><chat-decision event="` + strings.Repeat("6", 64) + `" pubkey="` + asker + `" kind="9" room="build" actor="` + backend.policy.Owner + `" expires="1788897600" interaction="question" selection="multiple" native freeform question multiple>`,88+ `<legend>Choose one or more options</legend>`, `<label><input type="checkbox" name="option" value="ship"> Ship it</label><label><input type="checkbox" name="option" value="hold"> Hold</label></fieldset><label>Add details or write your own answer <textarea name="custom-answer" maxlength="8000" rows="2"></textarea></label><button type="submit" data-primary>Submit</button></chat-decision><noscript><p>Answering needs JavaScript and a connected signer.</p></noscript></chat-activity>`,89+ `<chat-decision event="` + strings.Repeat("7", 64) + `" pubkey="` + asker + `" kind="9" room="build" actor="` + backend.policy.Owner + `" expires="0" interaction="question" selection="text" native freeform question><label>Your answer <textarea name="custom-answer"`,90+ `<chat-decision event="` + strings.Repeat("8", 64) + `" pubkey="` + asker + `" kind="9" room="build" actor="` + backend.policy.Owner + `" expires="0" interaction="approval" selection="single" native><fieldset><legend>Choose an option</legend><label><input type="radio" name="option" value="yes"> Yes</label><label><input type="radio" name="option" value="no"> No</label></fieldset><button type="submit" data-primary>Submit</button></chat-decision>`,91+ `<tr id="approval-` + strings.Repeat("9", 64) + `" data-state="answered">`, "<small>Ship it\n\nGo.</small>",7592 } {7693 if !strings.Contains(body, marker) {7794 t.Fatalf("missing %q in\n%s", marker, body)@@ -83,6 +100,20 @@ func TestApprovalsPageRendersRequestsForTheOwner(t *testing.T) {
83100 if strings.Contains(question, "<nostr-react") || !strings.Contains(question, `<nostr-compose kind="1111" root="`+strings.Repeat("2", 64)+`" root-pubkey="`+asker+`" root-kind="9">`) {84101 t.Fatalf("question controls:\n%s", question)85102 }103+ // A native item offers neither the reaction buttons nor the plain reply,104+ // which could not carry a valid answer; its host must not poll or swap.105+ for _, id := range []string{strings.Repeat("6", 64), strings.Repeat("7", 64), strings.Repeat("8", 64)} {106+ item := body[strings.Index(body, `id="approval-`+id):]107+ item = item[:strings.Index(item, "</approval-item>")]108+ if strings.Contains(item, "<nostr-react") || strings.Contains(item, "<nostr-compose") || strings.Contains(item, "fx-action") || strings.Contains(item, "fx-trigger") || !strings.Contains(item, `<footer><a href="/e/`+id+`">open</a></footer>`) {109+ t.Fatalf("native controls for %s:\n%s", id[:4], item)110+ }111+ }112+ approval := body[strings.Index(body, `id="approval-`+strings.Repeat("8", 64)):]113+ approval = approval[:strings.Index(approval, "</approval-item>")]114+ if strings.Contains(approval, "<textarea") {115+ t.Fatalf("native approval offers free text:\n%s", approval)116+ }86117 if strings.Contains(body, "class=") {87118 t.Fatal("page carries class attributes")88119 }@@ -138,3 +169,31 @@ func TestApprovalsPageAsksGuestsToSignIn(t *testing.T) {
138169 t.Fatalf("guest panel:\n%s", body)139170 }140171 }172+173+func TestApprovalViewReadsNativeInteractionFields(t *testing.T) {174+ asker := strings.Repeat("c", 64)175+ tagged := map[string]any{"tags": []any{[]any{"tinyagent", "1"}}}176+ options := []any{map[string]any{"id": "ship", "label": "Ship it"}, map[string]any{"id": "hold", "label": "Hold"}}177+ multiple := approvalViewFrom(map[string]any{"id": strings.Repeat("6", 64), "kind": 9, "type": "question", "asker": asker, "room": "build", "state": "answered", "interaction": "question", "selection": "multiple", "freeform": true, "options": options, "event": tagged, "answer": map[string]any{"decision": "replied", "content": `["ship","hold"]`}}, "")178+ if !multiple.Native || multiple.Interaction != "question" || multiple.Selection != "multiple" || !multiple.Multiple || !multiple.Freeform || !multiple.Question || multiple.Room != "build" || len(multiple.Options) != 2 || multiple.Options[1] != (chatActivityOption{ID: "hold", Label: "Hold"}) || multiple.AnswerContent != "Ship it\nHold" {179+ t.Fatalf("multiple-choice view %+v", multiple)180+ }181+ text := approvalViewFrom(map[string]any{"id": strings.Repeat("7", 64), "kind": 9, "type": "question", "asker": asker, "room": "build", "state": "open", "interaction": "question", "selection": "text", "event": tagged}, "")182+ if !text.Native || text.Selection != "text" || text.Multiple || !text.Freeform || !text.Question || len(text.Options) != 0 {183+ t.Fatalf("text view %+v", text)184+ }185+ approval := approvalViewFrom(map[string]any{"id": strings.Repeat("8", 64), "kind": 9, "type": "approve", "asker": asker, "room": "build", "state": "open", "interaction": "approval", "selection": "single", "options": options, "event": tagged}, "")186+ if !approval.Native || approval.Selection != "single" || approval.Multiple || approval.Freeform || approval.Question || approval.Interaction != "approval" {187+ t.Fatalf("approval view %+v", approval)188+ }189+ // Without options and without the tinyagent tag a selection is not a190+ // native interaction; an ordinary question keeps Question for its type.191+ plain := approvalViewFrom(map[string]any{"id": strings.Repeat("2", 64), "kind": 9, "type": "question", "asker": asker, "room": "build", "state": "open", "selection": "single", "event": map[string]any{"tags": []any{}}}, "")192+ if plain.Native || !plain.Question || plain.Selection != "" || plain.Interaction != "" || plain.Options != nil {193+ t.Fatalf("plain view %+v", plain)194+ }195+ ordinary := approvalViewFrom(map[string]any{"id": strings.Repeat("1", 64), "kind": 1111, "type": "approve", "asker": asker, "state": "answered", "answer": map[string]any{"decision": "replied", "content": `["ship"]`}}, "")196+ if ordinary.Native || ordinary.Question || ordinary.Room != "" || ordinary.AnswerContent != `["ship"]` {197+ t.Fatalf("ordinary view %+v", ordinary)198+ }199+}@@ -61,7 +61,8 @@
6161 <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>6262 <p>{{$item.Title}}</p>{{with $item.Body}}<p>{{.}}</p>{{end}}{{with $item.About}}<pre>{{.}}</pre>{{end}}6363 {{with $item.Grant}}<agent-grant-request request="{{$item.ID}}" data-review="{{.Raw}}"{{if .Error}} data-stale="true"{{end}}><h4>Proposed grant change</h4><table><thead><tr><th>scope</th><th>current</th><th>proposed</th></tr></thead><tbody>{{range .Rows}}{{if ne .Before .After}}<tr><th>{{.Label}}</th><td>{{.Before}}</td><td>{{.After}}</td></tr>{{end}}{{end}}</tbody></table>{{with .Error}}<p role="alert">{{.}}</p>{{else}}<button type="button" data-grant-approve data-primary disabled>Approve and grant</button>{{end}}<button type="button" data-grant-deny data-danger disabled>Deny</button></agent-grant-request>{{end}}64-<footer>{{if and (ne $item.Type "question") (ne $item.Type "grant")}}<nostr-react event="{{$item.ID}}" pubkey="{{$item.Asker}}" kind="{{$item.Kind}}"><button name="reaction" value="+" data-primary>{{if eq $item.Type "wiki-proposal"}}Accept{{else if eq $item.Type "wiki-merge"}}Accept{{else}}Approve{{end}}</button> <button name="reaction" value="-" data-danger>{{if eq $item.Type "wiki-proposal"}}Reject{{else if eq $item.Type "wiki-merge"}}Reject{{else}}Deny{{end}}</button></nostr-react> {{end}}<details><summary>Reply</summary><nostr-compose kind="1111" root="{{$item.ID}}" root-pubkey="{{$item.Asker}}" root-kind="{{$item.Kind}}"{{with $item.Coordinate}} coordinate="{{.}}"{{end}}><label>Reply <textarea name="content" required></textarea></label><button>Sign and reply</button></nostr-compose></details> <a href="{{$item.Open}}">open</a></footer>64+{{/* A Tiny agent interaction is answered by the chat-decision element the room card uses. Its host is an inert chat-activity whose endpoint is this page: the element re-reads it before signing to confirm the request still waits, and without a Fixi action the host neither polls nor swaps. */}}{{if $item.Native}}<chat-activity endpoint="/approvals" room="{{$item.Room}}" actor="{{$.Actor}}"><chat-decision event="{{$item.ID}}" pubkey="{{$item.Asker}}" kind="{{$item.Kind}}" room="{{$item.Room}}" actor="{{$.Actor}}" expires="{{$item.Expires}}" interaction="{{$item.Interaction}}" selection="{{$item.Selection}}" native{{if $item.Freeform}} freeform{{end}}{{if $item.Question}} question{{end}}{{if $item.Multiple}} multiple{{end}}>{{if $item.Options}}<fieldset><legend>{{if $item.Multiple}}Choose one or more options{{else}}Choose an option{{end}}</legend>{{range $item.Options}}<label><input type="{{if $item.Multiple}}checkbox{{else}}radio{{end}}" name="option" value="{{.ID}}"> {{.Label}}</label>{{end}}</fieldset>{{end}}{{if $item.Freeform}}<label>{{if $item.Options}}Add details or write your own answer{{else}}Your answer{{end}} <textarea name="custom-answer" maxlength="8000" rows="2"></textarea></label>{{end}}<button type="submit" data-primary>Submit</button></chat-decision><noscript><p>Answering needs JavaScript and a connected signer.</p></noscript></chat-activity>65+{{end}}<footer>{{if not $item.Native}}{{if and (ne $item.Type "question") (ne $item.Type "grant")}}<nostr-react event="{{$item.ID}}" pubkey="{{$item.Asker}}" kind="{{$item.Kind}}"><button name="reaction" value="+" data-primary>{{if eq $item.Type "wiki-proposal"}}Accept{{else if eq $item.Type "wiki-merge"}}Accept{{else}}Approve{{end}}</button> <button name="reaction" value="-" data-danger>{{if eq $item.Type "wiki-proposal"}}Reject{{else if eq $item.Type "wiki-merge"}}Reject{{else}}Deny{{end}}</button></nostr-react> {{end}}<details><summary>Reply</summary><nostr-compose kind="1111" root="{{$item.ID}}" root-pubkey="{{$item.Asker}}" root-kind="{{$item.Kind}}"{{with $item.Coordinate}} coordinate="{{.}}"{{end}}><label>Reply <textarea name="content" required></textarea></label><button>Sign and reply</button></nostr-compose></details> {{end}}<a href="{{$item.Open}}">open</a></footer>6566 </approval-item>6667 {{end}}{{else}}<p data-empty>Nothing waits for your decision.</p>6768 {{end}}{{if $done}}<h3>Answered</h3>