clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat(tinyclient): answer native agent requests from the Approvals page

Dami committed e98cef4
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>
4 files changed, +178 -3
Mscripts/approvals.test.mjs88++++++++++++++++++++++++++++++
Mtinyclient/approvals.go27+++++++++
Mtinyclient/approvals_test.go63+++++++++++++++++++++-
Mtinyclient/browse.html3+-
M scripts/approvals.test.mjs +88 -0
@@ -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+});
M tinyclient/approvals.go +27 -0
@@ -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{}
M tinyclient/approvals_test.go +61 -2
@@ -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+}
M tinyclient/browse.html +2 -1
@@ -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>