| 1 | diff --git a/internal/webui/browse.html b/internal/webui/browse.html |
| 2 | index e1d412b..6219251 100644 |
| 3 | --- a/internal/webui/browse.html |
| 4 | +++ b/internal/webui/browse.html |
| 5 | @@ -58,7 +58,7 @@ |
| 6 | {{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}}"> |
| 7 | <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> |
| 8 | <p>{{$item.Title}}</p>{{with $item.Body}}<p>{{.}}</p>{{end}}{{with $item.About}}<pre>{{.}}</pre>{{end}} |
| 9 | -<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> |
| 10 | +<footer>{{if ne $item.Type "question"}}<nostr-react event="{{$item.ID}}" pubkey="{{$item.Asker}}" kind="{{$item.Kind}}"><button name="reaction" value="+">Approve</button> <button name="reaction" 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> |
| 11 | </approval-item> |
| 12 | {{end}}{{else}}<p>Nothing waits for your decision.</p> |
| 13 | {{end}}{{if $done}}<h3>Answered</h3> |
| 14 | diff --git a/internal/webui/components.js b/internal/webui/components.js |
| 15 | index 9b63c1a..71f7915 100644 |
| 16 | --- a/internal/webui/components.js |
| 17 | +++ b/internal/webui/components.js |
| 18 | @@ -30,10 +30,16 @@ |
| 19 | // <nostr-compose kind="…" [coordinate="30617:…"] [root="…" root-pubkey="…" root-kind="…"] [parent="…" …]> |
| 20 | // Signs a NIP-34 issue, pull request, NIP-22 reply or status event and |
| 21 | // posts it to the relay. A reply outside a repository omits the coordinate. |
| 22 | +// <wiki-compose name="…" [author="…" coordinate="30818:…" event="…"]> |
| 23 | +// Signs a NIP-54 article and posts it. Someone other than the author |
| 24 | +// publishes a fork of the version in view; a "propose" button also signs |
| 25 | +// a merge request to that author. |
| 26 | // <nostr-react event="…" pubkey="…" [kind="…"]> |
| 27 | -// Answers a request for a decision with a signed kind 7 reaction: the |
| 28 | -// Approve button sends +, Deny sends -. The prompt method asks for a |
| 29 | -// visible confirmation first, which the answer link from a notification uses. |
| 30 | +// Signs a NIP-25 reaction to one event; the pressed button carries "+" |
| 31 | +// or "-". A request for a decision is approved or denied and a wiki |
| 32 | +// merge request accepted or rejected this way. The prompt method asks |
| 33 | +// for a visible confirmation first, which the answer link from a |
| 34 | +// notification uses. |
| 35 | // <approval-item id="approval-…"> |
| 36 | // One request on the Approvals page. When the page opens with the item's |
| 37 | // id and an answer in the query, it scrolls into view and offers that answer. |
| 38 | @@ -74,6 +80,8 @@ |
| 39 | event.preventDefault(); |
| 40 | if (this.submitting) return; |
| 41 | this.submitting = true; |
| 42 | + // The button that sent the form, for elements whose buttons differ. |
| 43 | + this.submitter = event.submitter || null; |
| 44 | this.busy(true); |
| 45 | try { |
| 46 | await this.submit(form); |
| 47 | @@ -562,11 +570,9 @@ |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | - // NostrReact answers a request for a decision. The reaction names the |
| 52 | - // request with e and the asker with p, so the asker's own subscription |
| 53 | - // sees the answer. Nothing is signed without a press: the buttons submit |
| 54 | - // the form, and prompt() asks once more before signing on behalf of a |
| 55 | - // notification action. |
| 56 | + // publishSigned asks the signer for a signature, checks that what came |
| 57 | + // back is the event that was requested, since extensions may edit input, |
| 58 | + // then posts it once and reports the relay's answer. |
| 59 | const publishSigned = async unsigned => { |
| 60 | if (!window.nostr?.signEvent) throw Error("Connect a signer first."); |
| 61 | const expected = JSON.stringify(unsigned); |
| 62 | @@ -574,11 +580,22 @@ |
| 63 | const actual = event && JSON.stringify({kind: event.kind, created_at: event.created_at, tags: event.tags, content: event.content}); |
| 64 | if (actual !== expected || !window.NostrSigner?.verifyEvent(event)) throw Error("The signer returned an invalid or changed event."); |
| 65 | const response = await tiny.signedFetch("/events", "POST", JSON.stringify(event), {contentType: "application/json"}); |
| 66 | - const result = await response.json(); |
| 67 | - if (!response.ok || result.accepted !== true) throw Error(result.error || result.message || "The relay rejected the event."); |
| 68 | + let result = null; |
| 69 | + try { result = await response.json(); } catch {} |
| 70 | + if (!response.ok || result?.accepted !== true) throw Error(result?.error || result?.message || "The relay rejected the event."); |
| 71 | return event; |
| 72 | }; |
| 73 | + const unixNow = () => Math.floor(Date.now() / 1000); |
| 74 | + |
| 75 | + // NostrReact signs a NIP-25 reaction to one event: "+" or "-" from the |
| 76 | + // pressed button. The reaction names the event with e and its author with |
| 77 | + // p, so the author's own subscription sees the answer. A request for a |
| 78 | + // decision is approved or denied this way and a wiki merge request |
| 79 | + // accepted or rejected. Nothing is signed without a press: the buttons |
| 80 | + // submit the form, and prompt() asks once more before signing on behalf of |
| 81 | + // a notification action. |
| 82 | const decisions = {"+": "approve", "-": "deny"}; |
| 83 | + const reactionWords = {"+": "Approved.", "-": "Denied.", accept: "Accepted.", approve: "Approved.", reject: "Rejected.", deny: "Denied."}; |
| 84 | class NostrReact extends FormElement { |
| 85 | connectedCallback() { |
| 86 | if (this.form) return; |
| 87 | @@ -586,12 +603,12 @@ |
| 88 | if (this.listening) return; |
| 89 | this.listening = true; |
| 90 | this.addEventListener("click", event => { |
| 91 | - const button = event.target.closest("button[name=answer]"); |
| 92 | + const button = event.target.closest("button[name=reaction]"); |
| 93 | if (button) this.choice = button.value; |
| 94 | }); |
| 95 | } |
| 96 | |
| 97 | - async submit(form, choice = this.choice) { |
| 98 | + async submit(form, choice = this.choice ?? this.submitter?.value ?? form?.elements?.reaction?.value) { |
| 99 | const id = this.getAttribute("event"), pubkey = this.getAttribute("pubkey"); |
| 100 | if (!isHex64(id) || !isHex64(pubkey)) throw Error("The request address is missing."); |
| 101 | if (!(choice in decisions)) throw Error("Choose Approve or Deny."); |
| 102 | @@ -599,9 +616,11 @@ |
| 103 | const kind = this.getAttribute("kind"); |
| 104 | if (/^\d+$/.test(kind || "")) tags.push(["k", kind]); |
| 105 | this.report("Signing…"); |
| 106 | - await publishSigned({kind: 7, created_at: Math.floor(Date.now() / 1000), tags, content: choice}); |
| 107 | + await publishSigned({kind: 7, created_at: unixNow(), tags, content: choice}); |
| 108 | + const label = (this.submitter?.textContent || "").trim().toLowerCase(); |
| 109 | this.choice = null; |
| 110 | - this.report(choice === "+" ? "Approved." : "Denied."); |
| 111 | + this.submitter = null; |
| 112 | + this.report(reactionWords[label] || reactionWords[choice]); |
| 113 | await tiny.navigate?.((globalThis.location?.href || "").split("?", 1)[0]); |
| 114 | } |
| 115 | |
| 116 | @@ -632,6 +651,60 @@ |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | + // WikiCompose publishes a NIP-54 article. When the signer is not the |
| 121 | + // author of the version in view, the article is a fork of that version, |
| 122 | + // and "propose" also signs a kind 818 merge request to that author. The |
| 123 | + // name follows the title on a new page until it is edited by hand. |
| 124 | + class WikiCompose extends FormElement { |
| 125 | + connectedCallback() { |
| 126 | + super.connectedCallback(); |
| 127 | + const title = this.form?.elements?.title, name = this.form?.elements?.name; |
| 128 | + if (this.wired === this.form || !title || !name) return; |
| 129 | + this.wired = this.form; |
| 130 | + let follow = !this.getAttribute("name"); |
| 131 | + name.addEventListener("input", () => { follow = name.value.trim() === ""; }); |
| 132 | + name.addEventListener("change", () => { name.value = tiny.util.wikiName(name.value); }); |
| 133 | + title.addEventListener("input", () => { if (follow) name.value = tiny.util.wikiName(title.value); }); |
| 134 | + } |
| 135 | + |
| 136 | + async submit(form) { |
| 137 | + const value = key => form.elements[key]?.value.trim() || ""; |
| 138 | + const title = value("title"), d = tiny.util.wikiName(value("name") || title), summary = value("summary"); |
| 139 | + const content = form.elements.content?.value || ""; |
| 140 | + if (!title) throw Error("Enter a title."); |
| 141 | + if (!d) throw Error("Enter a name with at least one letter or digit."); |
| 142 | + if (!content.trim()) throw Error("Enter the content."); |
| 143 | + const action = this.submitter?.value || "publish"; |
| 144 | + const author = this.getAttribute("author") || "", coordinate = this.getAttribute("coordinate") || "", base = this.getAttribute("event") || ""; |
| 145 | + if (!window.nostr?.signEvent) throw Error("Connect a signer first."); |
| 146 | + const pubkey = typeof window.nostr.getPublicKey === "function" ? await window.nostr.getPublicKey() : ""; |
| 147 | + const forking = isHex64(author) && author !== pubkey; |
| 148 | + if (action === "propose" && !forking) throw Error("This is your own version; publish it instead."); |
| 149 | + const tags = [["d", d], ["title", title]]; |
| 150 | + if (summary) tags.push(["summary", summary]); |
| 151 | + if (forking) { |
| 152 | + if (!/^30818:[0-9a-f]{64}:.+$/.test(coordinate) || !isHex64(base)) throw Error("The version to fork is missing."); |
| 153 | + tags.push(["a", coordinate, "", "fork"], ["e", base, "", "fork"]); |
| 154 | + } |
| 155 | + const version = await publishSigned({kind: 30818, created_at: unixNow(), tags, content}); |
| 156 | + if (action === "propose") { |
| 157 | + const request = {kind: 818, created_at: unixNow(), tags: [["a", coordinate], ["e", version.id, "", "source"], ["e", base], ["p", author]], content: summary || "Proposed version of " + title}; |
| 158 | + await publishSigned(request); |
| 159 | + this.report("Published and proposed to " + author.slice(0, 12) + "."); |
| 160 | + } else { |
| 161 | + this.report("Published."); |
| 162 | + } |
| 163 | + const path = "/wiki/" + encodeURIComponent(d); |
| 164 | + const href = tiny.localPath ? tiny.localPath(path) : path; |
| 165 | + if (tiny.navigate) { |
| 166 | + globalThis.history?.pushState?.({}, "", href); |
| 167 | + await tiny.navigate(href); |
| 168 | + } else { |
| 169 | + globalThis.location?.assign?.(href); |
| 170 | + } |
| 171 | + } |
| 172 | + } |
| 173 | + |
| 174 | // ApprovalItem reacts to the query a notification action opens the page |
| 175 | // with: id names the request and answer is approve, deny or reply. |
| 176 | class ApprovalItem extends HTMLElement { |
| 177 | @@ -1100,6 +1173,7 @@ |
| 178 | customElements.define("agent-grant", AgentGrant); |
| 179 | customElements.define("nostr-react", NostrReact); |
| 180 | customElements.define("approval-item", ApprovalItem); |
| 181 | + customElements.define("wiki-compose", WikiCompose); |
| 182 | customElements.define("push-toggle", PushToggle); |
| 183 | customElements.define("share-link", ShareLink); |
| 184 | customElements.define("nostr-key", NostrKey); |
| 185 | @@ -1130,7 +1204,7 @@ |
| 186 | if (root && !(url.pathname === root || url.pathname.startsWith(root + "/"))) return null; |
| 187 | return root ? url.pathname.slice(root.length) || "/" : url.pathname; |
| 188 | }; |
| 189 | - 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 || ""); |
| 190 | + const allowedRoute = path => /^(?:\/(?:inbox|approvals|outbox|search|articles|private|chat|media|sites|marmot|grasp|terms|signin|connect|tools|repo|repos|file|files|wiki)?\/?|\/manage(?:\/(?:people|agents|moderation|rules|identity|connect|data|sync|views|health|owner|status))?\/?|\/(?:invite|e|a|wiki)\/.+)$/.test(path || ""); |
| 191 | let navigationSerial = 0, activeAbort; |
| 192 | const streams = new Set(); |
| 193 | const closeStreams = () => { |
| 194 | @@ -1145,7 +1219,7 @@ |
| 195 | document.addEventListener("fx:sse:close", event => streams.delete(event.detail.cfg)); |
| 196 | window.addEventListener("pagehide", closeStreams); |
| 197 | const repairComponents = () => { |
| 198 | - document.querySelectorAll("rpc-form,signed-form,publish-list,agent-grant,nostr-react").forEach(node => { |
| 199 | + document.querySelectorAll("rpc-form,signed-form,publish-list,agent-grant,nostr-react,wiki-compose").forEach(node => { |
| 200 | if (node.form?.isConnected) return; |
| 201 | node.form = null; |
| 202 | node.output = null; |
| 203 | diff --git a/internal/webui/tiny.js b/internal/webui/tiny.js |
| 204 | index 748e838..e807137 100644 |
| 205 | --- a/internal/webui/tiny.js |
| 206 | +++ b/internal/webui/tiny.js |
| 207 | @@ -86,7 +86,21 @@ |
| 208 | const checksum = Array.from({length: 6}, (_, index) => (polymod >>> (5 * (5 - index))) & 31); |
| 209 | return prefix + "1" + words.concat(checksum).map(word => bech32Charset[word]).join(""); |
| 210 | }; |
| 211 | - tiny.util = Object.freeze({...tiny.util, bytes, hex, sha256, fromHex, element, relayURL, b64url, fromB64url, bech32Encode}); |
| 212 | + // wikiName turns a title into a NIP-54 page name the way the relay does: |
| 213 | + // lowercase, whitespace as hyphens, letters, digits and marks kept, |
| 214 | + // everything else dropped, hyphens collapsed and trimmed at both ends. |
| 215 | + const wikiName = title => { |
| 216 | + let out = ""; |
| 217 | + for (const ch of String(title ?? "").toLowerCase()) { |
| 218 | + if (/[\s\-_]/u.test(ch)) { |
| 219 | + if (out && !out.endsWith("-")) out += "-"; |
| 220 | + } else if (/[\p{L}\p{Nd}\p{M}]/u.test(ch)) { |
| 221 | + out += ch; |
| 222 | + } |
| 223 | + } |
| 224 | + return out.replace(/^-+|-+$/g, ""); |
| 225 | + }; |
| 226 | + tiny.util = Object.freeze({...tiny.util, bytes, hex, sha256, fromHex, element, relayURL, b64url, fromB64url, bech32Encode, wikiName}); |
| 227 | // signer returns the active signer: a resumed remote signer first, then a |
| 228 | // NIP-07 extension. |
| 229 | tiny.signer = () => globalThis.tinySigner || globalThis.nostr; |
| 230 | diff --git a/package.json b/package.json |
| 231 | index db1d6a0..2bbbdd9 100644 |
| 232 | --- a/package.json |
| 233 | +++ b/package.json |
| 234 | @@ -10,7 +10,8 @@ |
| 235 | "test:files": "node --test scripts/blossom-encryption.test.mjs scripts/blossom-manifests.test.mjs scripts/blossom-manifests-review.test.mjs scripts/blossom-upload.test.mjs scripts/file-messages.test.mjs scripts/file-workspace.test.mjs scripts/private-services.test.mjs scripts/tiny-namespace.test.mjs", |
| 236 | "test:collaboration": "node --test scripts/collaboration-compose.test.mjs", |
| 237 | "test:agents": "node --test scripts/agent-grant.test.mjs", |
| 238 | - "test:approvals": "node --test scripts/approvals.test.mjs" |
| 239 | + "test:approvals": "node --test scripts/approvals.test.mjs", |
| 240 | + "test:wiki": "node --test scripts/wiki-compose.test.mjs" |
| 241 | }, |
| 242 | "devDependencies": { |
| 243 | "@noble/hashes": "2.4.0", |
| 244 | diff --git a/scripts/wiki-compose.test.mjs b/scripts/wiki-compose.test.mjs |
| 245 | new file mode 100644 |
| 246 | index 0000000..0f52217 |
| 247 | --- /dev/null |
| 248 | +++ b/scripts/wiki-compose.test.mjs |
| 249 | @@ -0,0 +1,163 @@ |
| 250 | +import test from "node:test"; |
| 251 | +import assert from "node:assert/strict"; |
| 252 | +import fs from "node:fs"; |
| 253 | +import vm from "node:vm"; |
| 254 | +import { finalizeEvent, generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools"; |
| 255 | + |
| 256 | +const source = fs.readFileSync("internal/webui/components.js", "utf8"); |
| 257 | +const tinySource = fs.readFileSync("internal/webui/tiny.js", "utf8"); |
| 258 | +const wikiSource = source.slice(source.indexOf(" const publishSigned = "), source.indexOf(" // ApprovalItem reacts")); |
| 259 | +const isHex64 = value => /^[0-9a-f]{64}$/.test(value || ""); |
| 260 | + |
| 261 | +const util = (() => { |
| 262 | + const sandbox = {TextEncoder, TextDecoder, URL, crypto: {subtle: {}}, document: {}}; |
| 263 | + sandbox.globalThis = sandbox; |
| 264 | + vm.runInNewContext(tinySource, sandbox); |
| 265 | + return sandbox.tiny.util; |
| 266 | +})(); |
| 267 | + |
| 268 | +test("wikiName follows the relay's NIP-54 normalization", () => { |
| 269 | + for (const [title, name] of [ |
| 270 | + ["What's Up?", "whats-up"], |
| 271 | + ["日本語 Article", "日本語-article"], |
| 272 | + ["Москва", "москва"], |
| 273 | + [" --Hello__World-- ", "hello-world"], |
| 274 | + ["Release notes 1.4", "release-notes-14"], |
| 275 | + ["Files & Private Repositories", "files-private-repositories"], |
| 276 | + ["café au lait", "café-au-lait"], |
| 277 | + ["***", ""], |
| 278 | + ["", ""] |
| 279 | + ]) { |
| 280 | + assert.equal(util.wikiName(title), name, title); |
| 281 | + } |
| 282 | + assert.equal(util.wikiName(undefined), ""); |
| 283 | +}); |
| 284 | + |
| 285 | +// setup runs one element class from components.js against fakes for the |
| 286 | +// signer, the relay and the form, the way collaboration-compose.test does. |
| 287 | +function setup(name, attributes, values = {}, options = {}) { |
| 288 | + const secret = generateSecretKey(), pubkey = getPublicKey(secret); |
| 289 | + const sent = [], signed = []; |
| 290 | + class FormElement { |
| 291 | + getAttribute(key) { return attributes[key] ?? null; } |
| 292 | + report(message) { this.message = message; } |
| 293 | + } |
| 294 | + const window = {NostrSigner: {verifyEvent}, nostr: { |
| 295 | + getPublicKey: async () => pubkey, |
| 296 | + signEvent: async event => { |
| 297 | + signed.push(structuredClone(event)); |
| 298 | + if (options.change) event.content = "changed"; |
| 299 | + return finalizeEvent(structuredClone(event), secret); |
| 300 | + } |
| 301 | + }}; |
| 302 | + const tiny = {util, navigated: [], signedFetch: async (path, method, body) => { |
| 303 | + sent.push({path, method, event: JSON.parse(body)}); |
| 304 | + return Response.json(options.results?.[sent.length - 1] ?? options.result ?? {accepted: true}); |
| 305 | + }, navigate: async href => { tiny.navigated.push(href); }}; |
| 306 | + const Constructor = vm.runInNewContext(`${wikiSource}\n${name}`, {FormElement, window, tiny, URL, isHex64, Date, JSON, encodeURIComponent}); |
| 307 | + const component = new Constructor(); |
| 308 | + component.submitter = options.submitter ? {value: options.submitter, textContent: options.label || ""} : null; |
| 309 | + const form = {elements: Object.fromEntries(Object.entries(values).map(([key, value]) => [key, {value}]))}; |
| 310 | + return {component, form, sent, signed, tiny, pubkey}; |
| 311 | +} |
| 312 | + |
| 313 | +const author = "a".repeat(64), base = "b".repeat(64), mergeID = "c".repeat(64); |
| 314 | +const coordinate = `30818:${author}:release-notes-1-4`; |
| 315 | +const page = {name: "release-notes-1-4", author, coordinate, event: base}; |
| 316 | + |
| 317 | +test("a new page signs d, title and summary and opens the page", async () => { |
| 318 | + const s = setup("WikiCompose", {name: "", author: "", coordinate: "", event: ""}, {title: "What's Up?", name: "", summary: "A greeting", content: "# Hi\n\nHello."}); |
| 319 | + await s.component.submit(s.form); |
| 320 | + assert.equal(s.sent.length, 1); |
| 321 | + const event = s.sent[0].event; |
| 322 | + assert.equal(s.sent[0].path, "/events"); |
| 323 | + assert.equal(event.kind, 30818); |
| 324 | + assert.deepEqual(event.tags, [["d", "whats-up"], ["title", "What's Up?"], ["summary", "A greeting"]]); |
| 325 | + assert.equal(event.content, "# Hi\n\nHello."); |
| 326 | + assert.equal(verifyEvent(event), true); |
| 327 | + assert.equal(s.component.message, "Published."); |
| 328 | + assert.deepEqual(s.tiny.navigated, ["/wiki/whats-up"]); |
| 329 | +}); |
| 330 | + |
| 331 | +test("the name field wins over the title and is normalized", async () => { |
| 332 | + const s = setup("WikiCompose", {name: "", author: "", coordinate: "", event: ""}, {title: "Bitcoin", name: " BTC Coin ", content: "Money."}); |
| 333 | + await s.component.submit(s.form); |
| 334 | + assert.deepEqual(s.sent[0].event.tags, [["d", "btc-coin"], ["title", "Bitcoin"]]); |
| 335 | +}); |
| 336 | + |
| 337 | +test("another signer publishes a fork of the version in view", async () => { |
| 338 | + const s = setup("WikiCompose", page, {title: "Release notes 1.4", name: "release-notes-1-4", summary: "", content: "Rewritten."}); |
| 339 | + await s.component.submit(s.form); |
| 340 | + assert.equal(s.sent.length, 1); |
| 341 | + assert.deepEqual(s.sent[0].event.tags, [["d", "release-notes-1-4"], ["title", "Release notes 1.4"], ["a", coordinate, "", "fork"], ["e", base, "", "fork"]]); |
| 342 | + assert.notEqual(s.sent[0].event.pubkey, author); |
| 343 | +}); |
| 344 | + |
| 345 | +test("propose publishes the fork and then a merge request to the author", async () => { |
| 346 | + const s = setup("WikiCompose", page, {title: "Release notes 1.4", name: "release-notes-1-4", summary: "Fix the upload section", content: "Rewritten."}, {submitter: "propose"}); |
| 347 | + await s.component.submit(s.form); |
| 348 | + assert.equal(s.sent.length, 2); |
| 349 | + const [version, request] = s.sent.map(item => item.event); |
| 350 | + assert.equal(version.kind, 30818); |
| 351 | + assert.equal(request.kind, 818); |
| 352 | + assert.deepEqual(request.tags, [["a", coordinate], ["e", version.id, "", "source"], ["e", base], ["p", author]]); |
| 353 | + assert.equal(request.content, "Fix the upload section"); |
| 354 | + assert.equal(verifyEvent(request), true); |
| 355 | + assert.match(s.component.message, /proposed to aaaaaaaaaaaa/); |
| 356 | + assert.deepEqual(s.tiny.navigated, ["/wiki/release-notes-1-4"]); |
| 357 | +}); |
| 358 | + |
| 359 | +test("the author's own edit carries no fork tags and cannot be proposed", async () => { |
| 360 | + const s = setup("WikiCompose", page, {title: "Release notes 1.4", name: "release-notes-1-4", content: "Edited."}); |
| 361 | + s.component.getAttribute = key => ({...page, author: s.pubkey, coordinate: `30818:${s.pubkey}:release-notes-1-4`})[key] ?? null; |
| 362 | + await s.component.submit(s.form); |
| 363 | + assert.deepEqual(s.sent[0].event.tags, [["d", "release-notes-1-4"], ["title", "Release notes 1.4"]]); |
| 364 | + const own = setup("WikiCompose", page, {title: "Release notes 1.4", name: "release-notes-1-4", content: "Edited."}, {submitter: "propose"}); |
| 365 | + own.component.getAttribute = key => ({...page, author: own.pubkey})[key] ?? null; |
| 366 | + await assert.rejects(own.component.submit(own.form), /your own version/); |
| 367 | + assert.equal(own.sent.length, 0); |
| 368 | +}); |
| 369 | + |
| 370 | +test("missing fields, a changed signature and a relay refusal stop before or after one event", async () => { |
| 371 | + for (const [values, message] of [[{title: "", name: "", content: "x"}, /title/], [{title: "***", name: "", content: "x"}, /letter or digit/], [{title: "T", name: "", content: " "}, /content/]]) { |
| 372 | + const s = setup("WikiCompose", {name: "", author: "", coordinate: "", event: ""}, values); |
| 373 | + await assert.rejects(s.component.submit(s.form), message); |
| 374 | + assert.equal(s.signed.length, 0); |
| 375 | + } |
| 376 | + const changed = setup("WikiCompose", page, {title: "T", name: "t", content: "x"}, {change: true}); |
| 377 | + await assert.rejects(changed.component.submit(changed.form), /changed/); |
| 378 | + assert.equal(changed.sent.length, 0); |
| 379 | + const refused = setup("WikiCompose", page, {title: "T", name: "t", content: "x"}, {submitter: "propose", results: [{accepted: false, message: "blocked"}]}); |
| 380 | + await assert.rejects(refused.component.submit(refused.form), /blocked/); |
| 381 | + assert.equal(refused.sent.length, 1); |
| 382 | + assert.deepEqual(refused.tiny.navigated, []); |
| 383 | +}); |
| 384 | + |
| 385 | +test("nostr-react signs the reaction the pressed button carries", async () => { |
| 386 | + for (const [reaction, message] of [["+", "Accepted."], ["-", "Rejected."]]) { |
| 387 | + const s = setup("NostrReact", {event: mergeID, pubkey: author, kind: "818"}, {}, {submitter: reaction, label: reaction === "+" ? "Accept" : "Reject"}); |
| 388 | + await s.component.submit(s.form); |
| 389 | + const event = s.sent[0].event; |
| 390 | + assert.equal(event.kind, 7); |
| 391 | + assert.equal(event.content, reaction); |
| 392 | + assert.deepEqual(event.tags, [["e", mergeID, "", author], ["p", author], ["k", "818"]]); |
| 393 | + assert.equal(verifyEvent(event), true); |
| 394 | + assert.equal(s.component.message, message); |
| 395 | + } |
| 396 | + const plain = setup("NostrReact", {event: mergeID, pubkey: author}, {}, {submitter: "+"}); |
| 397 | + await plain.component.submit(plain.form); |
| 398 | + assert.deepEqual(plain.sent[0].event.tags, [["e", mergeID, "", author], ["p", author]]); |
| 399 | +}); |
| 400 | + |
| 401 | +test("nostr-react refuses unknown reactions and bad addresses before signing", async () => { |
| 402 | + for (const [attributes, submitter, message] of [ |
| 403 | + [{event: mergeID, pubkey: author}, "meh", /Approve or Deny/], |
| 404 | + [{event: "short", pubkey: author}, "+", /missing/], |
| 405 | + [{event: mergeID, pubkey: ""}, "-", /missing/] |
| 406 | + ]) { |
| 407 | + const s = setup("NostrReact", attributes, {}, {submitter}); |
| 408 | + await assert.rejects(s.component.submit(s.form), message); |
| 409 | + assert.equal(s.signed.length, 0); |
| 410 | + assert.equal(s.sent.length, 0); |
| 411 | + } |
| 412 | +}); |