diff --git a/packages/domain/__tests__/BlockedRelayList.test.ts b/packages/domain/__tests__/BlockedRelayList.test.ts new file mode 100644 index 0000000..beddea6 --- /dev/null +++ b/packages/domain/__tests__/BlockedRelayList.test.ts @@ -0,0 +1,110 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, BLOCKED_RELAYS, NOTE, getTagValues, normalizeRelayUrl} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {BlockedRelayList, BlockedRelayListBuilder} from "../src/kinds/BlockedRelayList" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) + +const r1 = "wss://relay.one.example/" +const r2 = "wss://relay.two.example/" +const r3 = "wss://relay.three.example/" + +const makeEvent = (o: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: BLOCKED_RELAYS, + tags: [], + content: "", + sig: "00".repeat(64), + ...o, + }) as TrustedEvent + +describe("BlockedRelayList", () => { + it("reads relay urls from relay tags", async () => { + const event = makeEvent({ + tags: [ + ["relay", r1], + ["relay", r2], + ["alt", "x"], + ], + }) + + const list = await BlockedRelayList.fromEvent(event) + + expect(list.urls().sort()).toEqual([r1, r2].sort()) + expect(list.includes(r1)).toBe(true) + expect(list.includes(r3)).toBe(false) + }) + + it("round-trips without duplicating tags and preserves passthrough", async () => { + const event = makeEvent({ + tags: [ + ["relay", r1], + ["relay", r2], + ["alt", "x"], + ], + }) + + const list = await BlockedRelayList.fromEvent(event) + const tmpl = await list.builder().toTemplate(signer) + + expect(tmpl.kind).toBe(BLOCKED_RELAYS) + expect(tmpl.tags.filter(t => t[0] === "relay").length).toBe(2) + expect(tmpl.tags).toContainEqual(["alt", "x"]) + }) + + it("builds from a fresh builder and normalizes urls", async () => { + const tmpl = await new BlockedRelayListBuilder() + .addRelay("wss://relay.one.example") + .toTemplate(signer) + + expect(getTagValues("relay", tmpl.tags)).toEqual([normalizeRelayUrl("wss://relay.one.example")]) + }) + + it("setRelays replaces existing relays", async () => { + const event = makeEvent({tags: [["relay", r1]]}) + const list = await BlockedRelayList.fromEvent(event) + + const tmpl = await list.builder().setRelays([r2, r3]).toTemplate(signer) + + expect(getTagValues("relay", tmpl.tags).sort()).toEqual([r2, r3].sort()) + }) + + it("round-trips public and private entries through encryption", async () => { + const event = await new BlockedRelayListBuilder() + .addRelay(r1) + .addPrivate(["relay", r2]) + .toEvent(signer) + + expect(event.kind).toBe(BLOCKED_RELAYS) + expect(getTagValues("relay", event.tags)).toEqual([r1]) + expect(event.content).not.toBe("") + + const decrypted = await BlockedRelayList.fromEvent(event, signer) + + expect(decrypted.decrypted).toBe(true) + expect(decrypted.urls().sort()).toEqual([r1, r2].sort()) + + const publicOnly = await BlockedRelayList.fromEvent(event) + + expect(publicOnly.decrypted).toBe(false) + expect(publicOnly.urls()).toEqual([r1]) + }) + + it("preserves undecrypted ciphertext on pass-through", async () => { + const event = await new BlockedRelayListBuilder().addPrivate(["relay", r2]).toEvent(signer) + const undecrypted = await BlockedRelayList.fromEvent(event) + + const tmpl = await undecrypted.builder().toTemplate(signer) + + expect(tmpl.content).toBe(event.content) + }) + + it("throws on the wrong kind", async () => { + await expect(BlockedRelayList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/BlossomServerList.test.ts b/packages/domain/__tests__/BlossomServerList.test.ts new file mode 100644 index 0000000..3494dbc --- /dev/null +++ b/packages/domain/__tests__/BlossomServerList.test.ts @@ -0,0 +1,100 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, BLOSSOM_SERVERS, NOTE, getTagValues, normalizeRelayUrl} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {BlossomServerList, BlossomServerListBuilder} from "../src/kinds/BlossomServerList" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) + +const s1 = "https://blossom.one.example/" +const s2 = "https://blossom.two.example/" +const s3 = "https://blossom.three.example/" + +const norm = (url: string) => normalizeRelayUrl(url) + +const makeEvent = (o: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: BLOSSOM_SERVERS, + tags: [], + content: "", + sig: "00".repeat(64), + ...o, + }) as TrustedEvent + +describe("BlossomServerList", () => { + it("reads server urls from server tags", async () => { + const event = makeEvent({ + tags: [ + ["server", s1], + ["server", s2], + ["alt", "x"], + ], + }) + + const list = await BlossomServerList.fromEvent(event) + + expect(list.servers().sort()).toEqual([norm(s1), norm(s2)].sort()) + expect(list.includes(s1)).toBe(true) + expect(list.includes(s3)).toBe(false) + }) + + it("round-trips without duplicating tags and preserves passthrough", async () => { + const event = makeEvent({ + tags: [ + ["server", s1], + ["server", s2], + ["alt", "x"], + ], + }) + + const list = await BlossomServerList.fromEvent(event) + const tmpl = await list.builder().toTemplate(signer) + + expect(tmpl.kind).toBe(BLOSSOM_SERVERS) + expect(tmpl.tags.filter(t => t[0] === "server").length).toBe(2) + expect(tmpl.tags).toContainEqual(["alt", "x"]) + }) + + it("builds from a fresh builder and normalizes urls", async () => { + const tmpl = await new BlossomServerListBuilder().addServer(s1).toTemplate(signer) + + expect(getTagValues("server", tmpl.tags)).toEqual([norm(s1)]) + }) + + it("setServers replaces existing servers", async () => { + const event = makeEvent({tags: [["server", s1]]}) + const list = await BlossomServerList.fromEvent(event) + + const tmpl = await list.builder().setServers([s2, s3]).toTemplate(signer) + + expect(getTagValues("server", tmpl.tags).sort()).toEqual([norm(s2), norm(s3)].sort()) + }) + + it("round-trips public and private entries through encryption", async () => { + const event = await new BlossomServerListBuilder() + .addServer(s1) + .addPrivate(["server", norm(s2)]) + .toEvent(signer) + + expect(getTagValues("server", event.tags)).toEqual([norm(s1)]) + expect(event.content).not.toBe("") + + const decrypted = await BlossomServerList.fromEvent(event, signer) + + expect(decrypted.decrypted).toBe(true) + expect(decrypted.servers().sort()).toEqual([norm(s1), norm(s2)].sort()) + + const publicOnly = await BlossomServerList.fromEvent(event) + + expect(publicOnly.decrypted).toBe(false) + expect(publicOnly.servers()).toEqual([norm(s1)]) + }) + + it("throws on the wrong kind", async () => { + await expect(BlossomServerList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/BookmarkList.test.ts b/packages/domain/__tests__/BookmarkList.test.ts new file mode 100644 index 0000000..1014bfb --- /dev/null +++ b/packages/domain/__tests__/BookmarkList.test.ts @@ -0,0 +1,135 @@ +import {describe, it, expect} from "vitest" +import { + makeSecret, + BOOKMARKS, + NOTE, + getEventTagValues, + getTopicTagValues, +} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {BookmarkList, BookmarkListBuilder} from "../src/kinds/BookmarkList" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) + +const noteId = "11".repeat(32) +const noteId2 = "22".repeat(32) +const address = `30023:${"aa".repeat(32)}:article-1` +const url = "https://example.com/post" + +const makeEvent = (o: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: BOOKMARKS, + tags: [], + content: "", + sig: "00".repeat(64), + ...o, + }) as TrustedEvent + +describe("BookmarkList", () => { + it("reads mixed bookmark entries", async () => { + const event = makeEvent({ + tags: [ + ["e", noteId], + ["a", address], + ["t", "nostr"], + ["r", url], + ["alt", "x"], + ], + }) + + const list = await BookmarkList.fromEvent(event) + + expect(list.ids()).toEqual([noteId]) + expect(list.addresses()).toEqual([address]) + expect(list.topics()).toEqual(["nostr"]) + expect(list.urls()).toEqual([url]) + }) + + it("round-trips without duplicating tags and preserves passthrough", async () => { + const event = makeEvent({ + tags: [ + ["e", noteId], + ["a", address], + ["t", "nostr"], + ["r", url], + ["alt", "x"], + ], + }) + + const list = await BookmarkList.fromEvent(event) + const tmpl = await list.builder().toTemplate(signer) + + expect(tmpl.kind).toBe(BOOKMARKS) + expect(tmpl.tags.filter(t => t[0] === "e").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "a").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "t").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "r").length).toBe(1) + expect(tmpl.tags).toContainEqual(["alt", "x"]) + }) + + it("builds from a fresh builder", async () => { + const tmpl = await new BookmarkListBuilder() + .bookmarkPublicly(["e", noteId]) + .bookmarkPublicly(["t", "nostr"]) + .toTemplate(signer) + + expect(getEventTagValues(tmpl.tags)).toEqual([noteId]) + expect(getTopicTagValues(tmpl.tags)).toEqual(["nostr"]) + }) + + it("removeBookmark removes by value", async () => { + const event = makeEvent({tags: [["e", noteId], ["e", noteId2]]}) + const list = await BookmarkList.fromEvent(event) + + const tmpl = await list.builder().removeBookmark(noteId).toTemplate(signer) + + expect(getEventTagValues(tmpl.tags)).toEqual([noteId2]) + }) + + it("round-trips public and private bookmarks through encryption", async () => { + const event = await new BookmarkListBuilder() + .bookmarkPublicly(["e", noteId]) + .bookmarkPrivately(["e", noteId2]) + .toEvent(signer) + + expect(getEventTagValues(event.tags)).toEqual([noteId]) + expect(event.content).not.toBe("") + + const decrypted = await BookmarkList.fromEvent(event, signer) + + expect(decrypted.decrypted).toBe(true) + expect(decrypted.ids().sort()).toEqual([noteId, noteId2].sort()) + + const publicOnly = await BookmarkList.fromEvent(event) + + expect(publicOnly.decrypted).toBe(false) + expect(publicOnly.ids()).toEqual([noteId]) + }) + + it("preserves undecrypted ciphertext on pass-through", async () => { + const event = await new BookmarkListBuilder().bookmarkPrivately(["e", noteId2]).toEvent(signer) + const undecrypted = await BookmarkList.fromEvent(event) + + const tmpl = await undecrypted.builder().toTemplate(signer) + + expect(tmpl.content).toBe(event.content) + }) + + it("refuses private mutation when undecrypted", async () => { + const event = await new BookmarkListBuilder().bookmarkPrivately(["e", noteId2]).toEvent(signer) + const undecrypted = await BookmarkList.fromEvent(event) + + await expect( + undecrypted.builder().bookmarkPrivately(["e", noteId]).toEvent(signer), + ).rejects.toThrow() + }) + + it("throws on the wrong kind", async () => { + await expect(BookmarkList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/Classified.test.ts b/packages/domain/__tests__/Classified.test.ts new file mode 100644 index 0000000..65b2d9e --- /dev/null +++ b/packages/domain/__tests__/Classified.test.ts @@ -0,0 +1,108 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, CLASSIFIED, NOTE} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {Classified, ClassifiedBuilder} from "../src/kinds/Classified" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) + +const makeEvent = (overrides: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: CLASSIFIED, + tags: [], + content: "", + sig: "00".repeat(64), + ...overrides, + }) as TrustedEvent + +describe("Classified", () => { + it("reads represented tags and content", async () => { + const event = makeEvent({ + content: "for sale", + tags: [ + ["d", "abc"], + ["title", "Bike"], + ["summary", "A good bike"], + ["price", "100", "USD"], + ["status", "active"], + ["image", "https://example.com/a.jpg"], + ["image", "https://example.com/b.jpg"], + ["t", "cycling"], + ["alt", "x"], + ], + }) + + const c = await Classified.fromEvent(event) + + expect(c.identifier()).toBe("abc") + expect(c.title()).toBe("Bike") + expect(c.summary()).toBe("A good bike") + expect(c.price()).toEqual({amount: 100, currency: "USD"}) + expect(c.status()).toBe("active") + expect(c.images()).toEqual(["https://example.com/a.jpg", "https://example.com/b.jpg"]) + expect(c.topics()).toEqual(["cycling"]) + expect(c.content()).toBe("for sale") + }) + + it("defaults the price currency to SAT", async () => { + const c = await Classified.fromEvent(makeEvent({tags: [["d", "x"], ["price", "50"]]})) + + expect(c.price()).toEqual({amount: 50, currency: "SAT"}) + }) + + it("round-trips with no duplicate represented tags", async () => { + const event = makeEvent({ + content: "for sale", + tags: [ + ["d", "abc"], + ["title", "Bike"], + ["summary", "A good bike"], + ["price", "100", "USD"], + ["status", "active"], + ["image", "https://example.com/a.jpg"], + ["image", "https://example.com/b.jpg"], + ["t", "cycling"], + ["alt", "x"], + ], + }) + + const tmpl = await (await Classified.fromEvent(event)).builder().toTemplate(signer) + + for (const key of ["d", "title", "summary", "price", "status"]) { + expect(tmpl.tags.filter(t => t[0] === key).length).toBe(1) + } + expect(tmpl.tags.filter(t => t[0] === "image").length).toBe(2) + expect(tmpl.tags.filter(t => t[0] === "t").length).toBe(1) + expect(tmpl.tags).toContainEqual(["d", "abc"]) + expect(tmpl.tags).toContainEqual(["price", "100", "USD"]) + // Unknown passthrough tag survives. + expect(tmpl.tags).toContainEqual(["alt", "x"]) + expect(tmpl.content).toBe("for sale") + }) + + it("builds from a fresh builder with an auto-generated d", async () => { + const tmpl = await new ClassifiedBuilder() + .setTitle("Fresh") + .setContent("desc") + .setPrice(25) + .setImages(["https://example.com/c.jpg"]) + .setTopics(["misc"]) + .toTemplate(signer) + + expect(tmpl.kind).toBe(CLASSIFIED) + expect(tmpl.tags.find(t => t[0] === "d")?.[1]).toBeTruthy() + expect(tmpl.tags).toContainEqual(["title", "Fresh"]) + expect(tmpl.tags).toContainEqual(["price", "25", "SAT"]) + expect(tmpl.tags).toContainEqual(["image", "https://example.com/c.jpg"]) + expect(tmpl.tags).toContainEqual(["t", "misc"]) + expect(tmpl.content).toBe("desc") + }) + + it("throws on the wrong kind", async () => { + await expect(Classified.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/Comment.test.ts b/packages/domain/__tests__/Comment.test.ts new file mode 100644 index 0000000..a6ac041 --- /dev/null +++ b/packages/domain/__tests__/Comment.test.ts @@ -0,0 +1,109 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, COMMENT, NOTE} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {Comment, CommentBuilder} from "../src/kinds/Comment" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) +const rootId = "aa".repeat(32) +const rootPubkey = "bb".repeat(32) +const parentId = "cc".repeat(32) +const parentPubkey = "dd".repeat(32) + +const makeEvent = (overrides: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: COMMENT, + tags: [], + content: "", + sig: "00".repeat(64), + ...overrides, + }) as TrustedEvent + +describe("Comment", () => { + it("reads root and parent references", async () => { + const event = makeEvent({ + content: "nice thread", + tags: [ + ["E", rootId], + ["K", "11"], + ["P", rootPubkey], + ["e", parentId], + ["k", "1111"], + ["p", parentPubkey], + ["alt", "x"], + ], + }) + + const comment = await Comment.fromEvent(event) + + expect(comment.content()).toBe("nice thread") + expect(comment.rootId()).toBe(rootId) + expect(comment.rootKind()).toBe("11") + expect(comment.rootPubkey()).toBe(rootPubkey) + expect(comment.parentId()).toBe(parentId) + expect(comment.parentKind()).toBe("1111") + expect(comment.parentPubkey()).toBe(parentPubkey) + expect(comment.root()).toEqual({id: rootId, address: undefined, kind: "11", pubkey: rootPubkey}) + expect(comment.parent()).toEqual({ + id: parentId, + address: undefined, + kind: "1111", + pubkey: parentPubkey, + }) + }) + + it("round-trips with no duplicate reference tags", async () => { + const event = makeEvent({ + content: "nice thread", + tags: [ + ["E", rootId], + ["K", "11"], + ["P", rootPubkey], + ["e", parentId], + ["k", "1111"], + ["p", parentPubkey], + ["alt", "x"], + ], + }) + + const tmpl = await (await Comment.fromEvent(event)).builder().toTemplate(signer) + + // Each represented reference key emits exactly once. + for (const key of ["E", "K", "P", "e", "k", "p"]) { + expect(tmpl.tags.filter(t => t[0] === key).length).toBe(1) + } + expect(tmpl.tags).toContainEqual(["E", rootId]) + expect(tmpl.tags).toContainEqual(["e", parentId]) + // Unknown passthrough tag survives. + expect(tmpl.tags).toContainEqual(["alt", "x"]) + expect(tmpl.content).toBe("nice thread") + }) + + it("builds references from full events", async () => { + const root = makeEvent({id: rootId, pubkey: rootPubkey, kind: 11}) + const parent = makeEvent({id: parentId, pubkey: parentPubkey, kind: 1111}) + + const tmpl = await new CommentBuilder() + .setContent("reply") + .setRootFromEvent(root) + .setParentFromEvent(parent) + .toTemplate(signer) + + expect(tmpl.kind).toBe(COMMENT) + expect(tmpl.tags).toContainEqual(["E", rootId]) + expect(tmpl.tags).toContainEqual(["K", "11"]) + expect(tmpl.tags).toContainEqual(["P", rootPubkey]) + expect(tmpl.tags).toContainEqual(["e", parentId]) + expect(tmpl.tags).toContainEqual(["k", "1111"]) + expect(tmpl.tags).toContainEqual(["p", parentPubkey]) + expect(tmpl.content).toBe("reply") + }) + + it("throws on the wrong kind", async () => { + await expect(Comment.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/EmojiList.test.ts b/packages/domain/__tests__/EmojiList.test.ts new file mode 100644 index 0000000..26aefcb --- /dev/null +++ b/packages/domain/__tests__/EmojiList.test.ts @@ -0,0 +1,104 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, EMOJIS, NOTE, getAddressTagValues} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {EmojiList, EmojiListBuilder} from "../src/kinds/EmojiList" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) + +const setAddress = `30030:${"aa".repeat(32)}:my-emojis` +const setAddress2 = `30030:${"bb".repeat(32)}:more-emojis` +const emojiTag = ["emoji", "soapbox", "https://example.com/soapbox.png"] +const emojiTag2 = ["emoji", "ostrich", "https://example.com/ostrich.png"] + +const makeEvent = (o: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: EMOJIS, + tags: [], + content: "", + sig: "00".repeat(64), + ...o, + }) as TrustedEvent + +describe("EmojiList", () => { + it("reads emoji-set addresses and inline emoji tags", async () => { + const event = makeEvent({ + tags: [["a", setAddress], emojiTag, ["alt", "x"]], + }) + + const list = await EmojiList.fromEvent(event) + + expect(list.addresses()).toEqual([setAddress]) + expect(list.emojis()).toEqual([emojiTag]) + }) + + it("round-trips without duplicating tags and preserves passthrough", async () => { + const event = makeEvent({ + tags: [["a", setAddress], emojiTag, ["alt", "x"]], + }) + + const list = await EmojiList.fromEvent(event) + const tmpl = await list.builder().toTemplate(signer) + + expect(tmpl.kind).toBe(EMOJIS) + expect(tmpl.tags.filter(t => t[0] === "a").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "emoji").length).toBe(1) + expect(tmpl.tags).toContainEqual(["alt", "x"]) + }) + + it("builds from a fresh builder", async () => { + const tmpl = await new EmojiListBuilder() + .addEmojiSet(setAddress) + .addEmoji("soapbox", "https://example.com/soapbox.png") + .toTemplate(signer) + + expect(getAddressTagValues(tmpl.tags)).toEqual([setAddress]) + expect(tmpl.tags).toContainEqual(emojiTag) + }) + + it("removeEmoji removes by value", async () => { + const event = makeEvent({tags: [emojiTag, emojiTag2]}) + const list = await EmojiList.fromEvent(event) + + const tmpl = await list.builder().removeEmoji("soapbox").toTemplate(signer) + + expect(tmpl.tags.filter(t => t[0] === "emoji")).toEqual([emojiTag2]) + }) + + it("round-trips public and private entries through encryption", async () => { + const event = await new EmojiListBuilder() + .addEmojiSet(setAddress) + .addPrivate(["a", setAddress2]) + .toEvent(signer) + + expect(getAddressTagValues(event.tags)).toEqual([setAddress]) + expect(event.content).not.toBe("") + + const decrypted = await EmojiList.fromEvent(event, signer) + + expect(decrypted.decrypted).toBe(true) + expect(decrypted.addresses().sort()).toEqual([setAddress, setAddress2].sort()) + + const publicOnly = await EmojiList.fromEvent(event) + + expect(publicOnly.decrypted).toBe(false) + expect(publicOnly.addresses()).toEqual([setAddress]) + }) + + it("preserves undecrypted ciphertext on pass-through", async () => { + const event = await new EmojiListBuilder().addPrivate(["a", setAddress2]).toEvent(signer) + const undecrypted = await EmojiList.fromEvent(event) + + const tmpl = await undecrypted.builder().toTemplate(signer) + + expect(tmpl.content).toBe(event.content) + }) + + it("throws on the wrong kind", async () => { + await expect(EmojiList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/Feed.test.ts b/packages/domain/__tests__/Feed.test.ts new file mode 100644 index 0000000..9fd519d --- /dev/null +++ b/packages/domain/__tests__/Feed.test.ts @@ -0,0 +1,90 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, FEED, NOTE} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {Feed, FeedBuilder} from "../src/kinds/Feed" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) + +const definition = ["union", ["search", "nostr"]] + +const makeEvent = (overrides: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: FEED, + tags: [], + content: "", + sig: "00".repeat(64), + ...overrides, + }) as TrustedEvent + +describe("Feed", () => { + it("reads represented tags", async () => { + const event = makeEvent({ + tags: [ + ["d", "abc"], + ["title", "My Feed"], + ["description", "all the things"], + ["feed", JSON.stringify(definition)], + ["alt", "My Feed"], + ], + }) + + const feed = await Feed.fromEvent(event) + + expect(feed.identifier()).toBe("abc") + expect(feed.title()).toBe("My Feed") + expect(feed.description()).toBe("all the things") + expect(feed.definition()).toEqual(definition) + }) + + it("round-trips with no duplicate represented tags", async () => { + const event = makeEvent({ + tags: [ + ["d", "abc"], + ["title", "My Feed"], + ["description", "all the things"], + ["feed", JSON.stringify(definition)], + ["alt", "My Feed"], + // "alt" is a represented/derived tag (mirrors title), so use a distinct + // unknown key for the passthrough assertion. + ["zzz", "x"], + ], + }) + + const tmpl = await (await Feed.fromEvent(event)).builder().toTemplate(signer) + + for (const key of ["d", "title", "description", "feed", "alt"]) { + expect(tmpl.tags.filter(t => t[0] === key).length).toBe(1) + } + expect(tmpl.tags).toContainEqual(["d", "abc"]) + expect(tmpl.tags).toContainEqual(["title", "My Feed"]) + expect(tmpl.tags).toContainEqual(["feed", JSON.stringify(definition)]) + // The derived "alt" tag mirrors the title. + expect(tmpl.tags).toContainEqual(["alt", "My Feed"]) + // Unknown passthrough tag survives. + expect(tmpl.tags).toContainEqual(["zzz", "x"]) + }) + + it("builds from a fresh builder with an auto-generated d", async () => { + const tmpl = await new FeedBuilder() + .setTitle("Fresh") + .setDescription("desc") + .setDefinition(definition) + .toTemplate(signer) + + expect(tmpl.kind).toBe(FEED) + expect(tmpl.tags.find(t => t[0] === "d")?.[1]).toBeTruthy() + expect(tmpl.tags).toContainEqual(["title", "Fresh"]) + expect(tmpl.tags).toContainEqual(["alt", "Fresh"]) + expect(tmpl.tags).toContainEqual(["description", "desc"]) + expect(tmpl.tags).toContainEqual(["feed", JSON.stringify(definition)]) + }) + + it("throws on the wrong kind", async () => { + await expect(Feed.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/FeedList.test.ts b/packages/domain/__tests__/FeedList.test.ts new file mode 100644 index 0000000..f5558d8 --- /dev/null +++ b/packages/domain/__tests__/FeedList.test.ts @@ -0,0 +1,79 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, FEEDS, NOTE} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {FeedList, FeedListBuilder} from "../src/kinds/FeedList" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) + +const addressA = `31890:${"22".repeat(32)}:feeda` +const addressB = `31890:${"33".repeat(32)}:feedb` + +const makeEvent = (o: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: FEEDS, + tags: [], + content: "", + sig: "00".repeat(64), + ...o, + }) as TrustedEvent + +describe("FeedList", () => { + it("reads saved feed addresses", async () => { + const reader = await FeedList.fromEvent( + makeEvent({tags: [["a", addressA], ["alt", "x"]]}), + ) + + expect(reader.addresses()).toEqual([addressA]) + expect(reader.includes(addressA)).toBe(true) + expect(reader.includes(addressB)).toBe(false) + }) + + it("round-trips without duplicating represented tags", async () => { + const reader = await FeedList.fromEvent( + makeEvent({tags: [["a", addressA], ["alt", "x"]]}), + ) + + const tmpl = await reader.builder().toTemplate(signer) + + expect(tmpl.tags.filter(t => t[0] === "a").length).toBe(1) + expect(tmpl.tags).toContainEqual(["alt", "x"]) + }) + + it("adds and removes feeds via a fresh builder", async () => { + const tmpl = await new FeedListBuilder() + .addFeed(addressA, "wss://relay.example.com/") + .addFeed(addressB) + .removeFeed(addressA) + .toTemplate(signer) + + expect(tmpl.kind).toBe(FEEDS) + expect(tmpl.tags).toContainEqual(["a", addressB, ""]) + expect(tmpl.tags.some(t => t[1] === addressA)).toBe(false) + }) + + it("round-trips public and private feeds through encryption", async () => { + const event = await new FeedListBuilder() + .addFeed(addressA) + .addFeedPrivately(addressB) + .toEvent(signer) + + const decrypted = await FeedList.fromEvent(event, signer) + + expect(decrypted.decrypted).toBe(true) + expect(decrypted.addresses().sort()).toEqual([addressA, addressB].sort()) + + const publicOnly = await FeedList.fromEvent(event) + + expect(publicOnly.decrypted).toBe(false) + expect(publicOnly.addresses()).toEqual([addressA]) + }) + + it("throws on the wrong kind", async () => { + await expect(FeedList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/FollowList.test.ts b/packages/domain/__tests__/FollowList.test.ts new file mode 100644 index 0000000..c317bc8 --- /dev/null +++ b/packages/domain/__tests__/FollowList.test.ts @@ -0,0 +1,103 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, FOLLOWS, NOTE, getPubkeyTagValues} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {FollowList, FollowListBuilder} from "../src/kinds/FollowList" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) + +const a = "aa".repeat(32) +const b = "bb".repeat(32) +const c = "cc".repeat(32) + +const makeEvent = (o: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: FOLLOWS, + tags: [], + content: "", + sig: "00".repeat(64), + ...o, + }) as TrustedEvent + +describe("FollowList", () => { + it("reads followed pubkeys", async () => { + const event = makeEvent({ + tags: [ + ["p", a], + ["p", b], + ["t", "nostr"], + ["alt", "x"], + ], + }) + + const list = await FollowList.fromEvent(event) + + expect(list.pubkeys().sort()).toEqual([a, b].sort()) + expect(list.includes(a)).toBe(true) + expect(list.includes(c)).toBe(false) + }) + + it("round-trips without duplicating tags and preserves passthrough", async () => { + const event = makeEvent({ + tags: [ + ["p", a], + ["p", b], + ["alt", "x"], + ], + }) + + const list = await FollowList.fromEvent(event) + const tmpl = await list.builder().toTemplate(signer) + + expect(tmpl.kind).toBe(FOLLOWS) + expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(2) + expect(tmpl.tags).toContainEqual(["alt", "x"]) + }) + + it("builds from a fresh builder via addFollow", async () => { + const tmpl = await new FollowListBuilder() + .addFollow(["p", a]) + .addFollow(["t", "nostr"]) + .toTemplate(signer) + + expect(getPubkeyTagValues(tmpl.tags)).toEqual([a]) + expect(tmpl.tags).toContainEqual(["t", "nostr"]) + }) + + it("removeFollow removes by value", async () => { + const event = makeEvent({tags: [["p", a], ["p", b]]}) + const list = await FollowList.fromEvent(event) + + const tmpl = await list.builder().removeFollow(a).toTemplate(signer) + + expect(getPubkeyTagValues(tmpl.tags)).toEqual([b]) + }) + + it("round-trips public and private follows through encryption", async () => { + const event = await new FollowListBuilder() + .addFollow(["p", a]) + .addPrivate(["p", b]) + .toEvent(signer) + + expect(getPubkeyTagValues(event.tags)).toEqual([a]) + expect(event.content).not.toBe("") + + const decrypted = await FollowList.fromEvent(event, signer) + + expect(decrypted.decrypted).toBe(true) + expect(decrypted.pubkeys().sort()).toEqual([a, b].sort()) + + const publicOnly = await FollowList.fromEvent(event) + + expect(publicOnly.decrypted).toBe(false) + expect(publicOnly.pubkeys()).toEqual([a]) + }) + + it("throws on the wrong kind", async () => { + await expect(FollowList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/GroupList.test.ts b/packages/domain/__tests__/GroupList.test.ts new file mode 100644 index 0000000..32df99c --- /dev/null +++ b/packages/domain/__tests__/GroupList.test.ts @@ -0,0 +1,110 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, COMMUNITIES, NOTE, getAddressTagValues} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {GroupList, GroupListBuilder} from "../src/kinds/GroupList" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) + +const g1 = `34550:${"aa".repeat(32)}:dev` +const g2 = `34550:${"bb".repeat(32)}:art` +const g3 = `34550:${"cc".repeat(32)}:music` + +const makeEvent = (o: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: COMMUNITIES, + tags: [], + content: "", + sig: "00".repeat(64), + ...o, + }) as TrustedEvent + +describe("GroupList", () => { + it("reads community addresses", async () => { + const event = makeEvent({ + tags: [ + ["a", g1, "wss://relay.example/"], + ["a", g2], + ["alt", "x"], + ], + }) + + const list = await GroupList.fromEvent(event) + + expect(list.addresses().sort()).toEqual([g1, g2].sort()) + }) + + it("round-trips without duplicating tags and preserves passthrough", async () => { + const event = makeEvent({ + tags: [ + ["a", g1, "wss://relay.example/"], + ["a", g2], + ["alt", "x"], + ], + }) + + const list = await GroupList.fromEvent(event) + const tmpl = await list.builder().toTemplate(signer) + + expect(tmpl.kind).toBe(COMMUNITIES) + expect(tmpl.tags.filter(t => t[0] === "a").length).toBe(2) + expect(tmpl.tags).toContainEqual(["alt", "x"]) + }) + + it("builds from a fresh builder with relay hint", async () => { + const tmpl = await new GroupListBuilder() + .addGroup(g1, "wss://relay.example/") + .addGroup(g2) + .toTemplate(signer) + + expect(getAddressTagValues(tmpl.tags).sort()).toEqual([g1, g2].sort()) + expect(tmpl.tags).toContainEqual(["a", g1, "wss://relay.example/"]) + expect(tmpl.tags).toContainEqual(["a", g2, ""]) + }) + + it("removeGroup removes by address", async () => { + const event = makeEvent({tags: [["a", g1], ["a", g2]]}) + const list = await GroupList.fromEvent(event) + + const tmpl = await list.builder().removeGroup(g1).toTemplate(signer) + + expect(getAddressTagValues(tmpl.tags)).toEqual([g2]) + }) + + it("round-trips public and private entries through encryption", async () => { + const event = await new GroupListBuilder() + .addGroup(g1) + .addPrivate(["a", g2, ""]) + .toEvent(signer) + + expect(getAddressTagValues(event.tags)).toEqual([g1]) + expect(event.content).not.toBe("") + + const decrypted = await GroupList.fromEvent(event, signer) + + expect(decrypted.decrypted).toBe(true) + expect(decrypted.addresses().sort()).toEqual([g1, g2].sort()) + + const publicOnly = await GroupList.fromEvent(event) + + expect(publicOnly.decrypted).toBe(false) + expect(publicOnly.addresses()).toEqual([g1]) + }) + + it("preserves undecrypted ciphertext on pass-through", async () => { + const event = await new GroupListBuilder().addPrivate(["a", g2, ""]).toEvent(signer) + const undecrypted = await GroupList.fromEvent(event) + + const tmpl = await undecrypted.builder().toTemplate(signer) + + expect(tmpl.content).toBe(event.content) + }) + + it("throws on the wrong kind", async () => { + await expect(GroupList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/Handler.test.ts b/packages/domain/__tests__/Handler.test.ts new file mode 100644 index 0000000..8d6b63b --- /dev/null +++ b/packages/domain/__tests__/Handler.test.ts @@ -0,0 +1,108 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, HANDLER_INFORMATION, NOTE} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {Handler, HandlerBuilder} from "../src/kinds/Handler" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) + +const makeEvent = (overrides: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: HANDLER_INFORMATION, + tags: [], + content: "", + sig: "00".repeat(64), + ...overrides, + }) as TrustedEvent + +describe("Handler", () => { + it("parses JSON metadata content and k tags", async () => { + const event = makeEvent({ + content: JSON.stringify({ + name: "Coracle", + about: "a client", + image: "https://example.com/i.png", + website: "https://example.com", + lud16: "a@example.com", + nip05: "a@example.com", + }), + tags: [ + ["d", "myhandler"], + ["k", "1"], + ["k", "30023"], + ["alt", "x"], + ], + }) + + const handler = await Handler.fromEvent(event) + + expect(handler.values.name).toBe("Coracle") + expect(handler.name()).toBe("Coracle") + expect(handler.about()).toBe("a client") + expect(handler.image()).toBe("https://example.com/i.png") + expect(handler.website()).toBe("https://example.com") + expect(handler.lud16()).toBe("a@example.com") + expect(handler.nip05()).toBe("a@example.com") + expect(handler.kinds()).toEqual([1, 30023]) + }) + + it("maps display_name and picture aliases to canonical accessors", async () => { + const handler = await Handler.fromEvent( + makeEvent({ + content: JSON.stringify({display_name: "Alias", picture: "https://example.com/p.png"}), + }), + ) + + expect(handler.name()).toBe("Alias") + expect(handler.image()).toBe("https://example.com/p.png") + }) + + it("round-trips with no duplication", async () => { + const event = makeEvent({ + content: JSON.stringify({name: "Coracle", about: "a client"}), + tags: [ + ["d", "myhandler"], + ["k", "1"], + ["k", "30023"], + ["alt", "x"], + ], + }) + + const tmpl = await (await Handler.fromEvent(event)).builder().toTemplate(signer) + + expect(tmpl.tags.filter(t => t[0] === "k").length).toBe(2) + // The d identifier is passed through untouched. + expect(tmpl.tags.filter(t => t[0] === "d").length).toBe(1) + expect(tmpl.tags).toContainEqual(["alt", "x"]) + + // Content re-serializes the parsed metadata. + const parsed = JSON.parse(tmpl.content) + expect(parsed.name).toBe("Coracle") + expect(parsed.about).toBe("a client") + }) + + it("builds from a fresh builder", async () => { + const tmpl = await new HandlerBuilder() + .setName("MyApp") + .setAbout("does things") + .setWebsite("https://my.app") + .setKinds([1, 7]) + .toTemplate(signer) + + expect(tmpl.kind).toBe(HANDLER_INFORMATION) + const parsed = JSON.parse(tmpl.content) + expect(parsed.name).toBe("MyApp") + expect(parsed.about).toBe("does things") + expect(parsed.website).toBe("https://my.app") + expect(tmpl.tags).toContainEqual(["k", "1"]) + expect(tmpl.tags).toContainEqual(["k", "7"]) + }) + + it("throws on the wrong kind", async () => { + await expect(Handler.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/HandlerRecommendation.test.ts b/packages/domain/__tests__/HandlerRecommendation.test.ts new file mode 100644 index 0000000..305bbea --- /dev/null +++ b/packages/domain/__tests__/HandlerRecommendation.test.ts @@ -0,0 +1,102 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, HANDLER_RECOMMENDATION, NOTE} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import { + HandlerRecommendation, + HandlerRecommendationBuilder, +} from "../src/kinds/HandlerRecommendation" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) + +const webAddress = `31990:${"aa".repeat(32)}:web-handler` +const otherAddress = `31990:${"bb".repeat(32)}:other-handler` + +const makeEvent = (overrides: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: HANDLER_RECOMMENDATION, + tags: [], + content: "", + sig: "00".repeat(64), + ...overrides, + }) as TrustedEvent + +describe("HandlerRecommendation", () => { + it("parses address tags and prefers the web handler", async () => { + const event = makeEvent({ + tags: [ + ["d", "1"], + ["a", otherAddress, "wss://relay.one", "android"], + ["a", webAddress, "wss://relay.two", "web"], + ["alt", "x"], + ], + }) + + const rec = await HandlerRecommendation.fromEvent(event) + + expect(rec.addresses()).toEqual([otherAddress, webAddress]) + expect(rec.addressTags().length).toBe(2) + // Prefers the recommendation marked "web". + expect(rec.handlerAddress()).toBe(webAddress) + }) + + it("falls back to the first recommendation without a web marker", async () => { + const rec = await HandlerRecommendation.fromEvent( + makeEvent({tags: [["d", "1"], ["a", otherAddress, "", "android"]]}), + ) + + expect(rec.handlerAddress()).toBe(otherAddress) + }) + + it("round-trips with no duplication", async () => { + const event = makeEvent({ + tags: [ + ["d", "1"], + ["a", otherAddress, "wss://relay.one", "android"], + ["a", webAddress, "wss://relay.two", "web"], + ["alt", "x"], + ], + }) + + const tmpl = await (await HandlerRecommendation.fromEvent(event)).builder().toTemplate(signer) + + expect(tmpl.tags.filter(t => t[0] === "d").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "a").length).toBe(2) + expect(tmpl.tags).toContainEqual(["alt", "x"]) + // The d identifier round-trips its value. + expect(tmpl.tags.find(t => t[0] === "d")![1]).toBe("1") + }) + + it("builds from a fresh builder", async () => { + const builder = new HandlerRecommendationBuilder() + // The d identifier holds the recommended kind; it has no setter, so set it + // directly on the public field. + builder.identifier = "1" + + const tmpl = await builder + .addRecommendation(webAddress, "wss://relay.one", "web") + // Duplicate addresses are ignored. + .addRecommendation(webAddress, "wss://relay.one", "web") + .toTemplate(signer) + + expect(tmpl.kind).toBe(HANDLER_RECOMMENDATION) + expect(tmpl.tags).toContainEqual(["d", "1"]) + expect(tmpl.tags.filter(t => t[0] === "a")).toEqual([ + ["a", webAddress, "wss://relay.one", "web"], + ]) + }) + + it("requires a d identifier", async () => { + await expect( + new HandlerRecommendationBuilder().addRecommendation(webAddress).toTemplate(signer), + ).rejects.toThrow() + }) + + it("throws on the wrong kind", async () => { + await expect(HandlerRecommendation.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/MessagingRelayList.test.ts b/packages/domain/__tests__/MessagingRelayList.test.ts new file mode 100644 index 0000000..ca7f4fc --- /dev/null +++ b/packages/domain/__tests__/MessagingRelayList.test.ts @@ -0,0 +1,107 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, MESSAGING_RELAYS, NOTE, getTagValues, normalizeRelayUrl} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {MessagingRelayList, MessagingRelayListBuilder} from "../src/kinds/MessagingRelayList" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) + +const r1 = "wss://inbox.one.example/" +const r2 = "wss://inbox.two.example/" +const r3 = "wss://inbox.three.example/" + +const makeEvent = (o: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: MESSAGING_RELAYS, + tags: [], + content: "", + sig: "00".repeat(64), + ...o, + }) as TrustedEvent + +describe("MessagingRelayList", () => { + it("reads messaging relay urls", async () => { + const event = makeEvent({ + tags: [ + ["relay", r1], + ["relay", r2], + ["alt", "x"], + ], + }) + + const list = await MessagingRelayList.fromEvent(event) + + expect(list.urls().sort()).toEqual([r1, r2].sort()) + }) + + it("round-trips without duplicating tags and preserves passthrough", async () => { + const event = makeEvent({ + tags: [ + ["relay", r1], + ["relay", r2], + ["alt", "x"], + ], + }) + + const list = await MessagingRelayList.fromEvent(event) + const tmpl = await list.builder().toTemplate(signer) + + expect(tmpl.kind).toBe(MESSAGING_RELAYS) + expect(tmpl.tags.filter(t => t[0] === "relay").length).toBe(2) + expect(tmpl.tags).toContainEqual(["alt", "x"]) + }) + + it("builds from a fresh builder and normalizes urls", async () => { + const tmpl = await new MessagingRelayListBuilder() + .addRelay("wss://inbox.one.example") + .toTemplate(signer) + + expect(getTagValues("relay", tmpl.tags)).toEqual([normalizeRelayUrl("wss://inbox.one.example")]) + }) + + it("setRelays replaces existing relays", async () => { + const event = makeEvent({tags: [["relay", r1]]}) + const list = await MessagingRelayList.fromEvent(event) + + const tmpl = await list.builder().setRelays([r2, r3]).toTemplate(signer) + + expect(getTagValues("relay", tmpl.tags).sort()).toEqual([r2, r3].sort()) + }) + + it("round-trips public and private entries through encryption", async () => { + const event = await new MessagingRelayListBuilder() + .addRelay(r1) + .addPrivate(["relay", r2]) + .toEvent(signer) + + expect(getTagValues("relay", event.tags)).toEqual([r1]) + expect(event.content).not.toBe("") + + const decrypted = await MessagingRelayList.fromEvent(event, signer) + + expect(decrypted.decrypted).toBe(true) + expect(decrypted.urls().sort()).toEqual([r1, r2].sort()) + + const publicOnly = await MessagingRelayList.fromEvent(event) + + expect(publicOnly.decrypted).toBe(false) + expect(publicOnly.urls()).toEqual([r1]) + }) + + it("preserves undecrypted ciphertext on pass-through", async () => { + const event = await new MessagingRelayListBuilder().addPrivate(["relay", r2]).toEvent(signer) + const undecrypted = await MessagingRelayList.fromEvent(event) + + const tmpl = await undecrypted.builder().toTemplate(signer) + + expect(tmpl.content).toBe(event.content) + }) + + it("throws on the wrong kind", async () => { + await expect(MessagingRelayList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/MuteList.test.ts b/packages/domain/__tests__/MuteList.test.ts index 093c0de..a370d22 100644 --- a/packages/domain/__tests__/MuteList.test.ts +++ b/packages/domain/__tests__/MuteList.test.ts @@ -2,7 +2,7 @@ import {describe, it, expect} from "vitest" import {makeSecret, MUTES, FOLLOWS, getPubkeyTagValues} from "@welshman/util" import type {TrustedEvent} from "@welshman/util" import {Nip01Signer} from "@welshman/signer" -import {MuteList} from "../src/MuteList" +import {MuteList, MuteListBuilder} from "../src/kinds/MuteList" const signer = new Nip01Signer(makeSecret()) @@ -12,14 +12,7 @@ const c = "cc".repeat(32) describe("MuteList", () => { it("round-trips public and private mutes through encryption", async () => { - const list = MuteList.init().addPublicly(a).addPrivately(b) - - expect(list.pubkeys.sort()).toEqual([a, b].sort()) - expect(list.includes(a)).toBe(true) - expect(list.includes(b)).toBe(true) - expect(list.includes(c)).toBe(false) - - const event = await list.toEvent(signer) + const event = await new MuteListBuilder().mutePublicly(a).mutePrivately(b).toEvent(signer) expect(event.kind).toBe(MUTES) expect(event.sig).toBeTruthy() @@ -28,46 +21,55 @@ describe("MuteList", () => { expect(event.content).not.toBe("") // Re-parsing with a capable signer recovers the private entries. - const decrypted = await MuteList.parse(event, signer) + const decrypted = await MuteList.fromEvent(event, signer) - expect(decrypted.isDecrypted).toBe(true) - expect(decrypted.pubkeys.sort()).toEqual([a, b].sort()) + expect(decrypted.decrypted).toBe(true) + expect(decrypted.pubkeys().sort()).toEqual([a, b].sort()) + expect(decrypted.includes(a)).toBe(true) + expect(decrypted.includes(b)).toBe(true) + expect(decrypted.includes(c)).toBe(false) // Parsing without a signer exposes only the public entries. - const publicOnly = await MuteList.parse(event) + const publicOnly = await MuteList.fromEvent(event) - expect(publicOnly.isDecrypted).toBe(false) - expect(publicOnly.pubkeys).toEqual([a]) + expect(publicOnly.decrypted).toBe(false) + expect(publicOnly.pubkeys()).toEqual([a]) }) it("removes from both public and private entries", async () => { - const list = MuteList.init().addPublicly(a).addPrivately(b) + const event = await new MuteListBuilder() + .mutePublicly(a) + .mutePrivately(b) + .unmute(a) + .unmute(b) + .toEvent(signer) - list.remove(a) - list.remove(b) + const parsed = await MuteList.fromEvent(event, signer) - expect(list.pubkeys).toEqual([]) + expect(parsed.pubkeys()).toEqual([]) }) it("preserves undecrypted ciphertext on pass-through serialization", async () => { - const event = await MuteList.init().addPrivately(b).toEvent(signer) - const undecrypted = await MuteList.parse(event) + const event = await new MuteListBuilder().mutePrivately(b).toEvent(signer) + const undecrypted = await MuteList.fromEvent(event) // We never decrypted, so the original ciphertext must survive untouched. - const template = await undecrypted.toTemplate(signer) + const template = await undecrypted.builder().toTemplate(signer) expect(template.content).toBe(event.content) }) it("refuses private mutation when undecrypted", async () => { - const event = await MuteList.init().addPrivately(b).toEvent(signer) - const undecrypted = await MuteList.parse(event) + const event = await new MuteListBuilder().mutePrivately(b).toEvent(signer) + const undecrypted = await MuteList.fromEvent(event) - expect(() => undecrypted.addPrivately(c)).toThrow() + // Mutation is now deferred-validated: adding a private entry to a list we + // couldn't decrypt throws at emit time, not on the mutating call. + await expect(undecrypted.builder().mutePrivately(c).toEvent(signer)).rejects.toThrow() }) it("toRumor encrypts but does not sign", async () => { - const rumor = await MuteList.init().addPrivately(b).toRumor(signer) + const rumor = await new MuteListBuilder().mutePrivately(b).toRumor(signer) expect(rumor.id).toBeTruthy() expect((rumor as TrustedEvent).sig).toBeUndefined() @@ -77,6 +79,6 @@ describe("MuteList", () => { it("throws on the wrong kind", async () => { const event = {kind: FOLLOWS, tags: [], content: "", pubkey: a} as TrustedEvent - await expect(MuteList.parse(event)).rejects.toThrow() + await expect(MuteList.fromEvent(event)).rejects.toThrow() }) }) diff --git a/packages/domain/__tests__/PinList.test.ts b/packages/domain/__tests__/PinList.test.ts new file mode 100644 index 0000000..d7bfa2b --- /dev/null +++ b/packages/domain/__tests__/PinList.test.ts @@ -0,0 +1,76 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, PINS, NOTE} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {PinList, PinListBuilder} from "../src/kinds/PinList" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) + +const eventId = "11".repeat(32) +const address = `31890:${"22".repeat(32)}:feed` + +const makeEvent = (o: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: PINS, + tags: [], + content: "", + sig: "00".repeat(64), + ...o, + }) as TrustedEvent + +describe("PinList", () => { + it("reads pinned event ids and addresses", async () => { + const reader = await PinList.fromEvent( + makeEvent({tags: [["e", eventId], ["a", address], ["alt", "x"]]}), + ) + + expect(reader.ids()).toEqual([eventId]) + expect(reader.addresses()).toEqual([address]) + }) + + it("round-trips without duplicating represented tags", async () => { + const reader = await PinList.fromEvent( + makeEvent({tags: [["e", eventId], ["a", address], ["alt", "x"]]}), + ) + + const tmpl = await reader.builder().toTemplate(signer) + + expect(tmpl.tags.filter(t => t[0] === "e").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "a").length).toBe(1) + expect(tmpl.tags).toContainEqual(["alt", "x"]) + }) + + it("builds from a fresh builder", async () => { + const tmpl = await new PinListBuilder().pinPublicly(["e", eventId]).toTemplate(signer) + + expect(tmpl.kind).toBe(PINS) + expect(tmpl.tags).toContainEqual(["e", eventId]) + }) + + it("round-trips public and private pins through encryption", async () => { + const event = await new PinListBuilder() + .pinPublicly(["e", eventId]) + .pinPrivately(["a", address]) + .toEvent(signer) + + const decrypted = await PinList.fromEvent(event, signer) + + expect(decrypted.decrypted).toBe(true) + expect(decrypted.ids()).toEqual([eventId]) + expect(decrypted.addresses()).toEqual([address]) + + const publicOnly = await PinList.fromEvent(event) + + expect(publicOnly.decrypted).toBe(false) + expect(publicOnly.ids()).toEqual([eventId]) + expect(publicOnly.addresses()).toEqual([]) + }) + + it("throws on the wrong kind", async () => { + await expect(PinList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/Poll.test.ts b/packages/domain/__tests__/Poll.test.ts new file mode 100644 index 0000000..1750993 --- /dev/null +++ b/packages/domain/__tests__/Poll.test.ts @@ -0,0 +1,123 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, POLL, NOTE} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {Poll, PollBuilder} from "../src/kinds/Poll" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) + +const makeEvent = (overrides: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: POLL, + tags: [], + content: "", + sig: "00".repeat(64), + ...overrides, + }) as TrustedEvent + +describe("Poll", () => { + it("parses the represented tags and plain-text title", async () => { + const event = makeEvent({ + content: "Favorite color?", + tags: [ + ["option", "1", "Red"], + ["option", "2", "Blue"], + ["polltype", "multiplechoice"], + ["endsAt", "1234"], + ["relay", "wss://relay.one"], + ["relay", "wss://relay.two"], + ["alt", "x"], + ], + }) + + const poll = await Poll.fromEvent(event) + + expect(poll.title()).toBe("Favorite color?") + expect(poll.options()).toEqual([ + {id: "1", label: "Red"}, + {id: "2", label: "Blue"}, + ]) + expect(poll.pollType()).toBe("multiplechoice") + expect(poll.endsAt()).toBe(1234) + expect(poll.relays()).toEqual(["wss://relay.one", "wss://relay.two"]) + }) + + it("tallies results from response events", async () => { + const poll = await Poll.fromEvent( + makeEvent({ + content: "Pick one", + tags: [ + ["option", "1", "Red"], + ["option", "2", "Blue"], + ], + }), + ) + + const responses = [ + {pubkey: "a", created_at: 1, tags: [["response", "1"]]}, + {pubkey: "b", created_at: 1, tags: [["response", "2"]]}, + // Latest response per pubkey wins. + {pubkey: "a", created_at: 2, tags: [["response", "2"]]}, + ] as TrustedEvent[] + + const result = poll.results(responses) + + expect(result.voters).toBe(2) + expect(result.options.find(o => o.id === "1")!.votes).toBe(0) + expect(result.options.find(o => o.id === "2")!.votes).toBe(2) + }) + + it("round-trips with no duplication", async () => { + const event = makeEvent({ + content: "Favorite color?", + tags: [ + ["option", "1", "Red"], + ["option", "2", "Blue"], + ["polltype", "multiplechoice"], + ["endsAt", "1234"], + ["relay", "wss://relay.one"], + ["alt", "x"], + ], + }) + + const tmpl = await (await Poll.fromEvent(event)).builder().toTemplate(signer) + + expect(tmpl.content).toBe("Favorite color?") + expect(tmpl.tags.filter(t => t[0] === "option").length).toBe(2) + expect(tmpl.tags.filter(t => t[0] === "polltype").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "endsAt").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "relay").length).toBe(1) + // Unknown tag survives the round-trip. + expect(tmpl.tags).toContainEqual(["alt", "x"]) + }) + + it("builds from a fresh builder", async () => { + const tmpl = await new PollBuilder() + .setTitle("Q?") + .addOption("Red", "1") + .addOption("Blue", "2") + .setPollType("multiplechoice") + .setEndsAt(9999) + .setRelays(["wss://relay.one"]) + .toTemplate(signer) + + expect(tmpl.kind).toBe(POLL) + expect(tmpl.content).toBe("Q?") + expect(tmpl.tags).toContainEqual(["option", "1", "Red"]) + expect(tmpl.tags).toContainEqual(["polltype", "multiplechoice"]) + expect(tmpl.tags).toContainEqual(["endsAt", "9999"]) + expect(tmpl.tags).toContainEqual(["relay", "wss://relay.one"]) + }) + + it("requires at least one option", async () => { + await expect(new PollBuilder().setTitle("Q?").toTemplate(signer)).rejects.toThrow() + }) + + it("throws on the wrong kind", async () => { + await expect(Poll.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/PollResponse.test.ts b/packages/domain/__tests__/PollResponse.test.ts new file mode 100644 index 0000000..36c8e59 --- /dev/null +++ b/packages/domain/__tests__/PollResponse.test.ts @@ -0,0 +1,81 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, POLL_RESPONSE, NOTE} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {PollResponse, PollResponseBuilder} from "../src/kinds/PollResponse" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) +const poll = "aa".repeat(32) + +const makeEvent = (overrides: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: POLL_RESPONSE, + tags: [], + content: "", + sig: "00".repeat(64), + ...overrides, + }) as TrustedEvent + +describe("PollResponse", () => { + it("parses the poll id and deduped selections", async () => { + const event = makeEvent({ + tags: [ + ["e", poll], + ["response", "1"], + ["response", "2"], + ["response", "1"], + ["alt", "x"], + ], + }) + + const response = await PollResponse.fromEvent(event) + + expect(response.pollId()).toBe(poll) + expect(response.selections()).toEqual(["1", "2"]) + }) + + it("round-trips with no duplication", async () => { + const event = makeEvent({ + tags: [ + ["e", poll], + ["response", "1"], + ["response", "2"], + ["alt", "x"], + ], + }) + + const tmpl = await (await PollResponse.fromEvent(event)).builder().toTemplate(signer) + + expect(tmpl.tags.filter(t => t[0] === "e").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "response").length).toBe(2) + expect(tmpl.tags).toContainEqual(["alt", "x"]) + }) + + it("builds from a fresh builder", async () => { + const tmpl = await new PollResponseBuilder() + .setPollId(poll) + .addSelection("1") + .addSelection("1") + .addSelection("2") + .toTemplate(signer) + + expect(tmpl.kind).toBe(POLL_RESPONSE) + expect(tmpl.tags).toContainEqual(["e", poll]) + expect(tmpl.tags.filter(t => t[0] === "response")).toEqual([ + ["response", "1"], + ["response", "2"], + ]) + }) + + it("requires a poll id", async () => { + await expect(new PollResponseBuilder().addSelection("1").toTemplate(signer)).rejects.toThrow() + }) + + it("throws on the wrong kind", async () => { + await expect(PollResponse.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/Profile.test.ts b/packages/domain/__tests__/Profile.test.ts index 6174221..fde0bb3 100644 --- a/packages/domain/__tests__/Profile.test.ts +++ b/packages/domain/__tests__/Profile.test.ts @@ -2,7 +2,7 @@ import {describe, it, expect} from "vitest" import {makeSecret, PROFILE, NOTE} from "@welshman/util" import type {TrustedEvent} from "@welshman/util" import {Nip01Signer} from "@welshman/signer" -import {Profile, displayPubkey} from "../src/Profile" +import {Profile, displayPubkey} from "../src/kinds/Profile" const signer = new Nip01Signer(makeSecret()) const pubkey = "ee".repeat(32) @@ -26,13 +26,13 @@ describe("Profile", () => { tags: [["alt", "profile"]], }) - const profile = await Profile.parse(event) + const profile = await Profile.fromEvent(event) expect(profile.values.name).toBe("alice") - expect(profile.hasName()).toBe(true) + expect(profile.name()).toBe("alice") expect(profile.display()).toBe("alice") - const signed = await profile.toEvent(signer) + const signed = await profile.builder().toEvent(signer) expect(signed.kind).toBe(PROFILE) expect(JSON.parse(signed.content).name).toBe("alice") @@ -40,28 +40,31 @@ describe("Profile", () => { expect(signed.tags).toEqual([["alt", "profile"]]) }) - it("derives lnurl from a lud16 address", () => { - const profile = Profile.init({lud16: "alice@example.com"}) + it("derives lnurl from a lud16 address", async () => { + const profile = await Profile.fromEvent( + makeEvent({content: JSON.stringify({lud16: "alice@example.com"})}), + ) - expect(profile.values.lnurl).toBeTruthy() + expect(profile.lnurl()).toBeTruthy() }) - it("gets and sets values by key", () => { - const profile = Profile.init({name: "alice"}) + it("seeds the builder from the reader and edits values", async () => { + const profile = await Profile.fromEvent(makeEvent({content: JSON.stringify({name: "alice"})})) - profile.set("about", "hello") + const event = await profile.builder().about("hello").toEvent(signer) + const updated = await Profile.fromEvent(event) - expect(profile.get("name")).toBe("alice") - expect(profile.get("about")).toBe("hello") + expect(updated.name()).toBe("alice") + expect(updated.about()).toBe("hello") }) it("display falls back to a shortened npub", async () => { - const profile = await Profile.parse(makeEvent({content: "{}"})) + const profile = await Profile.fromEvent(makeEvent({content: "{}"})) expect(profile.display()).toBe(displayPubkey(pubkey)) }) it("throws on the wrong kind", async () => { - await expect(Profile.parse(makeEvent({kind: NOTE}))).rejects.toThrow() + await expect(Profile.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() }) }) diff --git a/packages/domain/__tests__/RelayInvite.test.ts b/packages/domain/__tests__/RelayInvite.test.ts new file mode 100644 index 0000000..d68acc5 --- /dev/null +++ b/packages/domain/__tests__/RelayInvite.test.ts @@ -0,0 +1,62 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, RELAY_INVITE, NOTE} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {RelayInvite, RelayInviteBuilder} from "../src/kinds/RelayInvite" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) + +const makeEvent = (overrides: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: RELAY_INVITE, + tags: [], + content: "", + sig: "00".repeat(64), + ...overrides, + }) as TrustedEvent + +describe("RelayInvite", () => { + it("reads the claim tag", async () => { + const event = makeEvent({ + tags: [ + ["claim", "secret-code"], + ["alt", "x"], + ], + }) + + const invite = await RelayInvite.fromEvent(event) + + expect(invite.claim()).toBe("secret-code") + }) + + it("round-trips with no duplicate claim tag", async () => { + const event = makeEvent({ + tags: [ + ["claim", "secret-code"], + ["alt", "x"], + ], + }) + + const tmpl = await (await RelayInvite.fromEvent(event)).builder().toTemplate(signer) + + expect(tmpl.tags.filter(t => t[0] === "claim").length).toBe(1) + expect(tmpl.tags).toContainEqual(["claim", "secret-code"]) + // Unknown passthrough tag survives. + expect(tmpl.tags).toContainEqual(["alt", "x"]) + }) + + it("builds from a fresh builder", async () => { + const tmpl = await new RelayInviteBuilder().setClaim("fresh-code").toTemplate(signer) + + expect(tmpl.kind).toBe(RELAY_INVITE) + expect(tmpl.tags).toContainEqual(["claim", "fresh-code"]) + }) + + it("throws on the wrong kind", async () => { + await expect(RelayInvite.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/RelayJoin.test.ts b/packages/domain/__tests__/RelayJoin.test.ts new file mode 100644 index 0000000..e95a2c6 --- /dev/null +++ b/packages/domain/__tests__/RelayJoin.test.ts @@ -0,0 +1,65 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, RELAY_JOIN, NOTE} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {RelayJoin, RelayJoinBuilder} from "../src/kinds/RelayJoin" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) + +const makeEvent = (overrides: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: RELAY_JOIN, + tags: [], + content: "", + sig: "00".repeat(64), + ...overrides, + }) as TrustedEvent + +describe("RelayJoin", () => { + it("reads claim tag and reason content", async () => { + const join = await RelayJoin.fromEvent( + makeEvent({tags: [["claim", "abc123"]], content: "please let me in"}), + ) + + expect(join.claim()).toBe("abc123") + expect(join.reason()).toBe("please let me in") + }) + + it("returns undefined for missing claim/reason", async () => { + const join = await RelayJoin.fromEvent(makeEvent()) + + expect(join.claim()).toBeUndefined() + expect(join.reason()).toBeUndefined() + }) + + it("round-trips with no duplicate tags and preserves passthrough/content", async () => { + const join = await RelayJoin.fromEvent( + makeEvent({tags: [["claim", "abc123"], ["alt", "x"]], content: "let me in"}), + ) + + const tmpl = await join.builder().toTemplate(signer) + + expect(tmpl.kind).toBe(RELAY_JOIN) + expect(tmpl.tags.filter(t => t[0] === "claim").length).toBe(1) + expect(tmpl.tags).toContainEqual(["alt", "x"]) + expect(tmpl.content).toBe("let me in") + }) + + it("builds from a fresh builder", async () => { + const tmpl = await new RelayJoinBuilder() + .setClaim("invite42") + .setReason("hello") + .toTemplate(signer) + + expect(tmpl.tags).toContainEqual(["claim", "invite42"]) + expect(tmpl.content).toBe("hello") + }) + + it("throws on the wrong kind", async () => { + await expect(RelayJoin.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/RelayLeave.test.ts b/packages/domain/__tests__/RelayLeave.test.ts new file mode 100644 index 0000000..1b372ff --- /dev/null +++ b/packages/domain/__tests__/RelayLeave.test.ts @@ -0,0 +1,46 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, RELAY_LEAVE, NOTE} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {RelayLeave, RelayLeaveBuilder} from "../src/kinds/RelayLeave" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) +const group = "wss://relay.example.com" + +const makeEvent = (overrides: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: RELAY_LEAVE, + tags: [], + content: "", + sig: "00".repeat(64), + ...overrides, + }) as TrustedEvent + +describe("RelayLeave", () => { + it("round-trips the group behavior tag without duplication", async () => { + const leave = await RelayLeave.fromEvent(makeEvent({tags: [["h", group], ["alt", "x"]]})) + + expect(leave.group()).toBe(group) + + const tmpl = await leave.builder().toTemplate(signer) + + expect(tmpl.kind).toBe(RELAY_LEAVE) + expect(tmpl.tags.filter(t => t[0] === "h").length).toBe(1) + expect(tmpl.tags).toContainEqual(["h", group]) + expect(tmpl.tags).toContainEqual(["alt", "x"]) + }) + + it("sets the group via a fresh builder", async () => { + const tmpl = await new RelayLeaveBuilder().group(group).toTemplate(signer) + + expect(tmpl.tags).toContainEqual(["h", group]) + }) + + it("throws on the wrong kind", async () => { + await expect(RelayLeave.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/RelayList.test.ts b/packages/domain/__tests__/RelayList.test.ts new file mode 100644 index 0000000..8749e05 --- /dev/null +++ b/packages/domain/__tests__/RelayList.test.ts @@ -0,0 +1,92 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, RELAYS, NOTE, RelayMode, normalizeRelayUrl} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {RelayList, RelayListBuilder} from "../src/kinds/RelayList" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) + +const both = "wss://both.example.com/" +const read = "wss://read.example.com/" +const write = "wss://write.example.com/" + +const makeEvent = (o: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: RELAYS, + tags: [], + content: "", + sig: "00".repeat(64), + ...o, + }) as TrustedEvent + +describe("RelayList", () => { + it("reads relay urls split by read/write mode", async () => { + const reader = await RelayList.fromEvent( + makeEvent({ + tags: [ + ["r", both], + ["r", read, RelayMode.Read], + ["r", write, RelayMode.Write], + ["alt", "x"], + ], + }), + ) + + expect(reader.urls().sort()).toEqual( + [both, read, write].map(normalizeRelayUrl).sort(), + ) + expect(reader.readUrls().sort()).toEqual( + [both, read].map(normalizeRelayUrl).sort(), + ) + expect(reader.writeUrls().sort()).toEqual( + [both, write].map(normalizeRelayUrl).sort(), + ) + }) + + it("round-trips without duplicating represented tags", async () => { + const reader = await RelayList.fromEvent( + makeEvent({ + tags: [["r", both], ["r", read, RelayMode.Read], ["alt", "x"]], + }), + ) + + const tmpl = await reader.builder().toTemplate(signer) + + expect(tmpl.tags.filter(t => t[0] === "r").length).toBe(2) + expect(tmpl.tags).toContainEqual(["alt", "x"]) + }) + + it("adds modeless and single-mode relays via a fresh builder", async () => { + const tmpl = await new RelayListBuilder() + .addRelay(read, RelayMode.Read) + .addRelay(write, RelayMode.Write) + .addRelay(both, RelayMode.Read) + .addRelay(both, RelayMode.Write) + .toTemplate(signer) + + expect(tmpl.kind).toBe(RELAYS) + // both was added for read then write, so it should collapse to modeless. + expect(tmpl.tags).toContainEqual(["r", both]) + expect(tmpl.tags).toContainEqual(["r", read, RelayMode.Read]) + expect(tmpl.tags).toContainEqual(["r", write, RelayMode.Write]) + }) + + it("downgrades a modeless relay when one mode is removed", async () => { + const tmpl = await new RelayListBuilder() + .addRelay(both, RelayMode.Read) + .addRelay(both, RelayMode.Write) + .removeRelay(both, RelayMode.Read) + .toTemplate(signer) + + expect(tmpl.tags).toContainEqual(["r", both, RelayMode.Write]) + expect(tmpl.tags).not.toContainEqual(["r", both]) + }) + + it("throws on the wrong kind", async () => { + await expect(RelayList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/RelayMembers.test.ts b/packages/domain/__tests__/RelayMembers.test.ts new file mode 100644 index 0000000..6da0574 --- /dev/null +++ b/packages/domain/__tests__/RelayMembers.test.ts @@ -0,0 +1,63 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, RELAY_MEMBERS, NOTE} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {RelayMembers, RelayMembersBuilder} from "../src/kinds/RelayMembers" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) +const a = "aa".repeat(32) +const b = "bb".repeat(32) +const c = "cc".repeat(32) + +const makeEvent = (overrides: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: RELAY_MEMBERS, + tags: [], + content: "", + sig: "00".repeat(64), + ...overrides, + }) as TrustedEvent + +describe("RelayMembers", () => { + it("reads members from p tags", async () => { + const members = await RelayMembers.fromEvent(makeEvent({tags: [["p", a], ["p", b], ["p", a]]})) + + expect(members.pubkeys().sort()).toEqual([a, b].sort()) + expect(members.isMember(a)).toBe(true) + expect(members.isMember(c)).toBe(false) + }) + + it("round-trips with deduped p tags and passthrough", async () => { + const members = await RelayMembers.fromEvent( + makeEvent({tags: [["p", a], ["p", b], ["alt", "x"]]}), + ) + + const tmpl = await members.builder().toTemplate(signer) + + expect(tmpl.kind).toBe(RELAY_MEMBERS) + expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(2) + expect(tmpl.tags).toContainEqual(["p", a]) + expect(tmpl.tags).toContainEqual(["p", b]) + expect(tmpl.tags).toContainEqual(["alt", "x"]) + }) + + it("adds and removes members via the builder", async () => { + const tmpl = await new RelayMembersBuilder() + .addPubkey(a) + .addPubkey(b) + .addPubkey(a) + .removePubkey(b) + .toTemplate(signer) + + expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(1) + expect(tmpl.tags).toContainEqual(["p", a]) + }) + + it("throws on the wrong kind", async () => { + await expect(RelayMembers.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/RelayMembershipOp.test.ts b/packages/domain/__tests__/RelayMembershipOp.test.ts new file mode 100644 index 0000000..ab06a7b --- /dev/null +++ b/packages/domain/__tests__/RelayMembershipOp.test.ts @@ -0,0 +1,84 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, NOTE} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import { + RelayAddMember, + RelayAddMemberBuilder, + RelayRemoveMember, + RelayRemoveMemberBuilder, +} from "../src/kinds/RelayMembershipOp" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) +const a = "aa".repeat(32) +const b = "bb".repeat(32) + +const makeEvent = (overrides: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: RELAY_ADD_MEMBER, + tags: [], + content: "", + sig: "00".repeat(64), + ...overrides, + }) as TrustedEvent + +describe("RelayAddMember", () => { + it("reads affected pubkeys, deduped", async () => { + const op = await RelayAddMember.fromEvent(makeEvent({tags: [["p", a], ["p", b], ["p", a]]})) + + expect(op.pubkeys().sort()).toEqual([a, b].sort()) + }) + + it("round-trips with no duplicate p tags and passthrough", async () => { + const op = await RelayAddMember.fromEvent( + makeEvent({tags: [["p", a], ["p", b], ["alt", "x"]]}), + ) + + const tmpl = await op.builder().toTemplate(signer) + + expect(tmpl.kind).toBe(RELAY_ADD_MEMBER) + expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(2) + expect(tmpl.tags).toContainEqual(["p", a]) + expect(tmpl.tags).toContainEqual(["alt", "x"]) + }) + + it("builds fresh with the right kind", async () => { + const tmpl = await new RelayAddMemberBuilder().addPubkey(a).addPubkey(a).toTemplate(signer) + + expect(tmpl.kind).toBe(RELAY_ADD_MEMBER) + expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(1) + expect(tmpl.tags).toContainEqual(["p", a]) + }) + + it("throws on the wrong kind", async () => { + await expect(RelayAddMember.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) + +describe("RelayRemoveMember", () => { + it("reads affected pubkeys with the remove kind", async () => { + const op = await RelayRemoveMember.fromEvent( + makeEvent({kind: RELAY_REMOVE_MEMBER, tags: [["p", a]]}), + ) + + expect(op.kind).toBe(RELAY_REMOVE_MEMBER) + expect(op.pubkeys()).toEqual([a]) + }) + + it("builds fresh with the remove kind", async () => { + const tmpl = await new RelayRemoveMemberBuilder().addPubkey(a).toTemplate(signer) + + expect(tmpl.kind).toBe(RELAY_REMOVE_MEMBER) + expect(tmpl.tags).toContainEqual(["p", a]) + }) + + it("throws when the add kind is read as a remove", async () => { + await expect( + RelayRemoveMember.fromEvent(makeEvent({kind: RELAY_ADD_MEMBER})), + ).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/RelaySet.test.ts b/packages/domain/__tests__/RelaySet.test.ts new file mode 100644 index 0000000..b6e10b9 --- /dev/null +++ b/packages/domain/__tests__/RelaySet.test.ts @@ -0,0 +1,106 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, NAMED_RELAYS, NOTE, normalizeRelayUrl} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {RelaySet, RelaySetBuilder} from "../src/kinds/RelaySet" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) + +const relayA = "wss://set-a.example.com/" +const relayB = "wss://set-b.example.com/" + +const makeEvent = (o: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: NAMED_RELAYS, + tags: [], + content: "", + sig: "00".repeat(64), + ...o, + }) as TrustedEvent + +describe("RelaySet", () => { + it("reads metadata and relay urls", async () => { + const reader = await RelaySet.fromEvent( + makeEvent({ + tags: [ + ["d", "my-set"], + ["title", "My Set"], + ["description", "a set of relays"], + ["image", "https://example.com/img.png"], + ["relay", relayA], + ["alt", "x"], + ], + }), + ) + + expect(reader.identifier()).toBe("my-set") + expect(reader.title()).toBe("My Set") + expect(reader.description()).toBe("a set of relays") + expect(reader.image()).toBe("https://example.com/img.png") + expect(reader.urls()).toEqual([normalizeRelayUrl(relayA)]) + }) + + it("round-trips metadata, d and relay tags exactly once each", async () => { + const reader = await RelaySet.fromEvent( + makeEvent({ + tags: [ + ["d", "my-set"], + ["title", "My Set"], + ["description", "a set of relays"], + ["image", "https://example.com/img.png"], + ["relay", relayA], + ["alt", "x"], + ], + }), + ) + + const tmpl = await reader.builder().toTemplate(signer) + + expect(tmpl.tags.filter(t => t[0] === "d").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "title").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "description").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "image").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "relay").length).toBe(1) + // Metadata round-trips with the exact same values. + expect(tmpl.tags).toContainEqual(["d", "my-set"]) + expect(tmpl.tags).toContainEqual(["title", "My Set"]) + expect(tmpl.tags).toContainEqual(["description", "a set of relays"]) + expect(tmpl.tags).toContainEqual(["image", "https://example.com/img.png"]) + expect(tmpl.tags).toContainEqual(["relay", normalizeRelayUrl(relayA)]) + expect(tmpl.tags).toContainEqual(["alt", "x"]) + }) + + it("auto-generates a d identifier on a fresh builder", async () => { + const builder = new RelaySetBuilder() + + expect(builder.identifier).toBeTruthy() + + const tmpl = await builder.setTitle("Fresh").addRelay(relayA).toTemplate(signer) + + expect(tmpl.kind).toBe(NAMED_RELAYS) + expect(tmpl.tags.filter(t => t[0] === "d").length).toBe(1) + expect(tmpl.tags).toContainEqual(["title", "Fresh"]) + expect(tmpl.tags).toContainEqual(["relay", normalizeRelayUrl(relayA)]) + }) + + it("setRelays replaces relays but preserves metadata", async () => { + const reader = await RelaySet.fromEvent( + makeEvent({tags: [["d", "my-set"], ["title", "My Set"], ["relay", relayA]]}), + ) + + const tmpl = await reader.builder().setRelays([relayB]).toTemplate(signer) + + expect(tmpl.tags).toContainEqual(["relay", normalizeRelayUrl(relayB)]) + expect(tmpl.tags.some(t => t[0] === "relay" && t[1] === normalizeRelayUrl(relayA))).toBe(false) + expect(tmpl.tags).toContainEqual(["title", "My Set"]) + expect(tmpl.tags).toContainEqual(["d", "my-set"]) + }) + + it("throws on the wrong kind", async () => { + await expect(RelaySet.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/Report.test.ts b/packages/domain/__tests__/Report.test.ts new file mode 100644 index 0000000..ff75fb6 --- /dev/null +++ b/packages/domain/__tests__/Report.test.ts @@ -0,0 +1,81 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, REPORT, NOTE} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {Report, ReportBuilder} from "../src/kinds/Report" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) +const reported = "aa".repeat(32) +const eventId = "bb".repeat(32) + +const makeEvent = (overrides: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: REPORT, + tags: [], + content: "", + sig: "00".repeat(64), + ...overrides, + }) as TrustedEvent + +describe("Report", () => { + it("reads represented tags and content", async () => { + const event = makeEvent({ + content: "this is spam", + tags: [ + ["p", reported], + ["e", eventId, "spam"], + ["alt", "x"], + ], + }) + + const report = await Report.fromEvent(event) + + expect(report.reportedPubkey()).toBe(reported) + expect(report.eventId()).toBe(eventId) + expect(report.reason()).toBe("spam") + expect(report.content()).toBe("this is spam") + }) + + it("round-trips with no duplicate represented tags", async () => { + const event = makeEvent({ + content: "this is spam", + tags: [ + ["p", reported], + ["e", eventId, "spam"], + ["alt", "x"], + ], + }) + + const tmpl = await (await Report.fromEvent(event)).builder().toTemplate(signer) + + expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "e").length).toBe(1) + expect(tmpl.tags).toContainEqual(["p", reported]) + expect(tmpl.tags).toContainEqual(["e", eventId, "spam"]) + // Unknown passthrough tag survives. + expect(tmpl.tags).toContainEqual(["alt", "x"]) + expect(tmpl.content).toBe("this is spam") + }) + + it("builds from a fresh builder", async () => { + const tmpl = await new ReportBuilder() + .setReportedPubkey(reported) + .setEventId(eventId) + .setReason("impersonation") + .setContent("bad actor") + .toTemplate(signer) + + expect(tmpl.kind).toBe(REPORT) + expect(tmpl.tags).toContainEqual(["p", reported]) + expect(tmpl.tags).toContainEqual(["e", eventId, "impersonation"]) + expect(tmpl.content).toBe("bad actor") + }) + + it("throws on the wrong kind", async () => { + await expect(Report.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/RoomAdmins.test.ts b/packages/domain/__tests__/RoomAdmins.test.ts new file mode 100644 index 0000000..d64472c --- /dev/null +++ b/packages/domain/__tests__/RoomAdmins.test.ts @@ -0,0 +1,69 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, ROOM_ADMINS, NOTE} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {RoomAdmins, RoomAdminsBuilder} from "../src/kinds/RoomAdmins" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) +const a = "aa".repeat(32) +const b = "bb".repeat(32) + +const makeEvent = (overrides: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: ROOM_ADMINS, + tags: [], + content: "", + sig: "00".repeat(64), + ...overrides, + }) as TrustedEvent + +describe("RoomAdmins", () => { + it("reads represented tags", async () => { + const room = await RoomAdmins.fromEvent( + makeEvent({tags: [["d", "room1"], ["p", a], ["p", b], ["alt", "x"]]}), + ) + + expect(room.h()).toBe("room1") + expect(room.identifier()).toBe("room1") + expect(room.pubkeys()).toEqual([a, b]) + }) + + it("round-trips with no duplicated tags", async () => { + const room = await RoomAdmins.fromEvent( + makeEvent({tags: [["d", "room1"], ["p", a], ["p", b], ["alt", "x"]]}), + ) + + const tmpl = await room.builder().toTemplate(signer) + + expect(tmpl.kind).toBe(ROOM_ADMINS) + expect(tmpl.tags.filter(t => t[0] === "d").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(2) + expect(tmpl.tags).toContainEqual(["d", "room1"]) + expect(tmpl.tags).toContainEqual(["p", a]) + expect(tmpl.tags).toContainEqual(["p", b]) + // Unknown passthrough tag survives. + expect(tmpl.tags).toContainEqual(["alt", "x"]) + }) + + it("builds from a fresh builder", async () => { + const tmpl = await new RoomAdminsBuilder() + .setH("room2") + .addPubkey(a) + .addPubkey(a) // dedup + .addPubkey(b) + .toTemplate(signer) + + expect(tmpl.tags).toContainEqual(["d", "room2"]) + expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(2) + expect(tmpl.tags).toContainEqual(["p", a]) + expect(tmpl.tags).toContainEqual(["p", b]) + }) + + it("throws on the wrong kind", async () => { + await expect(RoomAdmins.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/RoomCreate.test.ts b/packages/domain/__tests__/RoomCreate.test.ts new file mode 100644 index 0000000..3bc55ad --- /dev/null +++ b/packages/domain/__tests__/RoomCreate.test.ts @@ -0,0 +1,46 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, ROOM_CREATE, NOTE} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {RoomCreate, RoomCreateBuilder} from "../src/kinds/RoomCreate" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) +const group = "abcd1234" + +const makeEvent = (overrides: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: ROOM_CREATE, + tags: [], + content: "", + sig: "00".repeat(64), + ...overrides, + }) as TrustedEvent + +describe("RoomCreate", () => { + it("round-trips the group behavior tag without duplication", async () => { + const create = await RoomCreate.fromEvent(makeEvent({tags: [["h", group], ["alt", "x"]]})) + + expect(create.group()).toBe(group) + + const tmpl = await create.builder().toTemplate(signer) + + expect(tmpl.kind).toBe(ROOM_CREATE) + expect(tmpl.tags.filter(t => t[0] === "h").length).toBe(1) + expect(tmpl.tags).toContainEqual(["h", group]) + expect(tmpl.tags).toContainEqual(["alt", "x"]) + }) + + it("sets the group via a fresh builder", async () => { + const tmpl = await new RoomCreateBuilder().group(group).toTemplate(signer) + + expect(tmpl.tags).toContainEqual(["h", group]) + }) + + it("throws on the wrong kind", async () => { + await expect(RoomCreate.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/RoomCreatePermission.test.ts b/packages/domain/__tests__/RoomCreatePermission.test.ts new file mode 100644 index 0000000..fcf8b4b --- /dev/null +++ b/packages/domain/__tests__/RoomCreatePermission.test.ts @@ -0,0 +1,64 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, ROOM_CREATE_PERMISSION, NOTE} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import { + RoomCreatePermission, + RoomCreatePermissionBuilder, +} from "../src/kinds/RoomCreatePermission" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) +const a = "aa".repeat(32) +const b = "bb".repeat(32) +const c = "cc".repeat(32) + +const makeEvent = (overrides: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: ROOM_CREATE_PERMISSION, + tags: [], + content: "", + sig: "00".repeat(64), + ...overrides, + }) as TrustedEvent + +describe("RoomCreatePermission", () => { + it("reads permitted pubkeys from p tags", async () => { + const perm = await RoomCreatePermission.fromEvent( + makeEvent({tags: [["p", a], ["p", b], ["p", a]]}), + ) + + expect(perm.pubkeys().sort()).toEqual([a, b].sort()) + expect(perm.canCreate(a)).toBe(true) + expect(perm.canCreate(c)).toBe(false) + }) + + it("round-trips with no duplicate p tags and passthrough", async () => { + const perm = await RoomCreatePermission.fromEvent( + makeEvent({tags: [["p", a], ["p", b], ["alt", "x"]]}), + ) + + const tmpl = await perm.builder().toTemplate(signer) + + expect(tmpl.kind).toBe(ROOM_CREATE_PERMISSION) + expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(2) + expect(tmpl.tags).toContainEqual(["p", a]) + expect(tmpl.tags).toContainEqual(["p", b]) + expect(tmpl.tags).toContainEqual(["alt", "x"]) + }) + + it("sets pubkeys via a fresh builder, deduped", async () => { + const tmpl = await new RoomCreatePermissionBuilder().setPubkeys([a, b, a]).toTemplate(signer) + + expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(2) + expect(tmpl.tags).toContainEqual(["p", a]) + expect(tmpl.tags).toContainEqual(["p", b]) + }) + + it("throws on the wrong kind", async () => { + await expect(RoomCreatePermission.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/RoomDelete.test.ts b/packages/domain/__tests__/RoomDelete.test.ts new file mode 100644 index 0000000..4cf78cf --- /dev/null +++ b/packages/domain/__tests__/RoomDelete.test.ts @@ -0,0 +1,69 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, ROOM_DELETE, NOTE} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {RoomDelete, RoomDeleteBuilder} from "../src/kinds/RoomDelete" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) + +const makeEvent = (overrides: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: ROOM_DELETE, + tags: [], + content: "", + sig: "00".repeat(64), + ...overrides, + }) as TrustedEvent + +describe("RoomDelete", () => { + it("reads multiple repeatable h tags", async () => { + const del = await RoomDelete.fromEvent( + makeEvent({tags: [["h", "room1"], ["h", "room2"], ["h", "room3"], ["alt", "x"]]}), + ) + + expect(del.hs()).toEqual(["room1", "room2", "room3"]) + expect(del.h()).toBe("room1") + }) + + it("round-trips multiple rooms, emitting one h each with no duplication", async () => { + const del = await RoomDelete.fromEvent( + makeEvent({tags: [["h", "room1"], ["h", "room2"], ["h", "room3"], ["alt", "x"]]}), + ) + + const tmpl = await del.builder().toTemplate(signer) + + expect(tmpl.kind).toBe(ROOM_DELETE) + // Three rooms in, three h tags out — exactly one per room, no duplicates. + expect(tmpl.tags.filter(t => t[0] === "h").length).toBe(3) + expect(tmpl.tags).toContainEqual(["h", "room1"]) + expect(tmpl.tags).toContainEqual(["h", "room2"]) + expect(tmpl.tags).toContainEqual(["h", "room3"]) + // Unknown passthrough tag survives. + expect(tmpl.tags).toContainEqual(["alt", "x"]) + }) + + it("builds from a fresh builder and edits the room set", async () => { + const tmpl = await new RoomDeleteBuilder() + .addRoom("room1") + .addRoom("room1") // dedup + .addRoom("room2") + .removeRoom("room1") + .toTemplate(signer) + + expect(tmpl.tags.filter(t => t[0] === "h").length).toBe(1) + expect(tmpl.tags).toContainEqual(["h", "room2"]) + expect(tmpl.tags).not.toContainEqual(["h", "room1"]) + }) + + it("requires at least one h tag", async () => { + await expect(new RoomDeleteBuilder().toTemplate(signer)).rejects.toThrow() + }) + + it("throws on the wrong kind", async () => { + await expect(RoomDelete.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/RoomJoin.test.ts b/packages/domain/__tests__/RoomJoin.test.ts new file mode 100644 index 0000000..d5b59ea --- /dev/null +++ b/packages/domain/__tests__/RoomJoin.test.ts @@ -0,0 +1,77 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, ROOM_JOIN, NOTE, getTagValue} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {RoomJoin, RoomJoinBuilder} from "../src/kinds/RoomJoin" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) + +const makeEvent = (overrides: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: ROOM_JOIN, + tags: [], + content: "", + sig: "00".repeat(64), + ...overrides, + }) as TrustedEvent + +describe("RoomJoin", () => { + it("reads represented fields", async () => { + const join = await RoomJoin.fromEvent( + makeEvent({ + tags: [["h", "room1"], ["claim", "invite-code"], ["alt", "x"]], + content: "please let me in", + }), + ) + + expect(join.group()).toBe("room1") + expect(join.code()).toBe("invite-code") + expect(join.reason()).toBe("please let me in") + }) + + it("round-trips with no duplicated tags", async () => { + const join = await RoomJoin.fromEvent( + makeEvent({ + tags: [["h", "room1"], ["claim", "invite-code"], ["alt", "x"]], + content: "please let me in", + }), + ) + + const tmpl = await join.builder().toTemplate(signer) + + expect(tmpl.kind).toBe(ROOM_JOIN) + // h round-trips via the base behavior tag. + expect(tmpl.tags.filter(t => t[0] === "h").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "claim").length).toBe(1) + expect(getTagValue("h", tmpl.tags)).toBe("room1") + expect(tmpl.tags).toContainEqual(["claim", "invite-code"]) + // Content (free-text reason) is preserved. + expect(tmpl.content).toBe("please let me in") + // Unknown passthrough tag survives. + expect(tmpl.tags).toContainEqual(["alt", "x"]) + }) + + it("builds from a fresh builder", async () => { + const tmpl = await new RoomJoinBuilder() + .group("room2") + .setCode("xyz") + .setReason("hi there") + .toTemplate(signer) + + expect(getTagValue("h", tmpl.tags)).toBe("room2") + expect(tmpl.tags).toContainEqual(["claim", "xyz"]) + expect(tmpl.content).toBe("hi there") + }) + + it("requires an h/group", async () => { + await expect(new RoomJoinBuilder().setCode("xyz").toTemplate(signer)).rejects.toThrow() + }) + + it("throws on the wrong kind", async () => { + await expect(RoomJoin.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/RoomLeave.test.ts b/packages/domain/__tests__/RoomLeave.test.ts new file mode 100644 index 0000000..5f93d1b --- /dev/null +++ b/packages/domain/__tests__/RoomLeave.test.ts @@ -0,0 +1,55 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, ROOM_LEAVE, NOTE} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {RoomLeave, RoomLeaveBuilder} from "../src/kinds/RoomLeave" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) +const group = "abcd1234" + +const makeEvent = (overrides: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: ROOM_LEAVE, + tags: [], + content: "", + sig: "00".repeat(64), + ...overrides, + }) as TrustedEvent + +describe("RoomLeave", () => { + it("reads the group via group() and h()", async () => { + const leave = await RoomLeave.fromEvent(makeEvent({tags: [["h", group]]})) + + expect(leave.group()).toBe(group) + expect(leave.h()).toBe(group) + }) + + it("round-trips the group behavior tag without duplication", async () => { + const leave = await RoomLeave.fromEvent(makeEvent({tags: [["h", group], ["alt", "x"]]})) + + const tmpl = await leave.builder().toTemplate(signer) + + expect(tmpl.kind).toBe(ROOM_LEAVE) + expect(tmpl.tags.filter(t => t[0] === "h").length).toBe(1) + expect(tmpl.tags).toContainEqual(["h", group]) + expect(tmpl.tags).toContainEqual(["alt", "x"]) + }) + + it("sets the group via a fresh builder", async () => { + const tmpl = await new RoomLeaveBuilder().group(group).toTemplate(signer) + + expect(tmpl.tags).toContainEqual(["h", group]) + }) + + it("requires an h identifier", async () => { + await expect(new RoomLeaveBuilder().toTemplate(signer)).rejects.toThrow() + }) + + it("throws on the wrong kind", async () => { + await expect(RoomLeave.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/RoomList.test.ts b/packages/domain/__tests__/RoomList.test.ts new file mode 100644 index 0000000..0b8ba34 --- /dev/null +++ b/packages/domain/__tests__/RoomList.test.ts @@ -0,0 +1,79 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, ROOMS, NOTE} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {RoomList, RoomListBuilder} from "../src/kinds/RoomList" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) + +const relay = "wss://groups.example.com/" +const groupA = "groupa" +const groupB = "groupb" + +const makeEvent = (o: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: ROOMS, + tags: [], + content: "", + sig: "00".repeat(64), + ...o, + }) as TrustedEvent + +describe("RoomList", () => { + it("reads joined groups", async () => { + const reader = await RoomList.fromEvent( + makeEvent({tags: [["group", groupA, relay], ["alt", "x"]]}), + ) + + expect(reader.groups()).toEqual([groupA]) + expect(reader.groupTags()).toEqual([["group", groupA, relay]]) + }) + + it("round-trips without duplicating represented tags", async () => { + const reader = await RoomList.fromEvent( + makeEvent({tags: [["group", groupA, relay], ["alt", "x"]]}), + ) + + const tmpl = await reader.builder().toTemplate(signer) + + expect(tmpl.tags.filter(t => t[0] === "group").length).toBe(1) + expect(tmpl.tags).toContainEqual(["alt", "x"]) + }) + + it("joins and leaves groups via a fresh builder", async () => { + const tmpl = await new RoomListBuilder() + .join(groupA, relay) + .join(groupB, relay) + .leave(groupA) + .toTemplate(signer) + + expect(tmpl.kind).toBe(ROOMS) + expect(tmpl.tags).toContainEqual(["group", groupB, relay]) + expect(tmpl.tags.some(t => t[1] === groupA)).toBe(false) + }) + + it("round-trips public and private groups through encryption", async () => { + const event = await new RoomListBuilder() + .join(groupA, relay) + .addPrivate(["group", groupB, relay]) + .toEvent(signer) + + const decrypted = await RoomList.fromEvent(event, signer) + + expect(decrypted.decrypted).toBe(true) + expect(decrypted.groups().sort()).toEqual([groupA, groupB].sort()) + + const publicOnly = await RoomList.fromEvent(event) + + expect(publicOnly.decrypted).toBe(false) + expect(publicOnly.groups()).toEqual([groupA]) + }) + + it("throws on the wrong kind", async () => { + await expect(RoomList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/RoomMembers.test.ts b/packages/domain/__tests__/RoomMembers.test.ts new file mode 100644 index 0000000..62d82b0 --- /dev/null +++ b/packages/domain/__tests__/RoomMembers.test.ts @@ -0,0 +1,78 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, ROOM_MEMBERS, NOTE} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {RoomMembers, RoomMembersBuilder} from "../src/kinds/RoomMembers" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) +const a = "aa".repeat(32) +const b = "bb".repeat(32) +const c = "cc".repeat(32) + +const makeEvent = (overrides: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: ROOM_MEMBERS, + tags: [], + content: "", + sig: "00".repeat(64), + ...overrides, + }) as TrustedEvent + +describe("RoomMembers", () => { + it("reads represented tags", async () => { + const room = await RoomMembers.fromEvent( + makeEvent({tags: [["d", "room1"], ["p", a], ["p", b], ["alt", "x"]]}), + ) + + expect(room.h()).toBe("room1") + expect(room.members()).toEqual([a, b]) + expect(room.isMember(a)).toBe(true) + expect(room.isMember(c)).toBe(false) + }) + + it("round-trips with no duplicated tags", async () => { + const room = await RoomMembers.fromEvent( + makeEvent({tags: [["d", "room1"], ["p", a], ["p", b], ["alt", "x"]]}), + ) + + const tmpl = await room.builder().toTemplate(signer) + + expect(tmpl.kind).toBe(ROOM_MEMBERS) + expect(tmpl.tags.filter(t => t[0] === "d").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(2) + expect(tmpl.tags).toContainEqual(["d", "room1"]) + expect(tmpl.tags).toContainEqual(["p", a]) + expect(tmpl.tags).toContainEqual(["p", b]) + // Unknown passthrough tag survives. + expect(tmpl.tags).toContainEqual(["alt", "x"]) + }) + + it("requires an h identifier on a fresh builder", async () => { + await expect(new RoomMembersBuilder().addMember(a).toTemplate(signer)).rejects.toThrow() + }) + + it("builds from a fresh builder and edits membership", async () => { + const builder = new RoomMembersBuilder() + builder.h = "room2" + + const tmpl = await builder + .addMember(a) + .addMember(a) // dedup + .addMember(b) + .removeMember(b) + .toTemplate(signer) + + expect(tmpl.tags).toContainEqual(["d", "room2"]) + expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(1) + expect(tmpl.tags).toContainEqual(["p", a]) + expect(tmpl.tags).not.toContainEqual(["p", b]) + }) + + it("throws on the wrong kind", async () => { + await expect(RoomMembers.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/RoomMembershipOp.test.ts b/packages/domain/__tests__/RoomMembershipOp.test.ts new file mode 100644 index 0000000..6fac17e --- /dev/null +++ b/packages/domain/__tests__/RoomMembershipOp.test.ts @@ -0,0 +1,114 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER, NOTE, getTagValue} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import { + RoomAddMember, + RoomAddMemberBuilder, + RoomRemoveMember, + RoomRemoveMemberBuilder, +} from "../src/kinds/RoomMembershipOp" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) +const a = "aa".repeat(32) +const b = "bb".repeat(32) + +const makeEvent = (overrides: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: ROOM_ADD_MEMBER, + tags: [], + content: "", + sig: "00".repeat(64), + ...overrides, + }) as TrustedEvent + +describe("RoomAddMember", () => { + it("reads pubkeys and group", async () => { + const op = await RoomAddMember.fromEvent( + makeEvent({tags: [["h", "room1"], ["p", a], ["p", b], ["alt", "x"]]}), + ) + + expect(op.kind).toBe(ROOM_ADD_MEMBER) + expect(op.group()).toBe("room1") + expect(op.pubkeys()).toEqual([a, b]) + }) + + it("round-trips with no duplicated tags", async () => { + const op = await RoomAddMember.fromEvent( + makeEvent({tags: [["h", "room1"], ["p", a], ["p", b], ["alt", "x"]]}), + ) + + const tmpl = await op.builder().toTemplate(signer) + + expect(tmpl.kind).toBe(ROOM_ADD_MEMBER) + // h round-trips via the base behavior tag. + expect(tmpl.tags.filter(t => t[0] === "h").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(2) + expect(getTagValue("h", tmpl.tags)).toBe("room1") + expect(tmpl.tags).toContainEqual(["p", a]) + expect(tmpl.tags).toContainEqual(["p", b]) + // Unknown passthrough tag survives. + expect(tmpl.tags).toContainEqual(["alt", "x"]) + }) + + it("builds from a fresh builder", async () => { + const tmpl = await new RoomAddMemberBuilder() + .group("room2") + .addPubkey(a) + .addPubkey(a) // dedup + .addPubkey(b) + .toTemplate(signer) + + expect(tmpl.kind).toBe(ROOM_ADD_MEMBER) + expect(getTagValue("h", tmpl.tags)).toBe("room2") + expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(2) + expect(tmpl.tags).toContainEqual(["p", a]) + expect(tmpl.tags).toContainEqual(["p", b]) + }) + + it("throws on the wrong kind", async () => { + await expect(RoomAddMember.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) + +describe("RoomRemoveMember", () => { + it("uses the remove kind and reads pubkeys", async () => { + const op = await RoomRemoveMember.fromEvent( + makeEvent({kind: ROOM_REMOVE_MEMBER, tags: [["h", "room1"], ["p", a]]}), + ) + + expect(op.kind).toBe(ROOM_REMOVE_MEMBER) + expect(op.pubkeys()).toEqual([a]) + }) + + it("round-trips through the remove builder", async () => { + const op = await RoomRemoveMember.fromEvent( + makeEvent({kind: ROOM_REMOVE_MEMBER, tags: [["h", "room1"], ["p", a], ["p", b]]}), + ) + + const tmpl = await op.builder().toTemplate(signer) + + expect(tmpl.kind).toBe(ROOM_REMOVE_MEMBER) + expect(tmpl.tags.filter(t => t[0] === "h").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(2) + expect(getTagValue("h", tmpl.tags)).toBe("room1") + }) + + it("builds from a fresh remove builder", async () => { + const tmpl = await new RoomRemoveMemberBuilder().group("room2").addPubkey(a).toTemplate(signer) + + expect(tmpl.kind).toBe(ROOM_REMOVE_MEMBER) + expect(getTagValue("h", tmpl.tags)).toBe("room2") + expect(tmpl.tags).toContainEqual(["p", a]) + }) + + it("throws on the wrong kind", async () => { + await expect( + RoomRemoveMember.fromEvent(makeEvent({kind: ROOM_ADD_MEMBER, tags: [["p", a]]})), + ).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/RoomMeta.test.ts b/packages/domain/__tests__/RoomMeta.test.ts new file mode 100644 index 0000000..1e12df3 --- /dev/null +++ b/packages/domain/__tests__/RoomMeta.test.ts @@ -0,0 +1,100 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, ROOM_META, NOTE} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {RoomMeta, RoomMetaBuilder} from "../src/kinds/RoomMeta" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) + +const makeEvent = (overrides: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: ROOM_META, + tags: [], + content: "", + sig: "00".repeat(64), + ...overrides, + }) as TrustedEvent + +describe("RoomMeta", () => { + it("reads represented tags", async () => { + const room = await RoomMeta.fromEvent( + makeEvent({ + tags: [ + ["d", "room1"], + ["name", "My Room"], + ["about", "a place"], + ["picture", "https://img", "256x256"], + ["closed"], + ["private"], + ["livekit"], + ["alt", "x"], + ], + }), + ) + + expect(room.h()).toBe("room1") + expect(room.identifier()).toBe("room1") + expect(room.name()).toBe("My Room") + expect(room.about()).toBe("a place") + expect(room.picture()).toBe("https://img") + expect(room.pictureMeta()).toEqual(["256x256"]) + expect(room.isClosed()).toBe(true) + expect(room.isHidden()).toBe(false) + expect(room.isPrivate()).toBe(true) + expect(room.isRestricted()).toBe(false) + expect(room.livekit()).toBe(true) + }) + + it("round-trips with no duplicated tags", async () => { + const room = await RoomMeta.fromEvent( + makeEvent({ + tags: [ + ["d", "room1"], + ["name", "My Room"], + ["about", "a place"], + ["picture", "https://img", "256x256"], + ["closed"], + ["private"], + ["alt", "x"], + ], + }), + ) + + const tmpl = await room.builder().toTemplate(signer) + + expect(tmpl.kind).toBe(ROOM_META) + expect(tmpl.tags.filter(t => t[0] === "d").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "name").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "about").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "picture").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "closed").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "private").length).toBe(1) + expect(tmpl.tags).toContainEqual(["d", "room1"]) + expect(tmpl.tags).toContainEqual(["name", "My Room"]) + expect(tmpl.tags).toContainEqual(["picture", "https://img", "256x256"]) + // Unknown passthrough tag survives. + expect(tmpl.tags).toContainEqual(["alt", "x"]) + }) + + it("builds from a fresh builder", async () => { + const tmpl = await new RoomMetaBuilder() + .setName("Fresh") + .setAbout("desc") + .setPicture("https://pic", ["100x100"]) + .toTemplate(signer) + + expect(tmpl.tags).toContainEqual(["name", "Fresh"]) + expect(tmpl.tags).toContainEqual(["about", "desc"]) + expect(tmpl.tags).toContainEqual(["picture", "https://pic", "100x100"]) + // A d-identifier is auto-generated. + expect(tmpl.tags.find(t => t[0] === "d")?.[1]).toBeTruthy() + }) + + it("throws on the wrong kind", async () => { + await expect(RoomMeta.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/SearchRelayList.test.ts b/packages/domain/__tests__/SearchRelayList.test.ts new file mode 100644 index 0000000..03cd440 --- /dev/null +++ b/packages/domain/__tests__/SearchRelayList.test.ts @@ -0,0 +1,81 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, SEARCH_RELAYS, NOTE, normalizeRelayUrl} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {SearchRelayList, SearchRelayListBuilder} from "../src/kinds/SearchRelayList" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) + +const relayA = "wss://search-a.example.com/" +const relayB = "wss://search-b.example.com/" + +const makeEvent = (o: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: SEARCH_RELAYS, + tags: [], + content: "", + sig: "00".repeat(64), + ...o, + }) as TrustedEvent + +describe("SearchRelayList", () => { + it("reads search relay urls", async () => { + const reader = await SearchRelayList.fromEvent( + makeEvent({tags: [["relay", relayA], ["alt", "x"]]}), + ) + + expect(reader.urls()).toEqual([normalizeRelayUrl(relayA)]) + expect(reader.includes(relayA)).toBe(true) + expect(reader.includes(relayB)).toBe(false) + }) + + it("round-trips without duplicating represented tags", async () => { + const reader = await SearchRelayList.fromEvent( + makeEvent({tags: [["relay", relayA], ["alt", "x"]]}), + ) + + const tmpl = await reader.builder().toTemplate(signer) + + expect(tmpl.tags.filter(t => t[0] === "relay").length).toBe(1) + expect(tmpl.tags).toContainEqual(["alt", "x"]) + }) + + it("adds and removes relays via a fresh builder", async () => { + const tmpl = await new SearchRelayListBuilder() + .addRelay(relayA) + .addRelay(relayB) + .removeRelay(relayA) + .toTemplate(signer) + + expect(tmpl.kind).toBe(SEARCH_RELAYS) + expect(tmpl.tags).toContainEqual(["relay", normalizeRelayUrl(relayB)]) + expect(tmpl.tags.some(t => t[1] === normalizeRelayUrl(relayA))).toBe(false) + }) + + it("round-trips public and private relays through encryption", async () => { + const event = await new SearchRelayListBuilder() + .addRelay(relayA) + .addPrivate(["relay", relayB]) + .toEvent(signer) + + const decrypted = await SearchRelayList.fromEvent(event, signer) + + expect(decrypted.decrypted).toBe(true) + expect(decrypted.urls().sort()).toEqual( + [normalizeRelayUrl(relayA), normalizeRelayUrl(relayB)].sort(), + ) + + const publicOnly = await SearchRelayList.fromEvent(event) + + expect(publicOnly.decrypted).toBe(false) + expect(publicOnly.urls()).toEqual([normalizeRelayUrl(relayA)]) + }) + + it("throws on the wrong kind", async () => { + await expect(SearchRelayList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/Thread.test.ts b/packages/domain/__tests__/Thread.test.ts new file mode 100644 index 0000000..0342f9a --- /dev/null +++ b/packages/domain/__tests__/Thread.test.ts @@ -0,0 +1,87 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, THREAD, NOTE} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {Thread, ThreadBuilder} from "../src/kinds/Thread" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) + +const makeEvent = (overrides: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: THREAD, + tags: [], + content: "", + sig: "00".repeat(64), + ...overrides, + }) as TrustedEvent + +describe("Thread", () => { + it("reads title and content", async () => { + const event = makeEvent({ + content: "thread body", + tags: [ + ["title", "Hello"], + ["alt", "x"], + ], + }) + + const thread = await Thread.fromEvent(event) + + expect(thread.title()).toBe("Hello") + expect(thread.content()).toBe("thread body") + }) + + it("round-trips with no duplicate title tag", async () => { + const event = makeEvent({ + content: "thread body", + tags: [ + ["title", "Hello"], + ["alt", "x"], + ], + }) + + const tmpl = await (await Thread.fromEvent(event)).builder().toTemplate(signer) + + expect(tmpl.tags.filter(t => t[0] === "title").length).toBe(1) + expect(tmpl.tags).toContainEqual(["title", "Hello"]) + // Unknown passthrough tag survives. + expect(tmpl.tags).toContainEqual(["alt", "x"]) + expect(tmpl.content).toBe("thread body") + }) + + it("round-trips a group (h) behavior tag without rebuilding", async () => { + const event = makeEvent({ + content: "thread body", + tags: [ + ["title", "Hello"], + ["h", "room"], + ], + }) + + const tmpl = await (await Thread.fromEvent(event)).builder().toTemplate(signer) + + expect(tmpl.tags.filter(t => t[0] === "h").length).toBe(1) + expect(tmpl.tags).toContainEqual(["h", "room"]) + }) + + it("builds from a fresh builder", async () => { + const tmpl = await new ThreadBuilder() + .setTitle("New thread") + .setContent("body") + .group("room") + .toTemplate(signer) + + expect(tmpl.kind).toBe(THREAD) + expect(tmpl.tags).toContainEqual(["title", "New thread"]) + expect(tmpl.tags).toContainEqual(["h", "room"]) + expect(tmpl.content).toBe("body") + }) + + it("throws on the wrong kind", async () => { + await expect(Thread.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/TimeEvent.test.ts b/packages/domain/__tests__/TimeEvent.test.ts new file mode 100644 index 0000000..a79c842 --- /dev/null +++ b/packages/domain/__tests__/TimeEvent.test.ts @@ -0,0 +1,120 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, EVENT_TIME, NOTE} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {TimeEvent, TimeEventBuilder} from "../src/kinds/TimeEvent" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) + +// Both timestamps fall within the same epoch-day so exactly one derived "D" tag +// is produced (range yields only `start` when end < start + DAY). +const start = 1700000000 +const end = 1700003600 + +const makeEvent = (overrides: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: EVENT_TIME, + tags: [], + content: "", + sig: "00".repeat(64), + ...overrides, + }) as TrustedEvent + +describe("TimeEvent", () => { + it("reads represented tags and content", async () => { + const event = makeEvent({ + content: "meetup", + tags: [ + ["d", "abc"], + ["title", "Party"], + ["location", "Town Hall"], + ["start", String(start)], + ["end", String(end)], + ["alt", "x"], + ], + }) + + const time = await TimeEvent.fromEvent(event) + + expect(time.identifier()).toBe("abc") + expect(time.title()).toBe("Party") + expect(time.location()).toBe("Town Hall") + expect(time.start()).toBe(start) + expect(time.end()).toBe(end) + expect(time.content()).toBe("meetup") + }) + + it("falls back to the legacy name tag for title", async () => { + const event = makeEvent({tags: [["name", "Legacy"]]}) + const time = await TimeEvent.fromEvent(event) + + expect(time.title()).toBe("Legacy") + }) + + it("round-trips with no duplicate represented tags", async () => { + const event = makeEvent({ + content: "meetup", + tags: [ + ["d", "abc"], + ["title", "Party"], + ["location", "Town Hall"], + ["start", String(start)], + ["end", String(end)], + ["alt", "x"], + ], + }) + + const tmpl = await (await TimeEvent.fromEvent(event)).builder().toTemplate(signer) + + for (const key of ["d", "title", "location", "start", "end"]) { + expect(tmpl.tags.filter(t => t[0] === key).length).toBe(1) + } + expect(tmpl.tags).toContainEqual(["d", "abc"]) + expect(tmpl.tags).toContainEqual(["title", "Party"]) + expect(tmpl.tags).toContainEqual(["start", String(start)]) + // Derived day index recomputed exactly once for this single-day span. + expect(tmpl.tags.filter(t => t[0] === "D").length).toBe(1) + // Unknown passthrough tag survives. + expect(tmpl.tags).toContainEqual(["alt", "x"]) + expect(tmpl.content).toBe("meetup") + }) + + it("does not duplicate the D index on round-trip", async () => { + // Even though the source already carries a "D" tag, it is consumed on read + // and recomputed, so it must appear exactly once. + const event = makeEvent({ + tags: [ + ["d", "abc"], + ["start", String(start)], + ["end", String(end)], + ["D", "999999"], + ], + }) + + const tmpl = await (await TimeEvent.fromEvent(event)).builder().toTemplate(signer) + + expect(tmpl.tags.filter(t => t[0] === "D").length).toBe(1) + expect(tmpl.tags).not.toContainEqual(["D", "999999"]) + }) + + it("builds from a fresh builder with an auto-generated d", async () => { + const tmpl = await new TimeEventBuilder() + .setTitle("Fresh") + .setStart(start) + .setEnd(end) + .toTemplate(signer) + + expect(tmpl.kind).toBe(EVENT_TIME) + expect(tmpl.tags.find(t => t[0] === "d")?.[1]).toBeTruthy() + expect(tmpl.tags).toContainEqual(["title", "Fresh"]) + expect(tmpl.tags).toContainEqual(["start", String(start)]) + }) + + it("throws on the wrong kind", async () => { + await expect(TimeEvent.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/TopicList.test.ts b/packages/domain/__tests__/TopicList.test.ts new file mode 100644 index 0000000..9b7281e --- /dev/null +++ b/packages/domain/__tests__/TopicList.test.ts @@ -0,0 +1,82 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, TOPICS, NOTE} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {TopicList, TopicListBuilder} from "../src/kinds/TopicList" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) + +const topicA = "nostr" +const topicB = "bitcoin" +const address = `30015:${"22".repeat(32)}:interests` + +const makeEvent = (o: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: TOPICS, + tags: [], + content: "", + sig: "00".repeat(64), + ...o, + }) as TrustedEvent + +describe("TopicList", () => { + it("reads followed topics and interest-set addresses", async () => { + const reader = await TopicList.fromEvent( + makeEvent({tags: [["t", topicA], ["a", address], ["alt", "x"]]}), + ) + + expect(reader.topics()).toEqual([topicA]) + expect(reader.addresses()).toEqual([address]) + expect(reader.includes(topicA)).toBe(true) + expect(reader.includes(topicB)).toBe(false) + }) + + it("round-trips without duplicating represented tags", async () => { + const reader = await TopicList.fromEvent( + makeEvent({tags: [["t", topicA], ["a", address], ["alt", "x"]]}), + ) + + const tmpl = await reader.builder().toTemplate(signer) + + expect(tmpl.tags.filter(t => t[0] === "t").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "a").length).toBe(1) + expect(tmpl.tags).toContainEqual(["alt", "x"]) + }) + + it("follows and unfollows via a fresh builder", async () => { + const tmpl = await new TopicListBuilder() + .follow(topicA) + .follow(topicB) + .unfollow(topicA) + .toTemplate(signer) + + expect(tmpl.kind).toBe(TOPICS) + expect(tmpl.tags).toContainEqual(["t", topicB]) + expect(tmpl.tags.some(t => t[1] === topicA)).toBe(false) + }) + + it("round-trips public and private topics through encryption", async () => { + const event = await new TopicListBuilder() + .followPublicly(topicA) + .followPrivately(topicB) + .toEvent(signer) + + const decrypted = await TopicList.fromEvent(event, signer) + + expect(decrypted.decrypted).toBe(true) + expect(decrypted.topics().sort()).toEqual([topicA, topicB].sort()) + + const publicOnly = await TopicList.fromEvent(event) + + expect(publicOnly.decrypted).toBe(false) + expect(publicOnly.topics()).toEqual([topicA]) + }) + + it("throws on the wrong kind", async () => { + await expect(TopicList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/ZapGoal.test.ts b/packages/domain/__tests__/ZapGoal.test.ts new file mode 100644 index 0000000..d706404 --- /dev/null +++ b/packages/domain/__tests__/ZapGoal.test.ts @@ -0,0 +1,91 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, ZAP_GOAL, NOTE} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {ZapGoal, ZapGoalBuilder} from "../src/kinds/ZapGoal" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) + +const makeEvent = (overrides: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: ZAP_GOAL, + tags: [], + content: "", + sig: "00".repeat(64), + ...overrides, + }) as TrustedEvent + +describe("ZapGoal", () => { + it("parses the represented tags and plain-text title", async () => { + const event = makeEvent({ + content: "New server fund", + tags: [ + ["summary", "help us buy a server"], + ["amount", "500000"], + ["relays", "wss://relay.one"], + ["relays", "wss://relay.two"], + ["alt", "x"], + ], + }) + + const goal = await ZapGoal.fromEvent(event) + + expect(goal.title()).toBe("New server fund") + expect(goal.summary()).toBe("help us buy a server") + expect(goal.amount()).toBe(500000) + expect(goal.relays()).toEqual(["wss://relay.one", "wss://relay.two"]) + }) + + it("defaults amount to 0 when missing or unparseable", async () => { + const goal = await ZapGoal.fromEvent(makeEvent({content: "Goal"})) + + expect(goal.amount()).toBe(0) + }) + + it("round-trips with no duplication", async () => { + const event = makeEvent({ + content: "New server fund", + tags: [ + ["summary", "help us buy a server"], + ["amount", "500000"], + ["relays", "wss://relay.one"], + ["alt", "x"], + ], + }) + + const tmpl = await (await ZapGoal.fromEvent(event)).builder().toTemplate(signer) + + expect(tmpl.content).toBe("New server fund") + expect(tmpl.tags.filter(t => t[0] === "summary").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "amount").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "relays").length).toBe(1) + expect(tmpl.tags).toContainEqual(["alt", "x"]) + }) + + it("builds from a fresh builder", async () => { + const tmpl = await new ZapGoalBuilder() + .setTitle("Goal") + .setSummary("a summary") + .setAmount(1000) + .setRelays(["wss://relay.one"]) + .toTemplate(signer) + + expect(tmpl.kind).toBe(ZAP_GOAL) + expect(tmpl.content).toBe("Goal") + expect(tmpl.tags).toContainEqual(["summary", "a summary"]) + expect(tmpl.tags).toContainEqual(["amount", "1000"]) + expect(tmpl.tags).toContainEqual(["relays", "wss://relay.one"]) + }) + + it("requires a title", async () => { + await expect(new ZapGoalBuilder().setAmount(1000).toTemplate(signer)).rejects.toThrow() + }) + + it("throws on the wrong kind", async () => { + await expect(ZapGoal.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/ZapReceipt.test.ts b/packages/domain/__tests__/ZapReceipt.test.ts new file mode 100644 index 0000000..9f3bf94 --- /dev/null +++ b/packages/domain/__tests__/ZapReceipt.test.ts @@ -0,0 +1,139 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, ZAP_RECEIPT, ZAP_REQUEST, NOTE} from "@welshman/util" +import type {TrustedEvent, Zapper} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {ZapReceipt, ZapReceiptBuilder} from "../src/kinds/ZapReceipt" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) +const sender = "aa".repeat(32) +const recipient = "bb".repeat(32) +const eventId = "cc".repeat(32) + +// hrp "100n" => 100 * 1e11 / 1e9 = 10000 millisats. +const bolt11 = "lnbc100n1pjexample" + +// The embedded kind-9734 zap request carried inside the JSON "description" tag. +const request = { + id: "11".repeat(32), + pubkey: sender, + created_at: 0, + kind: ZAP_REQUEST, + tags: [ + ["amount", "10000"], + ["lnurl", "lnurl1xyz"], + ], + content: "thanks!", + sig: "00".repeat(64), +} + +const makeEvent = (overrides: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: ZAP_RECEIPT, + tags: [], + content: "", + sig: "00".repeat(64), + ...overrides, + }) as TrustedEvent + +describe("ZapReceipt", () => { + it("parses the represented tags and the embedded zap request", async () => { + const event = makeEvent({ + tags: [ + ["bolt11", bolt11], + ["description", JSON.stringify(request)], + ["p", recipient], + ["e", eventId], + ["preimage", "abcd"], + ["alt", "x"], + ], + }) + + const receipt = await ZapReceipt.fromEvent(event) + + expect(receipt.bolt11()).toBe(bolt11) + expect(receipt.invoiceAmount()).toBe(10000) + expect(receipt.recipient()).toBe(recipient) + expect(receipt.eventId()).toBe(eventId) + expect(receipt.preimage()).toBe("abcd") + + // Parsed-values accessors derived from the JSON "description" tag. + expect(receipt.request()).toEqual(request) + expect(receipt.sender()).toBe(sender) + expect(receipt.comment()).toBe("thanks!") + }) + + it("returns undefined invoice amount for a malformed bolt11", async () => { + const receipt = await ZapReceipt.fromEvent(makeEvent({tags: [["bolt11", "not-an-invoice"]]})) + + expect(receipt.invoiceAmount()).toBeUndefined() + }) + + it("verifies a legitimate zap from a zapper", async () => { + const event = makeEvent({ + pubkey: "dd".repeat(32), + tags: [ + ["bolt11", bolt11], + ["description", JSON.stringify(request)], + ["p", recipient], + ], + }) + + const receipt = await ZapReceipt.fromEvent(event) + + const zapper = { + pubkey: recipient, + lnurl: "lnurl1xyz", + nostrPubkey: "dd".repeat(32), + } as Zapper + + expect(receipt.verify(zapper)).toBe(true) + // A forged receipt (wrong nostrPubkey) is rejected. + expect(receipt.verify({...zapper, nostrPubkey: "ab".repeat(32)})).toBe(false) + }) + + it("round-trips with no duplication", async () => { + const event = makeEvent({ + tags: [ + ["bolt11", bolt11], + ["description", JSON.stringify(request)], + ["p", recipient], + ["e", eventId], + ["preimage", "abcd"], + ["alt", "x"], + ], + }) + + const tmpl = await (await ZapReceipt.fromEvent(event)).builder().toTemplate(signer) + + expect(tmpl.tags.filter(t => t[0] === "bolt11").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "description").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "e").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "preimage").length).toBe(1) + expect(tmpl.tags).toContainEqual(["alt", "x"]) + }) + + it("builds from a fresh builder", async () => { + const tmpl = await new ZapReceiptBuilder() + .setBolt11(bolt11) + .setDescription(JSON.stringify(request)) + .setRecipient(recipient) + .setEventId(eventId) + .setPreimage("abcd") + .toTemplate(signer) + + expect(tmpl.kind).toBe(ZAP_RECEIPT) + expect(tmpl.tags).toContainEqual(["bolt11", bolt11]) + expect(tmpl.tags).toContainEqual(["p", recipient]) + expect(tmpl.tags).toContainEqual(["e", eventId]) + expect(tmpl.tags).toContainEqual(["preimage", "abcd"]) + }) + + it("throws on the wrong kind", async () => { + await expect(ZapReceipt.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/ZapRequest.test.ts b/packages/domain/__tests__/ZapRequest.test.ts new file mode 100644 index 0000000..aa975ae --- /dev/null +++ b/packages/domain/__tests__/ZapRequest.test.ts @@ -0,0 +1,100 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, ZAP_REQUEST, NOTE} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {ZapRequest, ZapRequestBuilder} from "../src/kinds/ZapRequest" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) +const recipient = "aa".repeat(32) +const eventId = "bb".repeat(32) + +const makeEvent = (overrides: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: ZAP_REQUEST, + tags: [], + content: "", + sig: "00".repeat(64), + ...overrides, + }) as TrustedEvent + +describe("ZapRequest", () => { + it("parses the represented tags and comment content", async () => { + const event = makeEvent({ + content: "thanks!", + tags: [ + ["amount", "21000"], + ["lnurl", "lnurl1xyz"], + ["p", recipient], + ["e", eventId], + ["relays", "wss://relay.one", "wss://relay.two"], + ["anon"], + ["alt", "x"], + ], + }) + + const req = await ZapRequest.fromEvent(event) + + expect(req.amount()).toBe(21000) + expect(req.lnurl()).toBe("lnurl1xyz") + expect(req.recipient()).toBe(recipient) + expect(req.eventId()).toBe(eventId) + expect(req.relays()).toEqual(["wss://relay.one", "wss://relay.two"]) + expect(req.isAnonymous()).toBe(true) + expect(req.comment()).toBe("thanks!") + }) + + it("round-trips with no duplication", async () => { + const event = makeEvent({ + content: "thanks!", + tags: [ + ["amount", "21000"], + ["lnurl", "lnurl1xyz"], + ["p", recipient], + ["e", eventId], + ["relays", "wss://relay.one"], + ["anon"], + ["alt", "x"], + ], + }) + + const tmpl = await (await ZapRequest.fromEvent(event)).builder().toTemplate(signer) + + expect(tmpl.content).toBe("thanks!") + expect(tmpl.tags.filter(t => t[0] === "amount").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "lnurl").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "e").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "relays").length).toBe(1) + expect(tmpl.tags.filter(t => t[0] === "anon").length).toBe(1) + expect(tmpl.tags).toContainEqual(["alt", "x"]) + }) + + it("builds from a fresh builder", async () => { + const tmpl = await new ZapRequestBuilder() + .setAmount(1000) + .setLnurl("lnurl1abc") + .setRecipient(recipient) + .setEventId(eventId) + .setRelays(["wss://relay.one"]) + .setAnonymous() + .setComment("hi") + .toTemplate(signer) + + expect(tmpl.kind).toBe(ZAP_REQUEST) + expect(tmpl.content).toBe("hi") + expect(tmpl.tags).toContainEqual(["amount", "1000"]) + expect(tmpl.tags).toContainEqual(["lnurl", "lnurl1abc"]) + expect(tmpl.tags).toContainEqual(["p", recipient]) + expect(tmpl.tags).toContainEqual(["e", eventId]) + expect(tmpl.tags).toContainEqual(["relays", "wss://relay.one"]) + expect(tmpl.tags).toContainEqual(["anon"]) + }) + + it("throws on the wrong kind", async () => { + await expect(ZapRequest.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow() + }) +}) diff --git a/packages/domain/src/EventBuilder.ts b/packages/domain/src/EventBuilder.ts new file mode 100644 index 0000000..0a9b1ff --- /dev/null +++ b/packages/domain/src/EventBuilder.ts @@ -0,0 +1,91 @@ +import {first, partition, spec} from "@welshman/lib" +import type {Maybe, MaybeAsync} from "@welshman/lib" +import {stamp, prep} from "@welshman/util" +import type {EventTemplate, SignedEvent, HashedEvent} from "@welshman/util" +import type {ISigner} from "@welshman/signer" +import type {EventReader} from "./EventReader.js" + +export abstract class EventBuilder { + abstract readonly kind: number + groupTag?: string[] + protectTag?: string[] + expiresTag?: string[] + extraTags: string[][] = [] + + constructor(readonly reader?: Reader) { + this.extraTags = reader?.event.tags ?? [] + this.groupTag = first(this.consumeTags("h")) + this.protectTag = first(this.consumeTags("-")) + this.expiresTag = first(this.consumeTags("expiration")) + } + + protected consumeTags(key: string): string[][] { + const [consumed, remaining] = partition(spec([key]), this.extraTags) + + this.extraTags = remaining + + return consumed + } + + group(group: Maybe) { + this.groupTag = group ? ["h", group] : undefined + + return this + } + + protect(protect: boolean) { + this.protectTag = protect ? ["-"] : undefined + + return this + } + + expires(expires: Maybe) { + this.expiresTag = expires ? ["expiration", String(expires)] : undefined + + return this + } + + protected buildTags(signer?: ISigner): MaybeAsync { + return [] + } + + protected buildContent(signer?: ISigner): MaybeAsync { + return "" + } + + protected validate(): void {} + + private behaviorTags(): string[][] { + const tags: string[][] = [] + + if (this.groupTag) tags.push(this.groupTag) + if (this.protectTag) tags.push(this.protectTag) + if (this.expiresTag) tags.push(this.expiresTag) + + return tags + } + + async toTemplate(signer?: ISigner): Promise { + this.validate() + + const kind = this.kind + const [content, implTags, behaviorTags] = await Promise.all([ + this.buildContent(signer), + this.buildTags(signer), + this.behaviorTags(), + ]) + const tags = [...implTags, ...behaviorTags, ...this.extraTags] + + return {kind, content, tags} + } + + async toRumor(signer: ISigner): Promise { + const [template, pubkey] = await Promise.all([this.toTemplate(signer), signer.getPubkey()]) + + return prep(template, pubkey) + } + + async toEvent(signer: ISigner): Promise { + return signer.sign(stamp(await this.toTemplate(signer))) + } +} diff --git a/packages/domain/src/EventReader.ts b/packages/domain/src/EventReader.ts new file mode 100644 index 0000000..1514dc7 --- /dev/null +++ b/packages/domain/src/EventReader.ts @@ -0,0 +1,65 @@ +import {spec} from "@welshman/lib" +import {getTagValue, getAddress} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import type {ISigner} from "@welshman/signer" +import type {EventBuilder} from "./EventBuilder.js" + +export abstract class EventReader { + abstract readonly kind: number + + constructor(readonly event: TrustedEvent) {} + + static async fromEvent( + this: (new (event: TrustedEvent) => T), + event: TrustedEvent, + signer?: ISigner, + ): Promise { + const reader = new this(event) + + if (event.kind !== reader.kind) { + throw new Error(`Expected a kind ${reader.kind} event, got kind ${event.kind}`) + } + + await reader.parse(signer) + + return reader + } + + protected async parse(signer?: ISigner): Promise {} + + id() { + return this.event.id + } + + pubkey() { + return this.event.pubkey + } + + createdAt() { + return this.event.created_at + } + + identifier() { + return getTagValue("d", this.event.tags) + } + + address() { + return getAddress(this.event) + } + + group() { + return getTagValue("h", this.event.tags) + } + + protect() { + return this.event.tags.some(spec(["-"])) + } + + expires() { + const expiration = parseInt(getTagValue("expiration", this.event.tags) ?? "") + + return isNaN(expiration) ? undefined : expiration + } + + abstract builder(): EventBuilder +} diff --git a/packages/domain/src/List.ts b/packages/domain/src/List.ts deleted file mode 100644 index 3cf1a30..0000000 --- a/packages/domain/src/List.ts +++ /dev/null @@ -1,187 +0,0 @@ -import {nthEq, parseJson} from "@welshman/lib" -import {uniqTags} from "@welshman/util" -import type {TrustedEvent} from "@welshman/util" -import {decrypt} from "@welshman/signer" -import type {ISigner} from "@welshman/signer" -import {EventReader, EventBuilder} from "./base.js" - -const isValidTag = (tag: unknown): tag is string[] => - Array.isArray(tag) && tag.length > 0 && tag.every(v => typeof v === "string") - -// The decrypted `plain` payload shared by every NIP-51 list. `decrypted` is false -// when there was ciphertext we couldn't read (no signer / decryption failed), so -// the private entries are unknown and must be left untouched. -export type ListPlain = { - privateTags: string[][] - decrypted: boolean -} - -// Decrypt the private tags in an event's content. Returns decrypted: false when -// there's content but no signer, or decryption fails. -export const decryptListContent = async ( - event: TrustedEvent, - signer?: ISigner, -): Promise => { - if (!event.content) return {privateTags: [], decrypted: true} - - if (!signer) return {privateTags: [], decrypted: false} - - try { - const plaintext = await decrypt(signer, event.pubkey, event.content) - const privateTags = (parseJson(plaintext) || []).filter(isValidTag) - - return {privateTags, decrypted: true} - } catch { - return {privateTags: [], decrypted: false} - } -} - -// Read side for NIP-51 lists: public entries in tags, private entries decrypted -// from content into `plain`. Subclasses declare the kind and add domain accessors -// over `tags()`. -export abstract class ListReader extends EventReader { - protected parsePlain(signer?: ISigner) { - return decryptListContent(this.event, signer) - } - - publicTags() { - return this.event.tags - } - - privateTags() { - return this.plain.privateTags - } - - decrypted() { - return this.plain.decrypted - } - - tags() { - return [...this.event.tags, ...this.plain.privateTags] - } - - // Seed a list builder from this reader: public tags, decrypted private tags, - // the original ciphertext (for the undecrypted case) and the behavior tags. - protected seedList(builder: B): B { - builder.publicTags = [...this.event.tags] - builder.plain = {privateTags: [...this.plain.privateTags], decrypted: this.plain.decrypted} - builder.originalContent = this.event.content - builder.group = this.group() - builder.protect = this.protect() - builder.expires = this.expires() - - return builder - } - - abstract builder(): ListBuilder -} - -// Write side for NIP-51 lists: mutate public/private tag sets, re-encrypt the -// private set into content on emit. -export abstract class ListBuilder extends EventBuilder { - publicTags: string[][] = [] - plain: ListPlain = {privateTags: [], decrypted: true} - // Preserved ciphertext when the source list was never decrypted. - originalContent?: string - - tags() { - return [...this.publicTags, ...this.plain.privateTags] - } - - addPublicTags(...tags: string[][]) { - this.publicTags = uniqTags([...this.publicTags, ...tags]) - - return this - } - - addPrivateTags(...tags: string[][]) { - if (!this.plain.decrypted) { - throw new Error("Cannot modify the private entries of a list that has not been decrypted") - } - - this.plain.privateTags = uniqTags([...this.plain.privateTags, ...tags]) - - return this - } - - keepTags(pred: (tag: string[]) => boolean) { - this.publicTags = this.publicTags.filter(t => pred(t)) - - if (this.plain.decrypted) { - this.plain.privateTags = this.plain.privateTags.filter(t => pred(t)) - } - - return this - } - - keepTagsWithKey(key: string) { - return this.keepTags(nthEq(0, key)) - } - - keepTagsWithValue(value: string) { - return this.keepTags(nthEq(1, value)) - } - - removeTags(pred: (tag: string[]) => boolean) { - this.publicTags = this.publicTags.filter(t => !pred(t)) - - if (this.plain.decrypted) { - this.plain.privateTags = this.plain.privateTags.filter(t => !pred(t)) - } - - return this - } - - removeTagsWithKey(key: string) { - return this.removeTags(nthEq(0, key)) - } - - removeTagsWithValue(value: string) { - return this.removeTags(nthEq(1, value)) - } - - clearPublicTags() { - this.publicTags = [] - - return this - } - - clearPrivateTags() { - if (!this.plain.decrypted) { - throw new Error("Cannot modify the private entries of a list that has not been decrypted") - } - - this.plain.privateTags = [] - - return this - } - - clearTags() { - this.publicTags = [] - - if (this.plain.decrypted) { - this.plain.privateTags = [] - } - - return this - } - - protected buildTags() { - return this.publicTags - } - - protected async buildContent(signer?: ISigner): Promise { - // Preserve the original ciphertext when we never decrypted it. - if (!this.plain.decrypted) return this.originalContent || "" - - if (this.plain.privateTags.length === 0) return "" - - if (!signer) { - throw new Error("A signer is required to encrypt the private entries of a list") - } - - const pubkey = await signer.getPubkey() - - return signer.nip44.encrypt(pubkey, JSON.stringify(this.plain.privateTags)) - } -} diff --git a/packages/domain/src/ListBuilder.ts b/packages/domain/src/ListBuilder.ts new file mode 100644 index 0000000..34c2c97 --- /dev/null +++ b/packages/domain/src/ListBuilder.ts @@ -0,0 +1,118 @@ +import {complement} from "@welshman/lib" +import type {ISigner} from "@welshman/signer" +import {EventBuilder} from "./EventBuilder.js" +import type {ListReader} from "./ListReader.js" + +export abstract class ListBuilder extends EventBuilder { + publicTags: string[][] = [] + privateTags: string[][] = [] + + constructor(readonly reader?: Reader) { + super(reader) + + this.publicTags = this.extraTags.splice(0) + + if (reader) { + this.privateTags = reader.privateTags + } + } + + addPublic(...tags: string[][]) { + this.publicTags.push(...tags) + + return this + } + + addPrivate(...tags: string[][]) { + this.privateTags.push(...tags) + + return this + } + + keepPublic(pred: (tag: string[]) => boolean) { + this.publicTags = this.publicTags.filter(pred) + + return this + } + + keepPrivate(pred: (tag: string[]) => boolean) { + this.privateTags = this.privateTags.filter(pred) + + return this + } + + keep(pred: (tag: string[]) => boolean) { + this.publicTags = this.publicTags.filter(pred) + this.privateTags = this.privateTags.filter(pred) + + return this + } + + dropPublic(pred: (tag: string[]) => boolean) { + this.publicTags = this.publicTags.filter(complement(pred)) + + return this + } + + dropPrivate(pred: (tag: string[]) => boolean) { + this.privateTags = this.privateTags.filter(complement(pred)) + + return this + } + + drop(pred: (tag: string[]) => boolean) { + this.publicTags = this.publicTags.filter(complement(pred)) + this.privateTags = this.privateTags.filter(complement(pred)) + + return this + } + + clearPublic() { + this.publicTags = [] + + return this + } + + clearPrivate() { + this.privateTags = [] + + return this + } + + clear() { + this.publicTags = [] + this.privateTags = [] + + return this + } + + protected validate() { + if ( + this.reader?.event.content && + this.reader?.decrypted === false && + this.privateTags.length > 0 + ) { + throw new Error("Unable to modify list when decryption was not performed") + } + } + + protected buildTags() { + return this.publicTags + } + + protected async buildContent(signer?: ISigner): Promise { + // Preserve the original ciphertext when we never decrypted it. + if (this.reader?.decrypted === false) return this.reader.event.content + + // No need to encrypt an empty array + if (this.privateTags.length === 0) return "" + + if (!signer) { + throw new Error("A signer is required to encrypt private tags") + } + + const pubkey = await signer.getPubkey() + + return signer.nip44.encrypt(pubkey, JSON.stringify(this.privateTags)) + } +} diff --git a/packages/domain/src/ListReader.ts b/packages/domain/src/ListReader.ts new file mode 100644 index 0000000..ba558d5 --- /dev/null +++ b/packages/domain/src/ListReader.ts @@ -0,0 +1,40 @@ +import {parseJson} from "@welshman/lib" +import type {TrustedEvent} from "@welshman/util" +import {decrypt} from "@welshman/signer" +import type {ISigner} from "@welshman/signer" +import {EventReader} from "./EventReader.js" +import type {ListBuilder} from "./ListBuilder.js" + +export abstract class ListReader extends EventReader { + decrypted = false + publicTags: string[][] = [] + privateTags: string[][] = [] + + protected async parse(signer?: ISigner) { + this.publicTags = this.event.tags + + if (!this.event.content) { + this.decrypted = true + } else if (signer) { + try { + const plaintext = await decrypt(signer, this.event.pubkey, this.event.content) + + this.decrypted = true + + const json = parseJson(plaintext) + + if (Array.isArray(json)) { + this.privateTags = json.filter(tag => Array.isArray(tag) && tag.length > 0 && tag.every(v => typeof v === "string")) + } + } catch { + // pass + } + } + } + + tags() { + return [...this.publicTags, ...this.privateTags] + } + + abstract builder(): ListBuilder +} diff --git a/packages/domain/src/Profile.ts b/packages/domain/src/Profile.ts deleted file mode 100644 index d3abede..0000000 --- a/packages/domain/src/Profile.ts +++ /dev/null @@ -1,154 +0,0 @@ -import {npubEncode} from "nostr-tools/nip19" -import {ellipsize, parseJson} from "@welshman/lib" -import {PROFILE, getLnUrl} from "@welshman/util" -import type {ISigner} from "@welshman/signer" -import {EventReader, EventBuilder} from "./base.js" - -export type ProfileValues = { - name?: string - nip05?: string - lud06?: string - lud16?: string - lnurl?: string - about?: string - banner?: string - picture?: string - website?: string - display_name?: string -} - -// Apply defaults, deriving `lnurl` from a `lud06` or `lud16` address. -export const makeProfileValues = (values: Partial = {}): ProfileValues => { - const result: ProfileValues = {...values} - - for (const key of ["lud06", "lud16"] as const) { - if (typeof result[key] === "string") { - const lnurl = getLnUrl(result[key]!) - - if (lnurl) { - result.lnurl = lnurl - } - } - } - - return result -} - -export const displayPubkey = (pubkey: string) => { - const d = npubEncode(pubkey) - - return d.slice(0, 8) + "…" + d.slice(-5) -} - -// Read side for a NIP-01 kind-0 profile. The metadata lives in the JSON content, -// parsed once into `plain` (a ProfileValues with `lnurl` derived from lud06/lud16). -// Accessors read `this.plain`; there are no represented tags. -export class Profile extends EventReader { - static kind = PROFILE - - protected parsePlain() { - return makeProfileValues(parseJson(this.event.content) || {}) - } - - name() { - return this.plain.name || this.plain.display_name - } - - nip05() { - return this.plain.nip05 - } - - lnurl() { - return this.plain.lnurl - } - - about() { - return this.plain.about - } - - banner() { - return this.plain.banner - } - - picture() { - return this.plain.picture - } - - website() { - return this.plain.website - } - - display(fallback = "") { - const name = this.name() - - if (name) return ellipsize(name, 60).trim() - - return displayPubkey(this.event.pubkey).trim() || fallback.trim() - } - - builder() { - const builder = new ProfileBuilder() - - builder.values = makeProfileValues(this.plain) - - return this.seedBuilder(builder) - } -} - -// Write side for a NIP-01 kind-0 profile. Holds the profile fields and serializes -// them to JSON content; emits no profile-specific tags. -export class ProfileBuilder extends EventBuilder { - static kind = PROFILE - - values: ProfileValues = makeProfileValues() - - setValues(values: Partial) { - this.values = makeProfileValues(values) - - return this - } - - setName(name: string) { - this.values = makeProfileValues({...this.values, name}) - - return this - } - - setNip05(nip05: string) { - this.values = makeProfileValues({...this.values, nip05}) - - return this - } - - setAbout(about: string) { - this.values = makeProfileValues({...this.values, about}) - - return this - } - - setBanner(banner: string) { - this.values = makeProfileValues({...this.values, banner}) - - return this - } - - setPicture(picture: string) { - this.values = makeProfileValues({...this.values, picture}) - - return this - } - - setWebsite(website: string) { - this.values = makeProfileValues({...this.values, website}) - - return this - } - - protected buildTags() { - return [] - } - - protected buildContent(_signer?: ISigner) { - return JSON.stringify(this.values) - } -} diff --git a/packages/domain/src/RoomCreate.ts b/packages/domain/src/RoomCreate.ts deleted file mode 100644 index 3e3f5c6..0000000 --- a/packages/domain/src/RoomCreate.ts +++ /dev/null @@ -1,22 +0,0 @@ -import {ROOM_CREATE} from "@welshman/util" -import {EventReader, EventBuilder} from "./base.js" - -// NIP-29 kind-9007 create-room action op. A regular (write-primarily) event -// carrying only the target group id ("h") tag. The "h" tag is a base behavior -// tag, so the reader exposes it via the inherited group() accessor and the -// builder sets it via setGroup — there are no kind-specific represented tags. -export class RoomCreate extends EventReader { - static kind = ROOM_CREATE - - builder() { - return this.seedBuilder(new RoomCreateBuilder()) - } -} - -export class RoomCreateBuilder extends EventBuilder { - static kind = ROOM_CREATE - - protected buildTags() { - return [] - } -} diff --git a/packages/domain/src/base.ts b/packages/domain/src/base.ts deleted file mode 100644 index 23c525d..0000000 --- a/packages/domain/src/base.ts +++ /dev/null @@ -1,210 +0,0 @@ -import {stamp, prep, getTagValue, getAddress} from "@welshman/util" -import type {EventTemplate, SignedEvent, HashedEvent, TrustedEvent} from "@welshman/util" -import type {ISigner} from "@welshman/signer" - -// Tag keys the base owns as publish-time behavior tags (group/protect/expires). -export const BEHAVIOR_TAG_KEYS = ["h", "-", "expiration"] - -/** - * Read side of a domain object: a lazy, read-only view over a single nostr event. - * - * Construct via the static `fromEvent(event, signer?)`, which validates the kind, - * eagerly computes the `plain` representation (decrypting and/or parsing the - * event content — the one thing that must happen up front, since it can be - * async), runs `validate()` (throws on missing *required* tags, lenient - * otherwise) and returns the reader. The event is always present, so identity - * accessors (`id`/`identifier`/`address`/…) are total — no optional handling. - * - * Everything else is read lazily through methods rather than parsed into fields. - * Subclasses: - * - declare `static kind` - * - add domain accessors over `this.event.tags` (and `this.plain`) - * - override `parsePlain` when the event has encrypted/JSON content - * - override `validate` to enforce required tags - * - implement `builder()` to return the matching mutable builder - * - * `plain` is generic: its shape varies per kind (decrypted private tags for - * lists, a parsed metadata object for JSON kinds, undefined for tag-only kinds), - * so each reader/builder knows what to do with it. - */ -export abstract class EventReader

{ - // Concrete subclasses declare `static kind = SOME_KIND`. - plain!: P - - constructor(readonly event: TrustedEvent) {} - - static async fromEvent>( - this: (new (event: TrustedEvent) => T) & {kind: number}, - event: TrustedEvent, - signer?: ISigner, - ): Promise { - if (event.kind !== this.kind) { - throw new Error(`Expected a kind ${this.kind} event, got kind ${event.kind}`) - } - - const reader = new this(event) - - reader.plain = (await reader.parsePlain(signer)) as T["plain"] - reader.validate() - - return reader - } - - // Eagerly compute the `plain` representation (decrypt and/or parse content). - // Default: nothing to compute. Runs once in fromEvent. - protected async parsePlain(_signer?: ISigner): Promise

{ - return undefined as P - } - - // Throw on missing required tags. Lenient by default — keep "required" narrow. - protected validate(): void {} - - // Tag keys this kind represents via dedicated accessors; combined with the - // behavior keys, these are excluded from extraTags() so a reader -> builder -> - // event round-trip doesn't lose or duplicate unknown tags. Default: none. - protected reservedTagKeys(): string[] { - return [] - } - - // Tags not represented by any accessor, for lossless carry-over into a builder. - extraTags(): string[][] { - const reserved = [...BEHAVIOR_TAG_KEYS, ...this.reservedTagKeys()] - - return this.event.tags.filter(t => !reserved.includes(t[0])) - } - - // Identity accessors — total, since the event is always present. - id() { - return this.event.id - } - - pubkey() { - return this.event.pubkey - } - - createdAt() { - return this.event.created_at - } - - identifier() { - return getTagValue("d", this.event.tags) - } - - address() { - return getAddress(this.event) - } - - // Behavior-tag accessors. - group() { - return getTagValue("h", this.event.tags) - } - - protect() { - return this.event.tags.some(t => t[0] === "-") - } - - expires() { - const expiration = parseInt(getTagValue("expiration", this.event.tags) ?? "") - - return isNaN(expiration) ? undefined : expiration - } - - // Copy the behavior tags + carry-over tags onto a freshly created builder. - // Concrete readers call this from builder() after setting kind-specific fields. - protected seedBuilder>(builder: B): B { - builder.group = this.group() - builder.protect = this.protect() - builder.expires = this.expires() - builder.extraTags = this.extraTags() - - return builder - } - - abstract builder(): EventBuilder

-} - -/** - * Write side of a domain object: a mutable draft assembled via setters and - * emitted via `toTemplate`/`toRumor`/`toEvent`. - * - * A builder may sit in an invalid/incomplete state for as long as you like; - * validation only runs at emit time (`validate()` throws then). Construct a - * fresh builder with `new XBuilder()` and required params, or seed one from a - * reader via `reader.builder()` to edit a replaceable event. - * - * Subclasses: - * - declare `static kind` - * - hold draft fields + chainable setters - * - implement `buildTags()` (the represented tags; do NOT emit behavior tags) - * - override `buildContent` for JSON/encrypted content - * - override `validate` to throw on an invalid draft - */ -export abstract class EventBuilder

{ - // Concrete subclasses declare `static kind = SOME_KIND`. - group?: string - protect = false - expires?: number - extraTags: string[][] = [] - plain!: P - - setGroup(group: string) { - this.group = group - - return this - } - - setProtect(protect = true) { - this.protect = protect - - return this - } - - setExpires(expires: number) { - this.expires = expires - - return this - } - - // The tags built from this kind's own fields. Must NOT include behavior tags - // (h/-/expiration) or the carried-over extraTags — the base appends those. - // Receives the signer (like buildContent) for kinds that need to encrypt tags. - protected abstract buildTags(signer?: ISigner): string[][] | Promise - - // The event content. Override for JSON metadata or encrypted content. - protected buildContent(_signer?: ISigner): string | Promise { - return "" - } - - // Throw on an invalid draft. Runs only at emit time. - protected validate(): void {} - - private behaviorTags(): string[][] { - const tags: string[][] = [] - - if (this.group) tags.push(["h", this.group]) - if (this.protect) tags.push(["-"]) - if (this.expires != null) tags.push(["expiration", String(this.expires)]) - - return tags - } - - async toTemplate(signer?: ISigner): Promise { - this.validate() - - const kind = (this.constructor as unknown as {kind: number}).kind - const content = await this.buildContent(signer) - const tags = [...(await this.buildTags(signer)), ...this.extraTags, ...this.behaviorTags()] - - return {kind, content, tags} - } - - async toRumor(signer: ISigner): Promise { - const [template, pubkey] = await Promise.all([this.toTemplate(signer), signer.getPubkey()]) - - return prep(template, pubkey) - } - - async toEvent(signer: ISigner): Promise { - return signer.sign(stamp(await this.toTemplate(signer))) - } -} diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 74ab7e3..bbcb578 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -1,45 +1,47 @@ -export * from "./base.js" -export * from "./BlockedRelayList.js" -export * from "./BlossomServerList.js" -export * from "./BookmarkList.js" -export * from "./Classified.js" -export * from "./Comment.js" -export * from "./EmojiList.js" -export * from "./Feed.js" -export * from "./FeedList.js" -export * from "./FollowList.js" -export * from "./GroupList.js" -export * from "./Handler.js" -export * from "./HandlerRecommendation.js" -export * from "./List.js" -export * from "./MessagingRelayList.js" -export * from "./MuteList.js" -export * from "./PinList.js" -export * from "./Poll.js" -export * from "./PollResponse.js" -export * from "./Profile.js" -export * from "./RelayInvite.js" -export * from "./RelayJoin.js" -export * from "./RelayLeave.js" -export * from "./RelayList.js" -export * from "./RelayMembers.js" -export * from "./RelayMembershipOp.js" -export * from "./RelaySet.js" -export * from "./Report.js" -export * from "./RoomAdmins.js" -export * from "./RoomCreate.js" -export * from "./RoomCreatePermission.js" -export * from "./RoomDelete.js" -export * from "./RoomJoin.js" -export * from "./RoomLeave.js" -export * from "./RoomList.js" -export * from "./RoomMembers.js" -export * from "./RoomMembershipOp.js" -export * from "./RoomMeta.js" -export * from "./SearchRelayList.js" -export * from "./Thread.js" -export * from "./TimeEvent.js" -export * from "./TopicList.js" -export * from "./ZapGoal.js" -export * from "./ZapReceipt.js" -export * from "./ZapRequest.js" +export * from "./EventReader.js" +export * from "./EventBuilder.js" +export * from "./kinds/BlockedRelayList.js" +export * from "./kinds/BlossomServerList.js" +export * from "./kinds/BookmarkList.js" +export * from "./kinds/Classified.js" +export * from "./kinds/Comment.js" +export * from "./kinds/EmojiList.js" +export * from "./kinds/Feed.js" +export * from "./kinds/FeedList.js" +export * from "./kinds/FollowList.js" +export * from "./kinds/GroupList.js" +export * from "./kinds/Handler.js" +export * from "./kinds/HandlerRecommendation.js" +export * from "./ListReader.js" +export * from "./ListBuilder.js" +export * from "./kinds/MessagingRelayList.js" +export * from "./kinds/MuteList.js" +export * from "./kinds/PinList.js" +export * from "./kinds/Poll.js" +export * from "./kinds/PollResponse.js" +export * from "./kinds/Profile.js" +export * from "./kinds/RelayInvite.js" +export * from "./kinds/RelayJoin.js" +export * from "./kinds/RelayLeave.js" +export * from "./kinds/RelayList.js" +export * from "./kinds/RelayMembers.js" +export * from "./kinds/RelayMembershipOp.js" +export * from "./kinds/RelaySet.js" +export * from "./kinds/Report.js" +export * from "./kinds/RoomAdmins.js" +export * from "./kinds/RoomCreate.js" +export * from "./kinds/RoomCreatePermission.js" +export * from "./kinds/RoomDelete.js" +export * from "./kinds/RoomJoin.js" +export * from "./kinds/RoomLeave.js" +export * from "./kinds/RoomList.js" +export * from "./kinds/RoomMembers.js" +export * from "./kinds/RoomMembershipOp.js" +export * from "./kinds/RoomMeta.js" +export * from "./kinds/SearchRelayList.js" +export * from "./kinds/Thread.js" +export * from "./kinds/TimeEvent.js" +export * from "./kinds/TopicList.js" +export * from "./kinds/ZapGoal.js" +export * from "./kinds/ZapReceipt.js" +export * from "./kinds/ZapRequest.js" diff --git a/packages/domain/src/BlockedRelayList.ts b/packages/domain/src/kinds/BlockedRelayList.ts similarity index 55% rename from packages/domain/src/BlockedRelayList.ts rename to packages/domain/src/kinds/BlockedRelayList.ts index 8fdee6c..1b7f18b 100644 --- a/packages/domain/src/BlockedRelayList.ts +++ b/packages/domain/src/kinds/BlockedRelayList.ts @@ -1,12 +1,13 @@ -import {uniqBy} from "@welshman/lib" +import {uniqBy, nthEq} from "@welshman/lib" import {BLOCKED_RELAYS, getTagValues, normalizeRelayUrl} from "@welshman/util" -import {ListReader, ListBuilder} from "./List.js" +import {ListReader} from "../ListReader.js" +import {ListBuilder} from "../ListBuilder.js" // NIP-51 kind-10006 blocked relays. Entries are marker-less ['relay', url] tags // (NOT NIP-65 'r' tags with read/write markers). `urls()` gates AUTH (never auth // to a blocked relay) and relay selection, so it stays a flat, normalized set. export class BlockedRelayList extends ListReader { - static kind = BLOCKED_RELAYS + readonly kind = BLOCKED_RELAYS urls() { return uniqBy(normalizeRelayUrl, getTagValues("relay", this.tags())) @@ -17,24 +18,24 @@ export class BlockedRelayList extends ListReader { } builder() { - return this.seedList(new BlockedRelayListBuilder()) + return new BlockedRelayListBuilder(this) } } -export class BlockedRelayListBuilder extends ListBuilder { - static kind = BLOCKED_RELAYS +export class BlockedRelayListBuilder extends ListBuilder { + readonly kind = BLOCKED_RELAYS addRelay(url: string) { - return this.addPublicTags(["relay", normalizeRelayUrl(url)]) + return this.addPublic(["relay", normalizeRelayUrl(url)]) } removeRelay(url: string) { - return this.removeTagsWithValue(normalizeRelayUrl(url)) + return this.drop(nthEq(1, normalizeRelayUrl(url))) } setRelays(urls: string[]) { - this.clearTags() + this.clear() - return this.addPublicTags(...urls.map(url => ["relay", normalizeRelayUrl(url)])) + return this.addPublic(...urls.map(url => ["relay", normalizeRelayUrl(url)])) } } diff --git a/packages/domain/src/BlossomServerList.ts b/packages/domain/src/kinds/BlossomServerList.ts similarity index 55% rename from packages/domain/src/BlossomServerList.ts rename to packages/domain/src/kinds/BlossomServerList.ts index 7ab86ad..cbadc31 100644 --- a/packages/domain/src/BlossomServerList.ts +++ b/packages/domain/src/kinds/BlossomServerList.ts @@ -1,12 +1,13 @@ -import {uniq} from "@welshman/lib" +import {uniq, nthEq} from "@welshman/lib" import {BLOSSOM_SERVERS, getTagValues, normalizeRelayUrl} from "@welshman/util" -import {ListReader, ListBuilder} from "./List.js" +import {ListReader} from "../ListReader.js" +import {ListBuilder} from "../ListBuilder.js" // Blossom BUD-03 user server list (kind 10063). Server endpoints are stored as // `["server", url]` tags (NOT the `r`/`relay` tags used by relay lists), so the // generic relay-tag helpers would miss them. Effectively public-only. export class BlossomServerList extends ListReader { - static kind = BLOSSOM_SERVERS + readonly kind = BLOSSOM_SERVERS servers() { return uniq(getTagValues("server", this.tags()).map(normalizeRelayUrl)) @@ -17,24 +18,24 @@ export class BlossomServerList extends ListReader { } builder() { - return this.seedList(new BlossomServerListBuilder()) + return new BlossomServerListBuilder(this) } } -export class BlossomServerListBuilder extends ListBuilder { - static kind = BLOSSOM_SERVERS +export class BlossomServerListBuilder extends ListBuilder { + readonly kind = BLOSSOM_SERVERS addServer(url: string) { - return this.addPublicTags(["server", normalizeRelayUrl(url)]) + return this.addPublic(["server", normalizeRelayUrl(url)]) } removeServer(url: string) { - return this.removeTagsWithValue(normalizeRelayUrl(url)) + return this.drop(nthEq(1, normalizeRelayUrl(url))) } setServers(urls: string[]) { - this.clearTags() + this.clear() - return this.addPublicTags(...urls.map(url => ["server", normalizeRelayUrl(url)])) + return this.addPublic(...urls.map(url => ["server", normalizeRelayUrl(url)])) } } diff --git a/packages/domain/src/BookmarkList.ts b/packages/domain/src/kinds/BookmarkList.ts similarity index 67% rename from packages/domain/src/BookmarkList.ts rename to packages/domain/src/kinds/BookmarkList.ts index ca3344c..00d8034 100644 --- a/packages/domain/src/BookmarkList.ts +++ b/packages/domain/src/kinds/BookmarkList.ts @@ -1,4 +1,4 @@ -import {uniq} from "@welshman/lib" +import {uniq, nthEq} from "@welshman/lib" import { BOOKMARKS, getEventTagValues, @@ -6,13 +6,14 @@ import { getTopicTagValues, getTagValues, } from "@welshman/util" -import {ListReader, ListBuilder} from "./List.js" +import {ListReader} from "../ListReader.js" +import {ListBuilder} from "../ListBuilder.js" // NIP-51 kind-10003 bookmark list. Mixed entries (notes via 'e', articles via // 'a', hashtags via 't', urls via 'r') can be bookmarked publicly (tags) or // privately (encrypted content); accessors treat both as one merged set. export class BookmarkList extends ListReader { - static kind = BOOKMARKS + readonly kind = BOOKMARKS ids() { return uniq(getEventTagValues(this.tags())) @@ -31,22 +32,22 @@ export class BookmarkList extends ListReader { } builder() { - return this.seedList(new BookmarkListBuilder()) + return new BookmarkListBuilder(this) } } -export class BookmarkListBuilder extends ListBuilder { - static kind = BOOKMARKS +export class BookmarkListBuilder extends ListBuilder { + readonly kind = BOOKMARKS bookmarkPublicly(tag: string[]) { - return this.addPublicTags(tag) + return this.addPublic(tag) } bookmarkPrivately(tag: string[]) { - return this.addPrivateTags(tag) + return this.addPrivate(tag) } removeBookmark(value: string) { - return this.removeTagsWithValue(value) + return this.drop(nthEq(1, value)) } } diff --git a/packages/domain/src/Classified.ts b/packages/domain/src/kinds/Classified.ts similarity index 70% rename from packages/domain/src/Classified.ts rename to packages/domain/src/kinds/Classified.ts index 280b0bf..eb11f6a 100644 --- a/packages/domain/src/Classified.ts +++ b/packages/domain/src/kinds/Classified.ts @@ -1,7 +1,8 @@ -import {randomId} from "@welshman/lib" +import {first, randomId} from "@welshman/lib" import {CLASSIFIED, getTag, getTagValue, getTagValues, getTopicTagValues} from "@welshman/util" import type {ISigner} from "@welshman/signer" -import {EventReader, EventBuilder} from "./base.js" +import {EventReader} from "../EventReader.js" +import {EventBuilder} from "../EventBuilder.js" export type ClassifiedPrice = { amount: number @@ -15,17 +16,7 @@ export type ClassifiedPrice = { // handled by the base `group` behavior tag. Plain-text content, so it extends // EventReader/EventBuilder directly. export class Classified extends EventReader { - static kind = CLASSIFIED - - protected validate() { - if (!this.identifier()) { - throw new Error("Classified requires a d tag") - } - } - - protected reservedTagKeys() { - return ["d", "title", "summary", "price", "status", "image", "t"] - } + readonly kind = CLASSIFIED title() { return getTagValue("title", this.event.tags) @@ -58,23 +49,12 @@ export class Classified extends EventReader { } builder() { - const builder = new ClassifiedBuilder() - - builder.identifier = this.identifier() || "" - builder.title = this.title() - builder.summary = this.summary() - builder.content = this.content() - builder.price = this.price() - builder.status = this.status() - builder.images = this.images() - builder.topics = this.topics() - - return this.seedBuilder(builder) + return new ClassifiedBuilder(this) } } -export class ClassifiedBuilder extends EventBuilder { - static kind = CLASSIFIED +export class ClassifiedBuilder extends EventBuilder { + readonly kind = CLASSIFIED identifier = randomId() title?: string @@ -85,6 +65,25 @@ export class ClassifiedBuilder extends EventBuilder { images: string[] = [] topics: string[] = [] + constructor(readonly reader?: Classified) { + super(reader) + + // Consume the represented tags out of the carried-over extraTags so they + // round-trip through the structured fields below rather than being emitted + // twice (once from buildTags, once from the base's extraTags pass-through). + const d = first(this.consumeTags("d")) + const price = first(this.consumeTags("price")) + + this.identifier = d?.[1] || randomId() + this.title = first(this.consumeTags("title"))?.[1] + this.summary = first(this.consumeTags("summary"))?.[1] + this.content = reader?.event.content ?? "" + this.price = price ? {amount: parseFloat(price[1]) || 0, currency: price[2] || "SAT"} : undefined + this.status = first(this.consumeTags("status"))?.[1] + this.images = this.consumeTags("image").map(t => t[1]) + this.topics = this.consumeTags("t").map(t => t[1]) + } + setTitle(title: string) { this.title = title diff --git a/packages/domain/src/Comment.ts b/packages/domain/src/kinds/Comment.ts similarity index 75% rename from packages/domain/src/Comment.ts rename to packages/domain/src/kinds/Comment.ts index 1b1828e..50cc76c 100644 --- a/packages/domain/src/Comment.ts +++ b/packages/domain/src/kinds/Comment.ts @@ -1,7 +1,9 @@ +import {first} from "@welshman/lib" import {COMMENT, Address, getAddress, getTagValue, isReplaceableKind} from "@welshman/util" import type {TrustedEvent} from "@welshman/util" import type {ISigner} from "@welshman/signer" -import {EventReader, EventBuilder} from "./base.js" +import {EventReader} from "../EventReader.js" +import {EventBuilder} from "../EventBuilder.js" // A NIP-22 reference to another event: its id, address (for addressable roots), // kind, and pubkey. All optional since a comment may reference any subset. @@ -12,10 +14,6 @@ export type CommentRef = { pubkey?: string } -// The tag keys NIP-22 uses for the root (uppercase) and parent (lowercase) -// references; read into the structs and rebuilt from them on emit. -const REF_TAG_KEYS = ["E", "A", "K", "P", "e", "a", "k", "p"] - // Build a reference from a full event, deriving the address only when the event // is addressable/replaceable. const refFromEvent = (event: TrustedEvent): CommentRef => ({ @@ -45,14 +43,10 @@ const refTags = (ref: CommentRef, [idKey, addressKey, kindKey, pubkeyKey]: strin // tags (e/a/k/p) name the immediate parent. The comment body is plain text in // the event content (not JSON). // -// The reference tags are read lazily into the root/parent accessors; REF_TAG_KEYS -// is declared reserved so any other tags round-trip via the base extraTags. +// The reference tags are read lazily into the root/parent accessors; any other +// tags round-trip via the base extraTags. export class Comment extends EventReader { - static kind = COMMENT - - protected reservedTagKeys() { - return REF_TAG_KEYS - } + readonly kind = COMMENT content() { return this.event.content || "" @@ -109,26 +103,51 @@ export class Comment extends EventReader { } builder() { - const builder = new CommentBuilder() - - builder.content = this.content() - builder.root = this.root() - builder.parent = this.parent() - - return this.seedBuilder(builder) + return new CommentBuilder(this) } } // Write side of NIP-22 kind-1111 generic comment. Set the body via setContent and // the root/parent references via setRoot/setParent (or the *FromEvent variants); // buildTags rebuilds the uppercase/lowercase reference tags from those structs. -export class CommentBuilder extends EventBuilder { - static kind = COMMENT +export class CommentBuilder extends EventBuilder { + readonly kind = COMMENT content = "" root: CommentRef = {} parent: CommentRef = {} + constructor(readonly reader?: Comment) { + super(reader) + + // Consume the represented reference tags out of the carried-over extraTags so + // they round-trip through the root/parent structs below rather than being + // emitted twice (once from buildTags, once from the base's extraTags + // pass-through). Uppercase keys name the root, lowercase the parent. + const rootId = first(this.consumeTags("E")) + const rootAddress = first(this.consumeTags("A")) + const rootKind = first(this.consumeTags("K")) + const rootPubkey = first(this.consumeTags("P")) + const parentId = first(this.consumeTags("e")) + const parentAddress = first(this.consumeTags("a")) + const parentKind = first(this.consumeTags("k")) + const parentPubkey = first(this.consumeTags("p")) + + this.content = reader?.event.content ?? "" + this.root = { + id: rootId?.[1], + address: rootAddress?.[1], + kind: rootKind?.[1], + pubkey: rootPubkey?.[1], + } + this.parent = { + id: parentId?.[1], + address: parentAddress?.[1], + kind: parentKind?.[1], + pubkey: parentPubkey?.[1], + } + } + setContent(content: string) { this.content = content diff --git a/packages/domain/src/EmojiList.ts b/packages/domain/src/kinds/EmojiList.ts similarity index 60% rename from packages/domain/src/EmojiList.ts rename to packages/domain/src/kinds/EmojiList.ts index c94ebc3..878de92 100644 --- a/packages/domain/src/EmojiList.ts +++ b/packages/domain/src/kinds/EmojiList.ts @@ -1,11 +1,12 @@ -import {uniq, spec} from "@welshman/lib" +import {uniq, spec, nthEq} from "@welshman/lib" import {EMOJIS, getAddressTagValues} from "@welshman/util" -import {ListReader, ListBuilder} from "./List.js" +import {ListReader} from "../ListReader.js" +import {ListBuilder} from "../ListBuilder.js" // NIP-51 / NIP-30 kind-10030 user emoji list. Holds references to kind 30030 // emoji sets via `a` tags, plus inline `["emoji", shortcode, url]` tags. export class EmojiList extends ListReader { - static kind = EMOJIS + readonly kind = EMOJIS // Addresses of referenced emoji sets (kind 30030). addresses() { @@ -18,22 +19,22 @@ export class EmojiList extends ListReader { } builder() { - return this.seedList(new EmojiListBuilder()) + return new EmojiListBuilder(this) } } -export class EmojiListBuilder extends ListBuilder { - static kind = EMOJIS +export class EmojiListBuilder extends ListBuilder { + readonly kind = EMOJIS addEmoji(shortcode: string, url: string) { - return this.addPublicTags(["emoji", shortcode, url]) + return this.addPublic(["emoji", shortcode, url]) } addEmojiSet(address: string) { - return this.addPublicTags(["a", address]) + return this.addPublic(["a", address]) } removeEmoji(value: string) { - return this.removeTagsWithValue(value) + return this.drop(nthEq(1, value)) } } diff --git a/packages/domain/src/Feed.ts b/packages/domain/src/kinds/Feed.ts similarity index 64% rename from packages/domain/src/Feed.ts rename to packages/domain/src/kinds/Feed.ts index d114767..48ba8b0 100644 --- a/packages/domain/src/Feed.ts +++ b/packages/domain/src/kinds/Feed.ts @@ -1,7 +1,8 @@ -import {randomId, parseJson} from "@welshman/lib" +import {first, randomId, parseJson} from "@welshman/lib" import {FEED, getTagValue} from "@welshman/util" -import {EventReader, EventBuilder} from "./base.js" +import {EventReader} from "../EventReader.js" +import {EventBuilder} from "../EventBuilder.js" // NIP-51 kind-31890 saved-feed DEFINITION event. Addressable via the "d" tag. // The feed definition is a @welshman/feeds `IFeed` AST, JSON-encoded in a "feed" @@ -11,21 +12,7 @@ import {EventReader, EventBuilder} from "./base.js" // isPeopleFeed are pure functions over the IFeed AST and stay in flotilla's lib, // not on this class. Tags-only, so it extends EventReader directly. export class Feed extends EventReader { - static kind = FEED - - protected validate() { - if (!this.identifier()) { - throw new Error("Feed requires a d tag") - } - - if (getTagValue("feed", this.event.tags) == null) { - throw new Error("Feed requires a feed tag") - } - } - - protected reservedTagKeys() { - return ["d", "alt", "title", "description", "feed"] - } + readonly kind = FEED title() { return getTagValue("title", this.event.tags) || "" @@ -42,19 +29,12 @@ export class Feed extends EventReader { } builder() { - const builder = new FeedBuilder() - - builder.identifier = this.identifier() || "" - builder.title = this.title() - builder.description = this.description() - builder.definition = this.definition() - - return this.seedBuilder(builder) + return new FeedBuilder(this) } } -export class FeedBuilder extends EventBuilder { - static kind = FEED +export class FeedBuilder extends EventBuilder { + readonly kind = FEED identifier = randomId() title = "" @@ -63,6 +43,27 @@ export class FeedBuilder extends EventBuilder { // isn't a dependency here, so the AST is written structurally. definition: unknown = ["union"] + constructor(readonly reader?: Feed) { + super(reader) + + // Consume the represented tags out of the carried-over extraTags so they + // round-trip through the structured fields below rather than being emitted + // twice (once from buildTags, once from the base's extraTags pass-through). + // The "alt" tag is a copy of title rebuilt in buildTags, so it's consumed and + // discarded here. + const d = first(this.consumeTags("d")) + const title = first(this.consumeTags("title")) + const description = first(this.consumeTags("description")) + const feed = first(this.consumeTags("feed")) + + this.consumeTags("alt") + + this.identifier = d?.[1] || randomId() + this.title = title?.[1] || "" + this.description = description?.[1] || "" + this.definition = feed ? parseJson(feed[1]) : ["union"] + } + setIdentifier(identifier: string) { this.identifier = identifier diff --git a/packages/domain/src/FeedList.ts b/packages/domain/src/kinds/FeedList.ts similarity index 59% rename from packages/domain/src/FeedList.ts rename to packages/domain/src/kinds/FeedList.ts index 0cce808..52b0c71 100644 --- a/packages/domain/src/FeedList.ts +++ b/packages/domain/src/kinds/FeedList.ts @@ -1,12 +1,13 @@ -import {uniq} from "@welshman/lib" +import {uniq, nthEq} from "@welshman/lib" import {FEEDS, getAddressTagValues} from "@welshman/util" -import {ListReader, ListBuilder} from "./List.js" +import {ListReader} from "../ListReader.js" +import {ListBuilder} from "../ListBuilder.js" // NIP-51 kind-10014 saved feeds list. Entries are `a` tags pointing at kind 31890 // FEED definitions, stored publicly (tags) or privately (encrypted content); the // reader treats both as one merged set. export class FeedList extends ListReader { - static kind = FEEDS + readonly kind = FEEDS addresses() { return uniq(getAddressTagValues(this.tags())) @@ -17,22 +18,22 @@ export class FeedList extends ListReader { } builder() { - return this.seedList(new FeedListBuilder()) + return new FeedListBuilder(this) } } -export class FeedListBuilder extends ListBuilder { - static kind = FEEDS +export class FeedListBuilder extends ListBuilder { + readonly kind = FEEDS addFeed(address: string, relayHint?: string) { - return this.addPublicTags(["a", address, relayHint || ""]) + return this.addPublic(["a", address, relayHint || ""]) } addFeedPrivately(address: string, relayHint?: string) { - return this.addPrivateTags(["a", address, relayHint || ""]) + return this.addPrivate(["a", address, relayHint || ""]) } removeFeed(address: string) { - return this.removeTagsWithValue(address) + return this.drop(nthEq(1, address)) } } diff --git a/packages/domain/src/FollowList.ts b/packages/domain/src/kinds/FollowList.ts similarity index 65% rename from packages/domain/src/FollowList.ts rename to packages/domain/src/kinds/FollowList.ts index 6031dee..d4f5b4f 100644 --- a/packages/domain/src/FollowList.ts +++ b/packages/domain/src/kinds/FollowList.ts @@ -1,13 +1,14 @@ -import {uniq} from "@welshman/lib" +import {uniq, nthEq} from "@welshman/lib" import {FOLLOWS, getPubkeyTagValues} from "@welshman/util" -import {ListReader, ListBuilder} from "./List.js" +import {ListReader} from "../ListReader.js" +import {ListBuilder} from "../ListBuilder.js" // NIP-02 kind-3 follow list. Structurally a 'p'-tag list; follows are public in // practice, but the encryptable-list machinery is inherited unchanged (private // tags simply go unused). Follow targets may also be non-pubkey tags (e.g. 't' // hashtags), so `addFollow` accepts a full tag and `removeFollow` removes by value. export class FollowList extends ListReader { - static kind = FOLLOWS + readonly kind = FOLLOWS pubkeys() { return uniq(getPubkeyTagValues(this.tags())) @@ -18,18 +19,18 @@ export class FollowList extends ListReader { } builder() { - return this.seedList(new FollowListBuilder()) + return new FollowListBuilder(this) } } -export class FollowListBuilder extends ListBuilder { - static kind = FOLLOWS +export class FollowListBuilder extends ListBuilder { + readonly kind = FOLLOWS addFollow(tag: string[]) { - return this.addPublicTags(tag) + return this.addPublic(tag) } removeFollow(value: string) { - return this.removeTagsWithValue(value) + return this.drop(nthEq(1, value)) } } diff --git a/packages/domain/src/GroupList.ts b/packages/domain/src/kinds/GroupList.ts similarity index 55% rename from packages/domain/src/GroupList.ts rename to packages/domain/src/kinds/GroupList.ts index e564243..898e91a 100644 --- a/packages/domain/src/GroupList.ts +++ b/packages/domain/src/kinds/GroupList.ts @@ -1,30 +1,31 @@ -import {uniq} from "@welshman/lib" +import {uniq, nthEq} from "@welshman/lib" import {COMMUNITIES, getAddressTagValues} from "@welshman/util" -import {ListReader, ListBuilder} from "./List.js" +import {ListReader} from "../ListReader.js" +import {ListBuilder} from "../ListBuilder.js" // NIP-51 kind-10004 group (community) membership list. Entries are `a` tags // pointing at kind-34550 community definitions, merged across public tags and // decrypted private content. export class GroupList extends ListReader { - static kind = COMMUNITIES + readonly kind = COMMUNITIES addresses() { return uniq(getAddressTagValues(this.tags())) } builder() { - return this.seedList(new GroupListBuilder()) + return new GroupListBuilder(this) } } -export class GroupListBuilder extends ListBuilder { - static kind = COMMUNITIES +export class GroupListBuilder extends ListBuilder { + readonly kind = COMMUNITIES addGroup(address: string, relayHint?: string) { - return this.addPublicTags(["a", address, relayHint || ""]) + return this.addPublic(["a", address, relayHint || ""]) } removeGroup(address: string) { - return this.removeTagsWithValue(address) + return this.drop(nthEq(1, address)) } } diff --git a/packages/domain/src/Handler.ts b/packages/domain/src/kinds/Handler.ts similarity index 56% rename from packages/domain/src/Handler.ts rename to packages/domain/src/kinds/Handler.ts index 1d12ac4..ebf807c 100644 --- a/packages/domain/src/Handler.ts +++ b/packages/domain/src/kinds/Handler.ts @@ -1,6 +1,8 @@ -import {parseJson} from "@welshman/lib" +import {isPojo, parseJson} from "@welshman/lib" import {HANDLER_INFORMATION, getKindTagValues} from "@welshman/util" -import {EventReader, EventBuilder} from "./base.js" +import type {ISigner} from "@welshman/signer" +import {EventReader} from "../EventReader.js" +import {EventBuilder} from "../EventBuilder.js" // The parsed JSON metadata blob stored in a handler's content. Shaped like a // profile; readers map the various aliases (display_name/picture) down to a @@ -16,40 +18,41 @@ export type HandlerMeta = { // NIP-89 kind-31990 handler information. Addressable (has a `d` tag); content is a // JSON metadata blob like a profile, and the handled `kinds` are stored as `k` -// tags. `plain` is the parsed metadata object. -export class Handler extends EventReader { - static kind = HANDLER_INFORMATION +// tags. `values` is the parsed metadata object. +export class Handler extends EventReader { + readonly kind = HANDLER_INFORMATION + readonly values: HandlerMeta = {} - protected async parsePlain(): Promise { - return parseJson(this.event.content) || {} - } + protected async parse(signer?: ISigner) { + const json = parseJson(this.event.content) - protected reservedTagKeys() { - return ["k"] + if (isPojo(json)) { + Object.assign(this.values, json) + } } name() { - return this.plain.name || (this.plain as {display_name?: string}).display_name + return this.values.name || (this.values as {display_name?: string}).display_name } about() { - return this.plain.about + return this.values.about } image() { - return this.plain.image || (this.plain as {picture?: string}).picture + return this.values.image || (this.values as {picture?: string}).picture } website() { - return this.plain.website + return this.values.website } lud16() { - return this.plain.lud16 + return this.values.lud16 } nip05() { - return this.plain.nip05 + return this.values.nip05 } kinds() { @@ -57,22 +60,12 @@ export class Handler extends EventReader { } builder() { - const builder = new HandlerBuilder() - - builder.name = this.name() - builder.about = this.about() - builder.image = this.image() - builder.website = this.website() - builder.lud16 = this.lud16() - builder.nip05 = this.nip05() - builder.kinds = this.kinds() - - return this.seedBuilder(builder) + return new HandlerBuilder(this) } } -export class HandlerBuilder extends EventBuilder { - static kind = HANDLER_INFORMATION +export class HandlerBuilder extends EventBuilder { + readonly kind = HANDLER_INFORMATION name?: string about?: string @@ -82,6 +75,18 @@ export class HandlerBuilder extends EventBuilder { nip05?: string kinds: number[] = [] + constructor(readonly reader?: Handler) { + super(reader) + + this.name = reader?.name() + this.about = reader?.about() + this.image = reader?.image() + this.website = reader?.website() + this.lud16 = reader?.lud16() + this.nip05 = reader?.nip05() + this.kinds = this.consumeTags("k").map(t => Number(t[1])) + } + setName(name: string) { this.name = name diff --git a/packages/domain/src/HandlerRecommendation.ts b/packages/domain/src/kinds/HandlerRecommendation.ts similarity index 71% rename from packages/domain/src/HandlerRecommendation.ts rename to packages/domain/src/kinds/HandlerRecommendation.ts index dd0dc84..9723e1e 100644 --- a/packages/domain/src/HandlerRecommendation.ts +++ b/packages/domain/src/kinds/HandlerRecommendation.ts @@ -1,23 +1,14 @@ -import {last} from "@welshman/lib" +import {first, last} from "@welshman/lib" import {HANDLER_RECOMMENDATION, getAddressTags, getAddressTagValues} from "@welshman/util" -import {EventReader, EventBuilder} from "./base.js" +import {EventReader} from "../EventReader.js" +import {EventBuilder} from "../EventBuilder.js" // NIP-89 kind-31989 handler recommendation. Addressable (the `d` tag holds the // recommended kind), tags-only with empty content. Each entry is a raw `a` tag // pointing at a kind-31990 handler, optionally carrying a relay hint and a // trailing platform marker (e.g. "web"). export class HandlerRecommendation extends EventReader { - static kind = HANDLER_RECOMMENDATION - - protected validate() { - if (!this.identifier()) { - throw new Error("HandlerRecommendation requires a d tag") - } - } - - protected reservedTagKeys() { - return ["d", "a"] - } + readonly kind = HANDLER_RECOMMENDATION // Raw `a` tags: ["a", address, relay?, platform?]. addressTags() { @@ -38,22 +29,23 @@ export class HandlerRecommendation extends EventReader { } builder() { - const builder = new HandlerRecommendationBuilder(this.identifier() || "") - - builder.addressTags = this.addressTags() - - return this.seedBuilder(builder) + return new HandlerRecommendationBuilder(this) } } -export class HandlerRecommendationBuilder extends EventBuilder { - static kind = HANDLER_RECOMMENDATION +export class HandlerRecommendationBuilder extends EventBuilder { + readonly kind = HANDLER_RECOMMENDATION + + identifier = "" // Raw `a` tags: ["a", address, relay?, platform?]. addressTags: string[][] = [] - constructor(public identifier: string) { - super() + constructor(readonly reader?: HandlerRecommendation) { + super(reader) + + this.identifier = first(this.consumeTags("d"))?.[1] || "" + this.addressTags = this.consumeTags("a") } addRecommendation(address: string, relay?: string, platform?: string) { diff --git a/packages/domain/src/MessagingRelayList.ts b/packages/domain/src/kinds/MessagingRelayList.ts similarity index 56% rename from packages/domain/src/MessagingRelayList.ts rename to packages/domain/src/kinds/MessagingRelayList.ts index b66380e..7cb4052 100644 --- a/packages/domain/src/MessagingRelayList.ts +++ b/packages/domain/src/kinds/MessagingRelayList.ts @@ -1,6 +1,7 @@ -import {uniqBy} from "@welshman/lib" +import {uniqBy, nthEq} from "@welshman/lib" import {MESSAGING_RELAYS, getTagValues, normalizeRelayUrl} from "@welshman/util" -import {ListReader, ListBuilder} from "./List.js" +import {ListReader} from "../ListReader.js" +import {ListBuilder} from "../ListBuilder.js" // NIP-17 kind-10050 messaging/inbox relays. Entries are marker-less // ['relay', url] tags (NOT NIP-65 'r' tags with read/write markers, and the @@ -8,31 +9,31 @@ import {ListReader, ListBuilder} from "./List.js" // encrypted DM gift-wraps are sent and fetched, so it stays a flat, normalized // set. Identical structure to BlockedRelayList/SearchRelayList. export class MessagingRelayList extends ListReader { - static kind = MESSAGING_RELAYS + readonly kind = MESSAGING_RELAYS urls() { return uniqBy(normalizeRelayUrl, getTagValues("relay", this.tags())) } builder() { - return this.seedList(new MessagingRelayListBuilder()) + return new MessagingRelayListBuilder(this) } } -export class MessagingRelayListBuilder extends ListBuilder { - static kind = MESSAGING_RELAYS +export class MessagingRelayListBuilder extends ListBuilder { + readonly kind = MESSAGING_RELAYS addRelay(url: string) { - return this.addPublicTags(["relay", normalizeRelayUrl(url)]) + return this.addPublic(["relay", normalizeRelayUrl(url)]) } removeRelay(url: string) { - return this.removeTagsWithValue(normalizeRelayUrl(url)) + return this.drop(nthEq(1, normalizeRelayUrl(url))) } setRelays(urls: string[]) { - this.clearTags() + this.clear() - return this.addPublicTags(...urls.map(url => ["relay", normalizeRelayUrl(url)])) + return this.addPublic(...urls.map(url => ["relay", normalizeRelayUrl(url)])) } } diff --git a/packages/domain/src/MuteList.ts b/packages/domain/src/kinds/MuteList.ts similarity index 56% rename from packages/domain/src/MuteList.ts rename to packages/domain/src/kinds/MuteList.ts index 674dc9a..064f85e 100644 --- a/packages/domain/src/MuteList.ts +++ b/packages/domain/src/kinds/MuteList.ts @@ -1,11 +1,12 @@ -import {uniq} from "@welshman/lib" +import {uniq, nthEq} from "@welshman/lib" import {MUTES, getPubkeyTagValues} from "@welshman/util" -import {ListReader, ListBuilder} from "./List.js" +import {ListReader} from "../ListReader.js" +import {ListBuilder} from "../ListBuilder.js" // NIP-51 kind-10000 mute list. Pubkeys can be muted publicly (tags) or privately // (encrypted content); the reader treats both as one merged set. export class MuteList extends ListReader { - static kind = MUTES + readonly kind = MUTES pubkeys() { return uniq(getPubkeyTagValues(this.tags())) @@ -16,22 +17,22 @@ export class MuteList extends ListReader { } builder() { - return this.seedList(new MuteListBuilder()) + return new MuteListBuilder(this) } } -export class MuteListBuilder extends ListBuilder { - static kind = MUTES +export class MuteListBuilder extends ListBuilder { + readonly kind = MUTES mutePublicly(pubkey: string) { - return this.addPublicTags(["p", pubkey]) + return this.addPublic(["p", pubkey]) } mutePrivately(pubkey: string) { - return this.addPrivateTags(["p", pubkey]) + return this.addPrivate(["p", pubkey]) } unmute(pubkey: string) { - return this.removeTagsWithValue(pubkey) + return this.drop(nthEq(1, pubkey)) } } diff --git a/packages/domain/src/PinList.ts b/packages/domain/src/kinds/PinList.ts similarity index 69% rename from packages/domain/src/PinList.ts rename to packages/domain/src/kinds/PinList.ts index 1799962..909c7d4 100644 --- a/packages/domain/src/PinList.ts +++ b/packages/domain/src/kinds/PinList.ts @@ -1,13 +1,14 @@ -import {uniq} from "@welshman/lib" +import {uniq, nthEq} from "@welshman/lib" import {PINS, getEventTagValues, getAddressTagValues} from "@welshman/util" -import {ListReader, ListBuilder} from "./List.js" +import {ListReader} from "../ListReader.js" +import {ListBuilder} from "../ListBuilder.js" // NIP-51 kind-10001 pin list. Pinned items are heterogeneous tags (typically // 'e' events and optionally 'a' addresses), so they are exposed through // type-specific accessors rather than a single id-only set. Items can be pinned // publicly (tags) or privately (encrypted content); the reader merges both. export class PinList extends ListReader { - static kind = PINS + readonly kind = PINS ids() { return uniq(getEventTagValues(this.tags())) @@ -18,24 +19,24 @@ export class PinList extends ListReader { } builder() { - return this.seedList(new PinListBuilder()) + return new PinListBuilder(this) } } -export class PinListBuilder extends ListBuilder { - static kind = PINS +export class PinListBuilder extends ListBuilder { + readonly kind = PINS // Pin a full tag (e.g. ["e", id, ...] or ["a", address, ...]) publicly. pinPublicly(tag: string[]) { - return this.addPublicTags(tag) + return this.addPublic(tag) } // Pin a full tag (e.g. ["e", id, ...] or ["a", address, ...]) privately. pinPrivately(tag: string[]) { - return this.addPrivateTags(tag) + return this.addPrivate(tag) } unpin(value: string) { - return this.removeTagsWithValue(value) + return this.drop(nthEq(1, value)) } } diff --git a/packages/domain/src/Poll.ts b/packages/domain/src/kinds/Poll.ts similarity index 79% rename from packages/domain/src/Poll.ts rename to packages/domain/src/kinds/Poll.ts index 78a583e..5135e62 100644 --- a/packages/domain/src/Poll.ts +++ b/packages/domain/src/kinds/Poll.ts @@ -1,8 +1,9 @@ -import {now, uniq, randomId} from "@welshman/lib" +import {now, uniq, first, randomId} from "@welshman/lib" import {POLL, getTagValue, getTagValues} from "@welshman/util" import type {TrustedEvent} from "@welshman/util" import type {ISigner} from "@welshman/signer" -import {EventReader, EventBuilder} from "./base.js" +import {EventReader} from "../EventReader.js" +import {EventBuilder} from "../EventBuilder.js" export type PollType = "singlechoice" | "multiplechoice" @@ -20,17 +21,7 @@ export type PollResult = { // text (not JSON), options come from "option" tags, and the response tally is // computed from sibling kind-1018 response events passed into `results`. export class Poll extends EventReader { - static kind = POLL - - protected validate() { - if (this.options().length === 0) { - throw new Error("Poll requires at least one option tag") - } - } - - protected reservedTagKeys() { - return ["option", "polltype", "endsAt", "relay"] - } + readonly kind = POLL // The poll title/question is plain-text content. title() { @@ -97,27 +88,32 @@ export class Poll extends EventReader { } builder() { - const builder = new PollBuilder(this.title()) - - builder.options = this.options() - builder.pollType = this.pollType() - builder.endsAt = this.endsAt() - builder.relays = this.relays() - - return this.seedBuilder(builder) + return new PollBuilder(this) } } -export class PollBuilder extends EventBuilder { - static kind = POLL +export class PollBuilder extends EventBuilder { + readonly kind = POLL + title = "" options: PollOption[] = [] pollType: PollType = "singlechoice" endsAt?: number relays: string[] = [] - constructor(public title = "") { - super() + constructor(readonly reader?: Poll) { + super(reader) + + // Consume the represented tags out of the carried-over extraTags so they + // round-trip through the structured fields below rather than being emitted + // twice (once from buildTags, once from the base's extraTags pass-through). + this.title = reader?.title() ?? "" + this.options = this.consumeTags("option").map(t => ({id: t[1], label: t[2] || t[1]})) + this.pollType = (first(this.consumeTags("polltype"))?.[1] as PollType) || "singlechoice" + this.endsAt = reader?.endsAt() + this.relays = this.consumeTags("relay").map(t => t[1]) + + this.consumeTags("endsAt") } setTitle(title: string) { diff --git a/packages/domain/src/PollResponse.ts b/packages/domain/src/kinds/PollResponse.ts similarity index 56% rename from packages/domain/src/PollResponse.ts rename to packages/domain/src/kinds/PollResponse.ts index 99cbc3f..04aa3ab 100644 --- a/packages/domain/src/PollResponse.ts +++ b/packages/domain/src/kinds/PollResponse.ts @@ -1,22 +1,13 @@ -import {uniq} from "@welshman/lib" +import {uniq, first} from "@welshman/lib" import {POLL_RESPONSE, getTagValue, getTagValues} from "@welshman/util" -import {EventReader, EventBuilder} from "./base.js" +import {EventReader} from "../EventReader.js" +import {EventBuilder} from "../EventBuilder.js" // NIP-88 kind-1018 poll vote. Empty content; the target poll is referenced via // an "e" tag and each chosen option id lives in its own "response" tag. Tags-only // content, so it extends EventReader/EventBuilder directly. export class PollResponse extends EventReader { - static kind = POLL_RESPONSE - - protected validate() { - if (!this.pollId()) { - throw new Error("PollResponse requires an e tag") - } - } - - protected reservedTagKeys() { - return ["e", "response"] - } + readonly kind = POLL_RESPONSE pollId() { return getTagValue("e", this.event.tags) || "" @@ -27,21 +18,26 @@ export class PollResponse extends EventReader { } builder() { - const builder = new PollResponseBuilder() - - builder.pollId = this.pollId() - builder.selections = this.selections() - - return this.seedBuilder(builder) + return new PollResponseBuilder(this) } } -export class PollResponseBuilder extends EventBuilder { - static kind = POLL_RESPONSE +export class PollResponseBuilder extends EventBuilder { + readonly kind = POLL_RESPONSE pollId = "" selections: string[] = [] + constructor(readonly reader?: PollResponse) { + super(reader) + + // Consume the represented tags out of the carried-over extraTags so they + // round-trip through the structured fields below rather than being emitted + // twice (once from buildTags, once from the base's extraTags pass-through). + this.pollId = first(this.consumeTags("e"))?.[1] ?? "" + this.selections = uniq(this.consumeTags("response").map(t => t[1])) + } + setPollId(pollId: string) { this.pollId = pollId diff --git a/packages/domain/src/kinds/Profile.ts b/packages/domain/src/kinds/Profile.ts new file mode 100644 index 0000000..18b4848 --- /dev/null +++ b/packages/domain/src/kinds/Profile.ts @@ -0,0 +1,135 @@ +import {npubEncode} from "nostr-tools/nip19" +import {ellipsize, isPojo, parseJson} from "@welshman/lib" +import type {Maybe} from "@welshman/lib" +import {PROFILE, getLnUrl} from "@welshman/util" +import type {ISigner} from "@welshman/signer" +import {EventReader} from "../EventReader.js" +import {EventBuilder} from "../EventBuilder.js" + +export const parseLnUrl = (values: Record = {}): Maybe => { + for (const key of ["lud06", "lud16"] as const) { + if (typeof values[key] === "string") { + const lnurl = getLnUrl(values[key]!) + + if (lnurl) { + return lnurl + } + } + } +} + +export const displayPubkey = (pubkey: string) => { + const d = npubEncode(pubkey) + + return d.slice(0, 8) + "…" + d.slice(-5) +} + +// Read side for a NIP-01 kind-0 profile. The metadata lives in the JSON content, +// parsed once into `values` (with `lnurl` derived from lud06/lud16). Accessors +// read `this.values`; there are no represented tags. +export class Profile extends EventReader { + readonly kind = PROFILE + readonly values: Record = {} + + protected async parse(signer?: ISigner) { + const json = parseJson(this.event.content) + + if (isPojo(json)) { + Object.assign(this.values, json) + } + } + + name(): Maybe { + return this.values.name || this.values.display_name + } + + nip05(): Maybe { + return this.values.nip05 + } + + lnurl(): Maybe { + return parseLnUrl(this.values) + } + + about(): Maybe { + return this.values.about + } + + banner(): Maybe { + return this.values.banner + } + + picture(): Maybe { + return this.values.picture + } + + website(): Maybe { + return this.values.website + } + + display(fallback = "") { + const name = this.name() + + if (name) return ellipsize(name, 60).trim() + + return displayPubkey(this.event.pubkey).trim() || fallback.trim() + } + + builder() { + return new ProfileBuilder(this) + } +} + +export class ProfileBuilder extends EventBuilder { + readonly kind = PROFILE + values: Record + + constructor(readonly reader?: Profile) { + super(reader) + this.values = {...(reader?.values ?? {})} + } + + name(name: string) { + this.values.name = name + + return this + } + + nip05(nip05: string) { + this.values.nip05 = nip05 + + return this + } + + about(about: string) { + this.values.about = about + + return this + } + + banner(banner: string) { + this.values.banner = banner + + return this + } + + picture(picture: string) { + this.values.picture = picture + + return this + } + + website(website: string) { + this.values.website = website + + return this + } + + protected buildTags() { + return [] + } + + protected buildContent(_signer?: ISigner) { + return JSON.stringify(this.values) + } +} diff --git a/packages/domain/src/RelayInvite.ts b/packages/domain/src/kinds/RelayInvite.ts similarity index 62% rename from packages/domain/src/RelayInvite.ts rename to packages/domain/src/kinds/RelayInvite.ts index 9400c32..ca68b32 100644 --- a/packages/domain/src/RelayInvite.ts +++ b/packages/domain/src/kinds/RelayInvite.ts @@ -1,35 +1,37 @@ +import {first} from "@welshman/lib" import {RELAY_INVITE, getTagValue} from "@welshman/util" -import {EventReader, EventBuilder} from "./base.js" +import {EventReader} from "../EventReader.js" +import {EventBuilder} from "../EventBuilder.js" // NIP-29 kind-28935 ephemeral relay invite event. Its "claim" tag carries the // invite code, which flotilla turns into a /join?r=&c= link. Flotilla only reads // this event (see app/relays.ts requestRelayClaim), so `claim` is the sole field. // Tags-only content, so it extends EventReader/EventBuilder directly. export class RelayInvite extends EventReader { - static kind = RELAY_INVITE - - protected reservedTagKeys() { - return ["claim"] - } + readonly kind = RELAY_INVITE claim() { return getTagValue("claim", this.event.tags) } builder() { - const builder = new RelayInviteBuilder() - - builder.claim = this.claim() - - return this.seedBuilder(builder) + return new RelayInviteBuilder(this) } } -export class RelayInviteBuilder extends EventBuilder { - static kind = RELAY_INVITE +export class RelayInviteBuilder extends EventBuilder { + readonly kind = RELAY_INVITE claim?: string + constructor(readonly reader?: RelayInvite) { + super(reader) + + const claim = first(this.consumeTags("claim")) + + this.claim = claim?.[1] + } + setClaim(claim: string) { this.claim = claim diff --git a/packages/domain/src/RelayJoin.ts b/packages/domain/src/kinds/RelayJoin.ts similarity index 61% rename from packages/domain/src/RelayJoin.ts rename to packages/domain/src/kinds/RelayJoin.ts index e495366..19c39ae 100644 --- a/packages/domain/src/RelayJoin.ts +++ b/packages/domain/src/kinds/RelayJoin.ts @@ -1,49 +1,45 @@ +import {first} from "@welshman/lib" import {RELAY_JOIN, getTagValue} from "@welshman/util" import type {ISigner} from "@welshman/signer" -import {EventReader, EventBuilder} from "./base.js" +import {EventReader} from "../EventReader.js" +import {EventBuilder} from "../EventBuilder.js" // Ephemeral kind-28934 relay/space join request. Both written (the join flow) // and read (membership status): it carries an optional invite "claim" tag and a // free-text reason in the event content, driving the space membership state // machine (RELAY_JOIN -> Pending/Granted). The content is the plain free-text -// reason, so `plain` is the (possibly undefined) reason string. -export class RelayJoin extends EventReader { - static kind = RELAY_JOIN - - protected async parsePlain() { - return this.event.content || undefined - } - - protected reservedTagKeys() { - return ["claim"] - } +// reason. +export class RelayJoin extends EventReader { + readonly kind = RELAY_JOIN claim() { return getTagValue("claim", this.event.tags) } reason() { - return this.plain + return this.event.content || undefined } builder() { - const builder = new RelayJoinBuilder() - - builder.claim = this.claim() - builder.reason = this.reason() - - builder.plain = this.plain - - return this.seedBuilder(builder) + return new RelayJoinBuilder(this) } } -export class RelayJoinBuilder extends EventBuilder { - static kind = RELAY_JOIN +export class RelayJoinBuilder extends EventBuilder { + readonly kind = RELAY_JOIN claim?: string reason?: string + constructor(readonly reader?: RelayJoin) { + super(reader) + + const claim = first(this.consumeTags("claim")) + + this.claim = claim?.[1] + this.reason = reader?.event.content || undefined + } + setClaim(claim: string) { this.claim = claim diff --git a/packages/domain/src/RelayLeave.ts b/packages/domain/src/kinds/RelayLeave.ts similarity index 59% rename from packages/domain/src/RelayLeave.ts rename to packages/domain/src/kinds/RelayLeave.ts index 3a71d29..7b6306d 100644 --- a/packages/domain/src/RelayLeave.ts +++ b/packages/domain/src/kinds/RelayLeave.ts @@ -1,22 +1,19 @@ import {RELAY_LEAVE} from "@welshman/util" -import {EventReader, EventBuilder} from "./base.js" +import {EventReader} from "../EventReader.js" +import {EventBuilder} from "../EventBuilder.js" // Ephemeral kind-28936 relay/space leave marker, the counterpart to RelayJoin. // Carries no tags and no content; flotilla both emits it (the leave flow) and // consumes it to reset the space membership state machine (RELAY_LEAVE -> // Initial). Tags-only (in fact tag-free) content. export class RelayLeave extends EventReader { - static kind = RELAY_LEAVE + readonly kind = RELAY_LEAVE builder() { - return this.seedBuilder(new RelayLeaveBuilder()) + return new RelayLeaveBuilder(this) } } -export class RelayLeaveBuilder extends EventBuilder { - static kind = RELAY_LEAVE - - protected buildTags() { - return [] as string[][] - } +export class RelayLeaveBuilder extends EventBuilder { + readonly kind = RELAY_LEAVE } diff --git a/packages/domain/src/RelayList.ts b/packages/domain/src/kinds/RelayList.ts similarity index 95% rename from packages/domain/src/RelayList.ts rename to packages/domain/src/kinds/RelayList.ts index 4645ac8..d1acebd 100644 --- a/packages/domain/src/RelayList.ts +++ b/packages/domain/src/kinds/RelayList.ts @@ -1,13 +1,14 @@ import {uniq, uniqBy} from "@welshman/lib" import {RELAYS, RelayMode, getRelayTags, getRelayTagValues, normalizeRelayUrl} from "@welshman/util" -import {ListReader, ListBuilder} from "./List.js" +import {ListReader} from "../ListReader.js" +import {ListBuilder} from "../ListBuilder.js" // NIP-65 kind-10002 relay list (the outbox-model routing substrate). Entries are // `["r", url, mode?]` tags where `mode` is RelayMode.Read or RelayMode.Write; a // missing marker means the relay is used for both read and write. NIP-65 entries // are public in practice, so mutations target the public tag set. export class RelayList extends ListReader { - static kind = RELAYS + readonly kind = RELAYS // All relay urls, deduped by normalized url. urls() { @@ -35,12 +36,12 @@ export class RelayList extends ListReader { } builder() { - return this.seedList(new RelayListBuilder()) + return new RelayListBuilder(this) } } -export class RelayListBuilder extends ListBuilder { - static kind = RELAYS +export class RelayListBuilder extends ListBuilder { + readonly kind = RELAYS // Relays usable for reading: includes modeless (both) entries. readUrls() { diff --git a/packages/domain/src/RelayMembers.ts b/packages/domain/src/kinds/RelayMembers.ts similarity index 68% rename from packages/domain/src/RelayMembers.ts rename to packages/domain/src/kinds/RelayMembers.ts index 7b6a7d5..fa348c6 100644 --- a/packages/domain/src/RelayMembers.ts +++ b/packages/domain/src/kinds/RelayMembers.ts @@ -1,16 +1,13 @@ import {uniq} from "@welshman/lib" import {RELAY_MEMBERS, getPubkeyTagValues} from "@welshman/util" -import {EventReader, EventBuilder} from "./base.js" +import {EventReader} from "../EventReader.js" +import {EventBuilder} from "../EventBuilder.js" // Flotilla relay-wide (space) member-list snapshot, replaceable kind 13534. // Members are stored as "p" tags. Not addressable (no "d" tag); tags-only // content, so it extends EventReader/EventBuilder directly. export class RelayMembers extends EventReader { - static kind = RELAY_MEMBERS - - protected reservedTagKeys() { - return ["p"] - } + readonly kind = RELAY_MEMBERS pubkeys() { return uniq(getPubkeyTagValues(this.event.tags)) @@ -21,19 +18,21 @@ export class RelayMembers extends EventReader { } builder() { - const builder = new RelayMembersBuilder() - - builder.pubkeys = this.pubkeys() - - return this.seedBuilder(builder) + return new RelayMembersBuilder(this) } } -export class RelayMembersBuilder extends EventBuilder { - static kind = RELAY_MEMBERS +export class RelayMembersBuilder extends EventBuilder { + readonly kind = RELAY_MEMBERS pubkeys: string[] = [] + constructor(readonly reader?: RelayMembers) { + super(reader) + + this.pubkeys = uniq(this.consumeTags("p").map(t => t[1])) + } + addPubkey(pubkey: string) { this.pubkeys = uniq([...this.pubkeys, pubkey]) diff --git a/packages/domain/src/RelayMembershipOp.ts b/packages/domain/src/kinds/RelayMembershipOp.ts similarity index 76% rename from packages/domain/src/RelayMembershipOp.ts rename to packages/domain/src/kinds/RelayMembershipOp.ts index 40f814b..2a518b6 100644 --- a/packages/domain/src/RelayMembershipOp.ts +++ b/packages/domain/src/kinds/RelayMembershipOp.ts @@ -1,6 +1,7 @@ import {uniq} from "@welshman/lib" import {RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, getPubkeyTagValues} from "@welshman/util" -import {EventReader, EventBuilder} from "./base.js" +import {EventReader} from "../EventReader.js" +import {EventBuilder} from "../EventBuilder.js" // Relay/space-level moderation op for adding (kind 8000) or removing (kind 8001) // members. Regular (non-addressable) events carrying the affected pubkeys in "p" @@ -12,10 +13,6 @@ import {EventReader, EventBuilder} from "./base.js" // => isMember true, RelayRemoveMember => isMember false) when no RELAY_MEMBERS // snapshot is available. export abstract class RelayMembershipOp extends EventReader { - protected reservedTagKeys() { - return ["p"] - } - // The affected pubkeys, deduped. pubkeys() { return uniq(getPubkeyTagValues(this.event.tags)) @@ -25,9 +22,15 @@ export abstract class RelayMembershipOp extends EventReader { } // Shared write side: collect pubkeys, emit them as "p" tags. -export abstract class RelayMembershipOpBuilder extends EventBuilder { +export abstract class RelayMembershipOpBuilder extends EventBuilder { pubkeys: string[] = [] + constructor(readonly reader?: RelayMembershipOp) { + super(reader) + + this.pubkeys = uniq(this.consumeTags("p").map(t => t[1])) + } + addPubkey(pubkey: string) { this.pubkeys = uniq([...this.pubkeys, pubkey]) @@ -40,33 +43,25 @@ export abstract class RelayMembershipOpBuilder extends EventBuilder { } export class RelayAddMember extends RelayMembershipOp { - static kind = RELAY_ADD_MEMBER + readonly kind = RELAY_ADD_MEMBER builder() { - const builder = new RelayAddMemberBuilder() - - builder.pubkeys = this.pubkeys() - - return this.seedBuilder(builder) + return new RelayAddMemberBuilder(this) } } export class RelayAddMemberBuilder extends RelayMembershipOpBuilder { - static kind = RELAY_ADD_MEMBER + readonly kind = RELAY_ADD_MEMBER } export class RelayRemoveMember extends RelayMembershipOp { - static kind = RELAY_REMOVE_MEMBER + readonly kind = RELAY_REMOVE_MEMBER builder() { - const builder = new RelayRemoveMemberBuilder() - - builder.pubkeys = this.pubkeys() - - return this.seedBuilder(builder) + return new RelayRemoveMemberBuilder(this) } } export class RelayRemoveMemberBuilder extends RelayMembershipOpBuilder { - static kind = RELAY_REMOVE_MEMBER + readonly kind = RELAY_REMOVE_MEMBER } diff --git a/packages/domain/src/RelaySet.ts b/packages/domain/src/kinds/RelaySet.ts similarity index 58% rename from packages/domain/src/RelaySet.ts rename to packages/domain/src/kinds/RelaySet.ts index f9e9206..6978b43 100644 --- a/packages/domain/src/RelaySet.ts +++ b/packages/domain/src/kinds/RelaySet.ts @@ -1,6 +1,11 @@ import {randomId, uniqBy} from "@welshman/lib" import {NAMED_RELAYS, getTagValue, getTagValues, normalizeRelayUrl} from "@welshman/util" -import {ListReader, ListBuilder} from "./List.js" +import {ListReader} from "../ListReader.js" +import {ListBuilder} from "../ListBuilder.js" + +// Metadata tag keys re-emitted from the builder's dedicated fields; the d tag is +// the addressable identifier and is handled separately. +const META_TAG_KEYS = ["d", "title", "description", "image"] // NIP-51 kind-30002 relay set: an addressable, named collection of relays // identified by its `d` tag. Entries are marker-less ['relay', url] tags (like @@ -8,17 +13,7 @@ import {ListReader, ListBuilder} from "./List.js" // It also carries optional set metadata (title/description/image) used to label // the set in UIs. export class RelaySet extends ListReader { - static kind = NAMED_RELAYS - - protected validate() { - if (!this.identifier()) { - throw new Error("RelaySet requires a d tag") - } - } - - protected reservedTagKeys() { - return ["d", "title", "description", "image", "relay"] - } + readonly kind = NAMED_RELAYS title() { return getTagValue("title", this.event.tags) @@ -37,34 +32,31 @@ export class RelaySet extends ListReader { } builder() { - const builder = new RelaySetBuilder() - - builder.identifier = this.identifier() || "" - builder.title = this.title() - builder.description = this.description() - builder.image = this.image() - - this.seedList(builder) - - // The d/title/description/image tags are re-emitted from the dedicated - // fields above, so drop them from the carried-over public entries to avoid - // duplication. The marker-less relay entries stay as public list tags. - builder.publicTags = builder.publicTags.filter( - t => !["d", "title", "description", "image"].includes(t[0]), - ) - - return builder + return new RelaySetBuilder(this) } } -export class RelaySetBuilder extends ListBuilder { - static kind = NAMED_RELAYS +export class RelaySetBuilder extends ListBuilder { + readonly kind = NAMED_RELAYS identifier = randomId() title?: string description?: string image?: string + constructor(readonly reader?: RelaySet) { + super(reader) + + // The list base splices every non-behavior tag into publicTags. Pull the + // d/title/description/image metadata into dedicated fields and drop them from + // publicTags so the marker-less relay entries are all that remain there. + this.identifier = getTagValue("d", this.publicTags) || randomId() + this.title = getTagValue("title", this.publicTags) + this.description = getTagValue("description", this.publicTags) + this.image = getTagValue("image", this.publicTags) + this.publicTags = this.publicTags.filter(t => !META_TAG_KEYS.includes(t[0])) + } + setIdentifier(identifier: string) { this.identifier = identifier @@ -90,18 +82,18 @@ export class RelaySetBuilder extends ListBuilder { } addRelay(url: string) { - return this.addPublicTags(["relay", normalizeRelayUrl(url)]) + return this.addPublic(["relay", normalizeRelayUrl(url)]) } removeRelay(url: string) { - return this.removeTagsWithValue(url) + return this.drop(t => t[1] === url) } setRelays(urls: string[]) { - // Replace only the relay entries; preserve the set's d/title/description/image metadata. - this.removeTagsWithKey("relay") + // Replace only the relay entries; preserve the set's metadata fields. + this.dropPublic(t => t[0] === "relay") - return this.addPublicTags(...urls.map(url => ["relay", normalizeRelayUrl(url)])) + return this.addPublic(...urls.map(url => ["relay", normalizeRelayUrl(url)])) } protected validate() { diff --git a/packages/domain/src/Report.ts b/packages/domain/src/kinds/Report.ts similarity index 68% rename from packages/domain/src/Report.ts rename to packages/domain/src/kinds/Report.ts index bca540f..572ce33 100644 --- a/packages/domain/src/Report.ts +++ b/packages/domain/src/kinds/Report.ts @@ -1,5 +1,7 @@ +import {first} from "@welshman/lib" import {REPORT, getTag, getTagValue} from "@welshman/util" -import {EventReader, EventBuilder} from "./base.js" +import {EventReader} from "../EventReader.js" +import {EventBuilder} from "../EventBuilder.js" // NIP-56 kind-1984 report, feeding flotilla's admin action-items / moderation // review queue (see app/actionItems.ts `deriveSpaceActionItems`). The reported @@ -7,13 +9,9 @@ import {EventReader, EventBuilder} from "./base.js" // report reason carried as the 3rd element of the "e" tag (NOT a separate tag). // Flotilla destructures this by hand in ReactionSummary.svelte and // ReportMenu.svelte; the accessors centralize that access. The report body lives -// in `content` as plain text (not JSON), so there's no `plain` representation. +// in `content` as plain text (not JSON). export class Report extends EventReader { - static kind = REPORT - - protected reservedTagKeys() { - return ["p", "e"] - } + readonly kind = REPORT // The reported author. Distinct from the base `pubkey()` (the reporter). reportedPubkey() { @@ -36,25 +34,33 @@ export class Report extends EventReader { } builder() { - const builder = new ReportBuilder() - - builder.reportedPubkey = this.reportedPubkey() - builder.eventId = this.eventId() - builder.reason = this.reason() - builder.content = this.content() - - return this.seedBuilder(builder) + return new ReportBuilder(this) } } -export class ReportBuilder extends EventBuilder { - static kind = REPORT +export class ReportBuilder extends EventBuilder { + readonly kind = REPORT reportedPubkey?: string eventId?: string reason?: string content = "" + constructor(readonly reader?: Report) { + super(reader) + + // Consume the represented tags out of the carried-over extraTags so they + // round-trip through the structured fields below rather than being emitted + // twice (once from buildTags, once from the base's extraTags pass-through). + const p = first(this.consumeTags("p")) + const e = first(this.consumeTags("e")) + + this.reportedPubkey = p?.[1] + this.eventId = e?.[1] + this.reason = e?.[2] + this.content = reader?.event.content ?? "" + } + setReportedPubkey(reportedPubkey: string) { this.reportedPubkey = reportedPubkey diff --git a/packages/domain/src/RoomAdmins.ts b/packages/domain/src/kinds/RoomAdmins.ts similarity index 55% rename from packages/domain/src/RoomAdmins.ts rename to packages/domain/src/kinds/RoomAdmins.ts index 32dd7de..55661ea 100644 --- a/packages/domain/src/RoomAdmins.ts +++ b/packages/domain/src/kinds/RoomAdmins.ts @@ -1,21 +1,12 @@ -import {randomId, uniq} from "@welshman/lib" +import {first, randomId, uniq} from "@welshman/lib" import {ROOM_ADMINS, getPubkeyTagValues} from "@welshman/util" -import {EventReader, EventBuilder} from "./base.js" +import {EventReader} from "../EventReader.js" +import {EventBuilder} from "../EventBuilder.js" // NIP-29 kind-39001 relay-generated room admin list. Addressable, with the group // id ("h") stored in the "d" tag and admins as "p" tags. Tags-only content. export class RoomAdmins extends EventReader { - static kind = ROOM_ADMINS - - protected validate() { - if (!this.identifier()) { - throw new Error("RoomAdmins requires a d tag") - } - } - - protected reservedTagKeys() { - return ["d", "p"] - } + readonly kind = ROOM_ADMINS // The group id is the addressable identifier (the "d" tag). h() { @@ -27,21 +18,28 @@ export class RoomAdmins extends EventReader { } builder() { - const builder = new RoomAdminsBuilder() - - builder.h = this.identifier() || "" - builder.pubkeys = this.pubkeys() - - return this.seedBuilder(builder) + return new RoomAdminsBuilder(this) } } -export class RoomAdminsBuilder extends EventBuilder { - static kind = ROOM_ADMINS +export class RoomAdminsBuilder extends EventBuilder { + readonly kind = ROOM_ADMINS h = randomId() pubkeys: string[] = [] + constructor(readonly reader?: RoomAdmins) { + super(reader) + + // Consume the represented tags out of the carried-over extraTags so they + // round-trip through the structured fields below rather than being emitted + // twice (once from buildTags, once from the base's extraTags pass-through). + const d = first(this.consumeTags("d")) + + this.h = d?.[1] || randomId() + this.pubkeys = uniq(this.consumeTags("p").map(t => t[1])) + } + setH(h: string) { this.h = h diff --git a/packages/domain/src/kinds/RoomCreate.ts b/packages/domain/src/kinds/RoomCreate.ts new file mode 100644 index 0000000..c76f693 --- /dev/null +++ b/packages/domain/src/kinds/RoomCreate.ts @@ -0,0 +1,20 @@ +import {ROOM_CREATE} from "@welshman/util" +import {EventReader} from "../EventReader.js" +import {EventBuilder} from "../EventBuilder.js" + +// NIP-29 kind-9007 create-room action op. A regular (write-primarily) event +// carrying only the target group id ("h") tag. The "h" tag is a base behavior +// tag, so the reader exposes it via the inherited group() accessor and the +// builder sets it via the inherited group() setter — there are no kind-specific +// represented tags, so nothing else is needed here. +export class RoomCreate extends EventReader { + readonly kind = ROOM_CREATE + + builder() { + return new RoomCreateBuilder(this) + } +} + +export class RoomCreateBuilder extends EventBuilder { + readonly kind = ROOM_CREATE +} diff --git a/packages/domain/src/RoomCreatePermission.ts b/packages/domain/src/kinds/RoomCreatePermission.ts similarity index 51% rename from packages/domain/src/RoomCreatePermission.ts rename to packages/domain/src/kinds/RoomCreatePermission.ts index 16652ee..6d83615 100644 --- a/packages/domain/src/RoomCreatePermission.ts +++ b/packages/domain/src/kinds/RoomCreatePermission.ts @@ -1,16 +1,13 @@ import {uniq} from "@welshman/lib" import {ROOM_CREATE_PERMISSION, getPubkeyTagValues} from "@welshman/util" -import {EventReader, EventBuilder} from "./base.js" +import {EventReader} from "../EventReader.js" +import {EventBuilder} from "../EventBuilder.js" // Flotilla/NIP-29 extension: relay-authored grant of room-creation permission // (kind 19004). The "p" tags list the pubkeys allowed to create rooms. Read-only // in practice. Tags-only content. export class RoomCreatePermission extends EventReader { - static kind = ROOM_CREATE_PERMISSION - - protected reservedTagKeys() { - return ["p"] - } + readonly kind = ROOM_CREATE_PERMISSION pubkeys() { return uniq(getPubkeyTagValues(this.event.tags)) @@ -21,19 +18,24 @@ export class RoomCreatePermission extends EventReader { } builder() { - const builder = new RoomCreatePermissionBuilder() - - builder.pubkeys = this.pubkeys() - - return this.seedBuilder(builder) + return new RoomCreatePermissionBuilder(this) } } -export class RoomCreatePermissionBuilder extends EventBuilder { - static kind = ROOM_CREATE_PERMISSION +export class RoomCreatePermissionBuilder extends EventBuilder { + readonly kind = ROOM_CREATE_PERMISSION pubkeys: string[] = [] + constructor(readonly reader?: RoomCreatePermission) { + super(reader) + + // Consume the represented "p" tags out of the carried-over extraTags so they + // round-trip through the structured field below rather than being emitted + // twice (once from buildTags, once from the base's extraTags pass-through). + this.pubkeys = uniq(this.consumeTags("p").map(t => t[1])) + } + setPubkeys(pubkeys: string[]) { this.pubkeys = pubkeys diff --git a/packages/domain/src/RoomDelete.ts b/packages/domain/src/kinds/RoomDelete.ts similarity index 60% rename from packages/domain/src/RoomDelete.ts rename to packages/domain/src/kinds/RoomDelete.ts index 9eb5c94..0e9169a 100644 --- a/packages/domain/src/RoomDelete.ts +++ b/packages/domain/src/kinds/RoomDelete.ts @@ -1,5 +1,6 @@ import {ROOM_DELETE, getTagValues} from "@welshman/util" -import {EventReader, EventBuilder} from "./base.js" +import {EventReader} from "../EventReader.js" +import {EventBuilder} from "../EventBuilder.js" // NIP-29 kind-9008 delete-room/tombstone op. A regular event that may carry // MULTIPLE group id ("h") tags, allowing a single delete event to tombstone @@ -7,20 +8,10 @@ import {EventReader, EventBuilder} from "./base.js" // // Note: unlike most kinds, "h" here is a repeatable identity tag (the rooms to // delete), not the base's single behavior group. So we handle "h" explicitly — -// hs() reads them all, the builder emits one tag per id, and "h" is reserved — -// and we do NOT use the base group accessor/setter. +// hs() reads them all, the builder emits one tag per id, and the base group is +// cleared — and we do NOT use the base group accessor/setter. export class RoomDelete extends EventReader { - static kind = ROOM_DELETE - - protected validate() { - if (this.hs().length === 0) { - throw new Error("RoomDelete requires at least one h tag") - } - } - - protected reservedTagKeys() { - return ["h"] - } + readonly kind = ROOM_DELETE // All group ids tombstoned by this event. hs() { @@ -33,26 +24,26 @@ export class RoomDelete extends EventReader { } builder() { - const builder = new RoomDeleteBuilder() - - builder.hs = this.hs() - - this.seedBuilder(builder) - - // "h" here is a repeatable room-id tag emitted by buildTags(), not the base's - // single behavior group. seedBuilder copies the first "h" into builder.group, - // which would make the base emit a duplicate "h" tag — so clear it. - builder.group = undefined - - return builder + return new RoomDeleteBuilder(this) } } -export class RoomDeleteBuilder extends EventBuilder { - static kind = ROOM_DELETE +export class RoomDeleteBuilder extends EventBuilder { + readonly kind = ROOM_DELETE hs: string[] = [] + constructor(readonly reader?: RoomDelete) { + super(reader) + + // "h" here is a repeatable room-id tag emitted by buildTags(), not the base's + // single behavior group. The base's constructor already consumed EVERY "h" out + // of extraTags but kept only the first as groupTag, so recover the full set + // from the reader and clear groupTag so the base doesn't emit a duplicate "h". + this.hs = reader?.hs() ?? [] + this.groupTag = undefined + } + addRoom(h: string) { if (!this.hs.includes(h)) { this.hs.push(h) diff --git a/packages/domain/src/RoomJoin.ts b/packages/domain/src/kinds/RoomJoin.ts similarity index 62% rename from packages/domain/src/RoomJoin.ts rename to packages/domain/src/kinds/RoomJoin.ts index b5d2698..f8d7e85 100644 --- a/packages/domain/src/RoomJoin.ts +++ b/packages/domain/src/kinds/RoomJoin.ts @@ -1,6 +1,8 @@ +import {first} from "@welshman/lib" import {ROOM_JOIN, getTagValue} from "@welshman/util" import type {ISigner} from "@welshman/signer" -import {EventReader, EventBuilder} from "./base.js" +import {EventReader} from "../EventReader.js" +import {EventBuilder} from "../EventBuilder.js" // NIP-29 kind-9021 room join request. A regular (read-and-written) event // carrying the target group id ("h", handled by the base group accessor), an @@ -8,17 +10,7 @@ import {EventReader, EventBuilder} from "./base.js" // event content. Drives the membership state machine (ROOM_JOIN -> // Pending/Granted) and the pending-join admin queue, grouped by h + pubkey. export class RoomJoin extends EventReader { - static kind = ROOM_JOIN - - protected validate() { - if (!this.group()) { - throw new Error("RoomJoin requires an h tag") - } - } - - protected reservedTagKeys() { - return ["claim"] - } + readonly kind = ROOM_JOIN // The invite "claim" tag. code() { @@ -31,21 +23,28 @@ export class RoomJoin extends EventReader { } builder() { - const builder = new RoomJoinBuilder() - - builder.code = this.code() - builder.reason = this.reason() - - return this.seedBuilder(builder) + return new RoomJoinBuilder(this) } } -export class RoomJoinBuilder extends EventBuilder { - static kind = ROOM_JOIN +export class RoomJoinBuilder extends EventBuilder { + readonly kind = ROOM_JOIN code?: string reason?: string + constructor(readonly reader?: RoomJoin) { + super(reader) + + // Consume the represented "claim" tag out of the carried-over extraTags so it + // round-trips through the structured field below rather than being emitted + // twice (once from buildTags, once from the base's extraTags pass-through). + const claim = first(this.consumeTags("claim")) + + this.code = claim?.[1] + this.reason = reader?.event.content || undefined + } + setCode(code: string) { this.code = code @@ -59,7 +58,7 @@ export class RoomJoinBuilder extends EventBuilder { } protected validate() { - if (!this.group) { + if (!this.groupTag) { throw new Error("RoomJoin requires an h/group") } } diff --git a/packages/domain/src/RoomLeave.ts b/packages/domain/src/kinds/RoomLeave.ts similarity index 59% rename from packages/domain/src/RoomLeave.ts rename to packages/domain/src/kinds/RoomLeave.ts index 614f029..579c8b8 100644 --- a/packages/domain/src/RoomLeave.ts +++ b/packages/domain/src/kinds/RoomLeave.ts @@ -1,18 +1,13 @@ import {ROOM_LEAVE} from "@welshman/util" -import {EventReader, EventBuilder} from "./base.js" +import {EventReader} from "../EventReader.js" +import {EventBuilder} from "../EventBuilder.js" // NIP-29 kind-9022 room leave op, the counterpart to RoomJoin. A regular event // carrying the target group id ("h") tag, which resets the membership state // machine (ROOM_LEAVE -> Initial). The only represented tag is the group ("h"), // which the base owns as a behavior tag, so buildTags is empty. Tags-only content. export class RoomLeave extends EventReader { - static kind = ROOM_LEAVE - - protected validate() { - if (!this.group()) { - throw new Error("RoomLeave requires an h tag") - } - } + readonly kind = ROOM_LEAVE // The group id ("h") is read via the base group() accessor. h() { @@ -20,22 +15,16 @@ export class RoomLeave extends EventReader { } builder() { - const builder = new RoomLeaveBuilder() - - return this.seedBuilder(builder) + return new RoomLeaveBuilder(this) } } -export class RoomLeaveBuilder extends EventBuilder { - static kind = ROOM_LEAVE +export class RoomLeaveBuilder extends EventBuilder { + readonly kind = ROOM_LEAVE protected validate() { - if (!this.group) { + if (!this.groupTag) { throw new Error("RoomLeave requires an h identifier") } } - - protected buildTags() { - return [] - } } diff --git a/packages/domain/src/RoomList.ts b/packages/domain/src/kinds/RoomList.ts similarity index 60% rename from packages/domain/src/RoomList.ts rename to packages/domain/src/kinds/RoomList.ts index 66077a2..aa72804 100644 --- a/packages/domain/src/RoomList.ts +++ b/packages/domain/src/kinds/RoomList.ts @@ -1,11 +1,13 @@ +import {nthEq} from "@welshman/lib" import {ROOMS, getGroupTags, getGroupTagValues} from "@welshman/util" -import {ListReader, ListBuilder} from "./List.js" +import {ListReader} from "../ListReader.js" +import {ListBuilder} from "../ListBuilder.js" // NIP-51 / NIP-29 kind-10009 simple-groups membership list. Each entry is a // group tag `["group", groupId, relayUrl]` (legacy `"h"` is also accepted on // read). Distinct from the NIP-29 room management events, which are not lists. export class RoomList extends ListReader { - static kind = ROOMS + readonly kind = ROOMS groups() { return getGroupTagValues(this.tags()) @@ -16,18 +18,18 @@ export class RoomList extends ListReader { } builder() { - return this.seedList(new RoomListBuilder()) + return new RoomListBuilder(this) } } -export class RoomListBuilder extends ListBuilder { - static kind = ROOMS +export class RoomListBuilder extends ListBuilder { + readonly kind = ROOMS join(groupId: string, relayUrl: string) { - return this.addPublicTags(["group", groupId, relayUrl]) + return this.addPublic(["group", groupId, relayUrl]) } leave(groupId: string) { - return this.removeTagsWithValue(groupId) + return this.drop(nthEq(1, groupId)) } } diff --git a/packages/domain/src/RoomMembers.ts b/packages/domain/src/kinds/RoomMembers.ts similarity index 58% rename from packages/domain/src/RoomMembers.ts rename to packages/domain/src/kinds/RoomMembers.ts index b296da1..e5dac39 100644 --- a/packages/domain/src/RoomMembers.ts +++ b/packages/domain/src/kinds/RoomMembers.ts @@ -1,22 +1,13 @@ -import {uniq} from "@welshman/lib" +import {first, uniq} from "@welshman/lib" import {ROOM_MEMBERS, getPubkeyTagValues} from "@welshman/util" -import {EventReader, EventBuilder} from "./base.js" +import {EventReader} from "../EventReader.js" +import {EventBuilder} from "../EventBuilder.js" // NIP-29 kind-39002 relay-authored room member-list snapshot. Addressable, with // the group id ("h") stored in the "d" tag and members listed as "p" tags. // Tags-only content. export class RoomMembers extends EventReader { - static kind = ROOM_MEMBERS - - protected validate() { - if (!this.identifier()) { - throw new Error("RoomMembers requires a d tag") - } - } - - protected reservedTagKeys() { - return ["d", "p"] - } + readonly kind = ROOM_MEMBERS // The group id is the addressable identifier (the "d" tag). h() { @@ -32,21 +23,28 @@ export class RoomMembers extends EventReader { } builder() { - const builder = new RoomMembersBuilder() - - builder.h = this.identifier() || "" - builder.members = this.members() - - return this.seedBuilder(builder) + return new RoomMembersBuilder(this) } } -export class RoomMembersBuilder extends EventBuilder { - static kind = ROOM_MEMBERS +export class RoomMembersBuilder extends EventBuilder { + readonly kind = ROOM_MEMBERS h = "" members: string[] = [] + constructor(readonly reader?: RoomMembers) { + super(reader) + + // Consume the represented tags out of the carried-over extraTags so they + // round-trip through the structured fields below rather than being emitted + // twice (once from buildTags, once from the base's extraTags pass-through). + const d = first(this.consumeTags("d")) + + this.h = d?.[1] || "" + this.members = uniq(this.consumeTags("p").map(t => t[1])) + } + addMember(pubkey: string) { this.members = uniq([...this.members, pubkey]) diff --git a/packages/domain/src/RoomMembershipOp.ts b/packages/domain/src/kinds/RoomMembershipOp.ts similarity index 67% rename from packages/domain/src/RoomMembershipOp.ts rename to packages/domain/src/kinds/RoomMembershipOp.ts index 5d23129..6c2476f 100644 --- a/packages/domain/src/RoomMembershipOp.ts +++ b/packages/domain/src/kinds/RoomMembershipOp.ts @@ -1,6 +1,7 @@ import {uniq} from "@welshman/lib" import {ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER, getPubkeyTagValues} from "@welshman/util" -import {EventReader, EventBuilder} from "./base.js" +import {EventReader} from "../EventReader.js" +import {EventBuilder} from "../EventBuilder.js" // NIP-29 moderation op for adding (kind 9000) or removing (kind 9001) room // members. Regular (non-addressable) events carrying the affected pubkeys in "p" @@ -11,10 +12,6 @@ import {EventReader, EventBuilder} from "./base.js" // Flotilla's membership replay treats RoomAddMember => member, RoomRemoveMember // => not a member. export abstract class RoomMembershipOp extends EventReader { - protected reservedTagKeys() { - return ["p"] - } - // The affected pubkeys, deduped. pubkeys() { return uniq(getPubkeyTagValues(this.event.tags)) @@ -25,9 +22,18 @@ export abstract class RoomMembershipOp extends EventReader { // Shared write side: collect pubkeys, emit them as "p" tags. The target group id // ("h") is set via the base group behavior tag. -export abstract class RoomMembershipOpBuilder extends EventBuilder { +export abstract class RoomMembershipOpBuilder extends EventBuilder { pubkeys: string[] = [] + constructor(readonly reader?: RoomMembershipOp) { + super(reader) + + // Consume the represented "p" tags out of the carried-over extraTags so they + // round-trip through the structured field below rather than being emitted + // twice (once from buildTags, once from the base's extraTags pass-through). + this.pubkeys = uniq(this.consumeTags("p").map(t => t[1])) + } + addPubkey(pubkey: string) { this.pubkeys = uniq([...this.pubkeys, pubkey]) @@ -40,33 +46,25 @@ export abstract class RoomMembershipOpBuilder extends EventBuilder { } export class RoomAddMember extends RoomMembershipOp { - static kind = ROOM_ADD_MEMBER + readonly kind = ROOM_ADD_MEMBER builder() { - const builder = new RoomAddMemberBuilder() - - builder.pubkeys = this.pubkeys() - - return this.seedBuilder(builder) + return new RoomAddMemberBuilder(this) } } export class RoomAddMemberBuilder extends RoomMembershipOpBuilder { - static kind = ROOM_ADD_MEMBER + readonly kind = ROOM_ADD_MEMBER } export class RoomRemoveMember extends RoomMembershipOp { - static kind = ROOM_REMOVE_MEMBER + readonly kind = ROOM_REMOVE_MEMBER builder() { - const builder = new RoomRemoveMemberBuilder() - - builder.pubkeys = this.pubkeys() - - return this.seedBuilder(builder) + return new RoomRemoveMemberBuilder(this) } } export class RoomRemoveMemberBuilder extends RoomMembershipOpBuilder { - static kind = ROOM_REMOVE_MEMBER + readonly kind = ROOM_REMOVE_MEMBER } diff --git a/packages/domain/src/RoomMeta.ts b/packages/domain/src/kinds/RoomMeta.ts similarity index 63% rename from packages/domain/src/RoomMeta.ts rename to packages/domain/src/kinds/RoomMeta.ts index a4d8a1b..e5092dc 100644 --- a/packages/domain/src/RoomMeta.ts +++ b/packages/domain/src/kinds/RoomMeta.ts @@ -1,21 +1,12 @@ -import {randomId} from "@welshman/lib" +import {first, randomId} from "@welshman/lib" import {ROOM_META, getTag, getTagValue} from "@welshman/util" -import {EventReader, EventBuilder} from "./base.js" +import {EventReader} from "../EventReader.js" +import {EventBuilder} from "../EventBuilder.js" // NIP-29 kind-39000 relay-generated group metadata. Addressable, with the group // id ("h") stored in the "d" tag. Tags-only content. export class RoomMeta extends EventReader { - static kind = ROOM_META - - protected validate() { - if (!this.identifier()) { - throw new Error("RoomMeta requires a d tag") - } - } - - protected reservedTagKeys() { - return ["d", "name", "about", "picture", "closed", "hidden", "private", "restricted", "livekit"] - } + readonly kind = ROOM_META // The group id is the addressable identifier (the "d" tag). h() { @@ -61,25 +52,12 @@ export class RoomMeta extends EventReader { } builder() { - const builder = new RoomMetaBuilder() - - builder.h = this.identifier() || "" - builder.name = this.name() - builder.about = this.about() - builder.picture = this.picture() - builder.pictureMeta = this.pictureMeta() - builder.closed = this.isClosed() - builder.hidden = this.isHidden() - builder.isPrivate = this.isPrivate() - builder.restricted = this.isRestricted() - builder.livekit = this.livekit() - - return this.seedBuilder(builder) + return new RoomMetaBuilder(this) } } -export class RoomMetaBuilder extends EventBuilder { - static kind = ROOM_META +export class RoomMetaBuilder extends EventBuilder { + readonly kind = ROOM_META h = randomId() name?: string @@ -92,6 +70,27 @@ export class RoomMetaBuilder extends EventBuilder { restricted = false livekit = false + constructor(readonly reader?: RoomMeta) { + super(reader) + + // Consume the represented tags out of the carried-over extraTags so they + // round-trip through the structured fields below rather than being emitted + // twice (once from buildTags, once from the base's extraTags pass-through). + const d = first(this.consumeTags("d")) + const picture = first(this.consumeTags("picture")) + + this.h = d?.[1] || randomId() + this.name = first(this.consumeTags("name"))?.[1] + this.about = first(this.consumeTags("about"))?.[1] + this.picture = picture?.[1] + this.pictureMeta = picture ? picture.slice(2) : undefined + this.closed = this.consumeTags("closed").length > 0 + this.hidden = this.consumeTags("hidden").length > 0 + this.isPrivate = this.consumeTags("private").length > 0 + this.restricted = this.consumeTags("restricted").length > 0 + this.livekit = this.consumeTags("livekit").length > 0 + } + setName(name: string) { this.name = name diff --git a/packages/domain/src/SearchRelayList.ts b/packages/domain/src/kinds/SearchRelayList.ts similarity index 55% rename from packages/domain/src/SearchRelayList.ts rename to packages/domain/src/kinds/SearchRelayList.ts index 93541fa..bd5f836 100644 --- a/packages/domain/src/SearchRelayList.ts +++ b/packages/domain/src/kinds/SearchRelayList.ts @@ -1,12 +1,13 @@ -import {uniqBy} from "@welshman/lib" +import {uniqBy, nthEq} from "@welshman/lib" import {SEARCH_RELAYS, getTagValues, normalizeRelayUrl} from "@welshman/util" -import {ListReader, ListBuilder} from "./List.js" +import {ListReader} from "../ListReader.js" +import {ListBuilder} from "../ListBuilder.js" // NIP-51 kind-10007 search relays (NIP-50). Entries are marker-less // ['relay', url] tags (NOT NIP-65 'r' tags with read/write markers). Identical // structure to BlockedRelayList; `urls()` stays a flat, normalized set. export class SearchRelayList extends ListReader { - static kind = SEARCH_RELAYS + readonly kind = SEARCH_RELAYS urls() { return uniqBy(normalizeRelayUrl, getTagValues("relay", this.tags())) @@ -17,24 +18,24 @@ export class SearchRelayList extends ListReader { } builder() { - return this.seedList(new SearchRelayListBuilder()) + return new SearchRelayListBuilder(this) } } -export class SearchRelayListBuilder extends ListBuilder { - static kind = SEARCH_RELAYS +export class SearchRelayListBuilder extends ListBuilder { + readonly kind = SEARCH_RELAYS addRelay(url: string) { - return this.addPublicTags(["relay", normalizeRelayUrl(url)]) + return this.addPublic(["relay", normalizeRelayUrl(url)]) } removeRelay(url: string) { - return this.removeTagsWithValue(normalizeRelayUrl(url)) + return this.drop(nthEq(1, normalizeRelayUrl(url))) } setRelays(urls: string[]) { - this.clearTags() + this.clear() - return this.addPublicTags(...urls.map(url => ["relay", normalizeRelayUrl(url)])) + return this.addPublic(...urls.map(url => ["relay", normalizeRelayUrl(url)])) } } diff --git a/packages/domain/src/Thread.ts b/packages/domain/src/kinds/Thread.ts similarity index 58% rename from packages/domain/src/Thread.ts rename to packages/domain/src/kinds/Thread.ts index 3a863cb..ba3a3e2 100644 --- a/packages/domain/src/Thread.ts +++ b/packages/domain/src/kinds/Thread.ts @@ -1,18 +1,16 @@ +import {first} from "@welshman/lib" import {THREAD, getTagValue} from "@welshman/util" -import {EventReader, EventBuilder} from "./base.js" +import {EventReader} from "../EventReader.js" +import {EventBuilder} from "../EventBuilder.js" // NIP-7D kind-11 forum thread root. The body lives in `content` as plain text // (not JSON) and the title is carried in a "title" tag; room scoping is handled // by the base `group` behavior tag. Non-addressable (referenced by event id); // replies are COMMENT (kind 1111) via "#E". Flotilla also appends editor/inline // tags at call sites; those round-trip via the base `extraTags` (with "title" -// declared reserved so it isn't double-counted). +// consumed by the builder so it isn't double-counted). export class Thread extends EventReader { - static kind = THREAD - - protected reservedTagKeys() { - return ["title"] - } + readonly kind = THREAD title() { return getTagValue("title", this.event.tags) @@ -23,21 +21,26 @@ export class Thread extends EventReader { } builder() { - const builder = new ThreadBuilder() - - builder.title = this.title() - builder.content = this.content() - - return this.seedBuilder(builder) + return new ThreadBuilder(this) } } -export class ThreadBuilder extends EventBuilder { - static kind = THREAD +export class ThreadBuilder extends EventBuilder { + readonly kind = THREAD title?: string content = "" + constructor(readonly reader?: Thread) { + super(reader) + + // Consume the represented "title" tag out of the carried-over extraTags so it + // round-trips through the field below rather than being emitted twice (once + // from buildTags, once from the base's extraTags pass-through). + this.title = first(this.consumeTags("title"))?.[1] + this.content = reader?.event.content ?? "" + } + setTitle(title: string) { this.title = title diff --git a/packages/domain/src/TimeEvent.ts b/packages/domain/src/kinds/TimeEvent.ts similarity index 65% rename from packages/domain/src/TimeEvent.ts rename to packages/domain/src/kinds/TimeEvent.ts index b901a68..169c139 100644 --- a/packages/domain/src/TimeEvent.ts +++ b/packages/domain/src/kinds/TimeEvent.ts @@ -1,6 +1,7 @@ -import {randomId, range, DAY} from "@welshman/lib" +import {first, randomId, range, DAY} from "@welshman/lib" import {EVENT_TIME, getTagValue} from "@welshman/util" -import {EventReader, EventBuilder} from "./base.js" +import {EventReader} from "../EventReader.js" +import {EventBuilder} from "../EventBuilder.js" // NIP-52 kind-31923 time-based calendar event. Addressable via the "d" tag. // `start`/`end` are unix-second timestamps carried in "start"/"end" tags @@ -16,11 +17,7 @@ import {EventReader, EventBuilder} from "./base.js" // dropped on read and recomputed in buildTags (matching flotilla's // daysBetween: one tag per epoch-day floor(seconds / DAY) the event spans). export class TimeEvent extends EventReader { - static kind = EVENT_TIME - - protected reservedTagKeys() { - return ["d", "title", "name", "location", "start", "end", "D"] - } + readonly kind = EVENT_TIME title() { return getTagValue("title", this.event.tags) || getTagValue("name", this.event.tags) @@ -47,21 +44,12 @@ export class TimeEvent extends EventReader { } builder() { - const builder = new TimeEventBuilder() - - builder.identifier = this.identifier() || "" - builder.title = this.title() - builder.location = this.location() - builder.content = this.content() - builder.start = this.start() - builder.end = this.end() - - return this.seedBuilder(builder) + return new TimeEventBuilder(this) } } -export class TimeEventBuilder extends EventBuilder { - static kind = EVENT_TIME +export class TimeEventBuilder extends EventBuilder { + readonly kind = EVENT_TIME identifier = randomId() title?: string @@ -70,6 +58,30 @@ export class TimeEventBuilder extends EventBuilder { start?: number end?: number + constructor(readonly reader?: TimeEvent) { + super(reader) + + // Consume the represented tags out of the carried-over extraTags so they + // round-trip through the structured fields below rather than being emitted + // twice (once from buildTags, once from the base's extraTags pass-through). + // The legacy "name" tag and the derived "D" day tags are consumed too: name + // folds into title, and the "D" index is recomputed in buildTags. + const d = first(this.consumeTags("d")) + const title = first(this.consumeTags("title")) + const name = first(this.consumeTags("name")) + const start = first(this.consumeTags("start")) + const end = first(this.consumeTags("end")) + + this.consumeTags("D") + + this.identifier = d?.[1] || randomId() + this.title = title?.[1] || name?.[1] + this.location = first(this.consumeTags("location"))?.[1] + this.content = reader?.event.content ?? "" + this.start = start ? (isNaN(parseInt(start[1])) ? undefined : parseInt(start[1])) : undefined + this.end = end ? (isNaN(parseInt(end[1])) ? undefined : parseInt(end[1])) : undefined + } + setTitle(title: string) { this.title = title diff --git a/packages/domain/src/TopicList.ts b/packages/domain/src/kinds/TopicList.ts similarity index 67% rename from packages/domain/src/TopicList.ts rename to packages/domain/src/kinds/TopicList.ts index 887cb4e..cd2aaee 100644 --- a/packages/domain/src/TopicList.ts +++ b/packages/domain/src/kinds/TopicList.ts @@ -1,13 +1,14 @@ -import {uniq} from "@welshman/lib" +import {uniq, nthEq} from "@welshman/lib" import {TOPICS, getTopicTagValues, getAddressTagValues} from "@welshman/util" -import {ListReader, ListBuilder} from "./List.js" +import {ListReader} from "../ListReader.js" +import {ListBuilder} from "../ListBuilder.js" // NIP-51 kind-10015 interests/followed-topics list. Followed hashtags are stored // as `t` tags; the list may also reference interest sets (kind 30015) via `a` // tags. Extends ListReader/ListBuilder so entries may be public (tags) or private // (encrypted content), treated as one merged set by the accessors. export class TopicList extends ListReader { - static kind = TOPICS + readonly kind = TOPICS topics() { return uniq(getTopicTagValues(this.tags())) @@ -22,19 +23,19 @@ export class TopicList extends ListReader { } builder() { - return this.seedList(new TopicListBuilder()) + return new TopicListBuilder(this) } } -export class TopicListBuilder extends ListBuilder { - static kind = TOPICS +export class TopicListBuilder extends ListBuilder { + readonly kind = TOPICS followPublicly(topic: string) { - return this.addPublicTags(["t", topic]) + return this.addPublic(["t", topic]) } followPrivately(topic: string) { - return this.addPrivateTags(["t", topic]) + return this.addPrivate(["t", topic]) } follow(topic: string) { @@ -42,6 +43,6 @@ export class TopicListBuilder extends ListBuilder { } unfollow(topic: string) { - return this.removeTagsWithValue(topic) + return this.drop(nthEq(1, topic)) } } diff --git a/packages/domain/src/ZapGoal.ts b/packages/domain/src/kinds/ZapGoal.ts similarity index 69% rename from packages/domain/src/ZapGoal.ts rename to packages/domain/src/kinds/ZapGoal.ts index 11428cc..abca54f 100644 --- a/packages/domain/src/ZapGoal.ts +++ b/packages/domain/src/kinds/ZapGoal.ts @@ -1,5 +1,7 @@ +import {first} from "@welshman/lib" import {ZAP_GOAL, getTagValue, getTagValues} from "@welshman/util" -import {EventReader, EventBuilder} from "./base.js" +import {EventReader} from "../EventReader.js" +import {EventBuilder} from "../EventBuilder.js" // NIP-75 kind-9041 zap goal. A fundraising target that drives flotilla's goals // feature: the goal title lives in `content` as plain text (not JSON), the body @@ -10,17 +12,7 @@ import {EventReader, EventBuilder} from "./base.js" // computed elsewhere from sibling zap receipts (ZAP_RESPONSE) and is not modeled // here. Tags + plain-text content, so it extends EventReader/EventBuilder. export class ZapGoal extends EventReader { - static kind = ZAP_GOAL - - protected validate() { - if (!this.title()) { - throw new Error("ZapGoal requires a title") - } - } - - protected reservedTagKeys() { - return ["summary", "amount", "relays"] - } + readonly kind = ZAP_GOAL // The goal title is plain-text content, not JSON or encrypted. title() { @@ -40,25 +32,28 @@ export class ZapGoal extends EventReader { } builder() { - const builder = new ZapGoalBuilder(this.title()) - - builder.summary = this.summary() - builder.amount = this.amount() - builder.relays = this.relays() - - return this.seedBuilder(builder) + return new ZapGoalBuilder(this) } } -export class ZapGoalBuilder extends EventBuilder { - static kind = ZAP_GOAL +export class ZapGoalBuilder extends EventBuilder { + readonly kind = ZAP_GOAL + title = "" summary?: string amount = 0 relays: string[] = [] - constructor(public title = "") { - super() + constructor(readonly reader?: ZapGoal) { + super(reader) + + // Consume the represented tags out of the carried-over extraTags so they + // round-trip through the structured fields below rather than being emitted + // twice (once from buildTags, once from the base's extraTags pass-through). + this.title = reader?.title() ?? "" + this.summary = first(this.consumeTags("summary"))?.[1] + this.amount = parseInt(first(this.consumeTags("amount"))?.[1] || "0") || 0 + this.relays = this.consumeTags("relays").map(t => t[1]) } setTitle(title: string) { diff --git a/packages/domain/src/ZapReceipt.ts b/packages/domain/src/kinds/ZapReceipt.ts similarity index 75% rename from packages/domain/src/ZapReceipt.ts rename to packages/domain/src/kinds/ZapReceipt.ts index 62669b5..b82b1af 100644 --- a/packages/domain/src/ZapReceipt.ts +++ b/packages/domain/src/kinds/ZapReceipt.ts @@ -1,24 +1,24 @@ -import {parseJson} from "@welshman/lib" +import {first, parseJson} from "@welshman/lib" import {ZAP_RECEIPT, getTagValue, getInvoiceAmount} from "@welshman/util" import type {TrustedEvent, Zapper} from "@welshman/util" -import {EventReader, EventBuilder} from "./base.js" +import type {ISigner} from "@welshman/signer" +import {EventReader} from "../EventReader.js" +import {EventBuilder} from "../EventBuilder.js" // NIP-57 kind-9735 zap receipt. Relay/LN-generated, so it's effectively read-only: // we parse the bolt11 invoice and the embedded kind-9734 zap request (carried in // the JSON "description" tag, which we expose as `plain`). The builder exists for // completeness but is rarely used in practice. -export class ZapReceipt extends EventReader { - static kind = ZAP_RECEIPT +export class ZapReceipt extends EventReader { + readonly kind = ZAP_RECEIPT - // The embedded kind-9734 zap request lives in the "description" tag as JSON. - protected parsePlain() { + // The embedded kind-9734 zap request, parsed once from the JSON "description" tag. + plain?: TrustedEvent + + protected async parse(signer?: ISigner) { const description = getTagValue("description", this.event.tags) - return description ? parseJson(description) || undefined : undefined - } - - protected reservedTagKeys() { - return ["bolt11", "description", "preimage", "p", "e"] + this.plain = description ? parseJson(description) || undefined : undefined } bolt11() { @@ -111,22 +111,14 @@ export class ZapReceipt extends EventReader { } builder() { - const builder = new ZapReceiptBuilder() - - builder.bolt11 = this.bolt11() - builder.description = getTagValue("description", this.event.tags) - builder.recipient = this.recipient() - builder.eventId = this.eventId() - builder.preimage = this.preimage() - - return this.seedBuilder(builder) + return new ZapReceiptBuilder(this) } } // Write side for a zap receipt. Receipts are normally relay/LN-generated, so this // is rarely used; it builds the represented tags from raw draft fields. -export class ZapReceiptBuilder extends EventBuilder { - static kind = ZAP_RECEIPT +export class ZapReceiptBuilder extends EventBuilder { + readonly kind = ZAP_RECEIPT bolt11?: string description?: string @@ -134,6 +126,19 @@ export class ZapReceiptBuilder extends EventBuilder { eventId?: string preimage?: string + constructor(readonly reader?: ZapReceipt) { + super(reader) + + // Consume the represented tags out of the carried-over extraTags so they + // round-trip through the structured fields below rather than being emitted + // twice (once from buildTags, once from the base's extraTags pass-through). + this.bolt11 = first(this.consumeTags("bolt11"))?.[1] + this.description = first(this.consumeTags("description"))?.[1] + this.recipient = first(this.consumeTags("p"))?.[1] + this.eventId = first(this.consumeTags("e"))?.[1] + this.preimage = first(this.consumeTags("preimage"))?.[1] + } + setBolt11(bolt11: string) { this.bolt11 = bolt11 diff --git a/packages/domain/src/ZapRequest.ts b/packages/domain/src/kinds/ZapRequest.ts similarity index 65% rename from packages/domain/src/ZapRequest.ts rename to packages/domain/src/kinds/ZapRequest.ts index edd40e3..a4075c8 100644 --- a/packages/domain/src/ZapRequest.ts +++ b/packages/domain/src/kinds/ZapRequest.ts @@ -1,15 +1,13 @@ +import {first} from "@welshman/lib" import {ZAP_REQUEST, getTag, getTagValue} from "@welshman/util" -import {EventReader, EventBuilder} from "./base.js" +import {EventReader} from "../EventReader.js" +import {EventBuilder} from "../EventBuilder.js" // NIP-57 kind-9734 zap request: zap metadata in tags plus an optional comment in // content. `amount` is in millisats. Tags-only structured data; the comment lives // in the event content. export class ZapRequest extends EventReader { - static kind = ZAP_REQUEST - - protected reservedTagKeys() { - return ["amount", "lnurl", "p", "e", "relays", "anon"] - } + readonly kind = ZAP_REQUEST amount() { const amount = getTagValue("amount", this.event.tags) @@ -44,22 +42,12 @@ export class ZapRequest extends EventReader { } builder() { - const builder = new ZapRequestBuilder() - - builder.amount = this.amount() - builder.lnurl = this.lnurl() - builder.recipient = this.recipient() - builder.eventId = this.eventId() - builder.relays = this.relays() - builder.anonymous = this.isAnonymous() - builder.comment = this.comment() - - return this.seedBuilder(builder) + return new ZapRequestBuilder(this) } } -export class ZapRequestBuilder extends EventBuilder { - static kind = ZAP_REQUEST +export class ZapRequestBuilder extends EventBuilder { + readonly kind = ZAP_REQUEST amount?: number lnurl?: string @@ -69,6 +57,24 @@ export class ZapRequestBuilder extends EventBuilder { anonymous = false comment = "" + constructor(readonly reader?: ZapRequest) { + super(reader) + + // Consume the represented tags out of the carried-over extraTags so they + // round-trip through the structured fields below rather than being emitted + // twice (once from buildTags, once from the base's extraTags pass-through). + const amount = first(this.consumeTags("amount")) + const anon = first(this.consumeTags("anon")) + + this.amount = amount ? parseInt(amount[1]) : undefined + this.lnurl = first(this.consumeTags("lnurl"))?.[1] + this.recipient = first(this.consumeTags("p"))?.[1] + this.eventId = first(this.consumeTags("e"))?.[1] + this.relays = first(this.consumeTags("relays"))?.slice(1) ?? [] + this.anonymous = Boolean(anon) + this.comment = reader?.event.content ?? "" + } + setAmount(amount: number) { this.amount = amount