Move domain stuff to sub directory, clean up base classes

This commit is contained in:
Jon Staab
2026-06-19 10:42:58 -07:00
parent bfd91f2d39
commit 1bd62d3024
95 changed files with 4956 additions and 1353 deletions
@@ -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> = {}): 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()
})
})
@@ -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> = {}): 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()
})
})
@@ -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> = {}): 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()
})
})
@@ -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> = {}): 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()
})
})
+109
View File
@@ -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> = {}): 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()
})
})
+104
View File
@@ -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> = {}): 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()
})
})
+90
View File
@@ -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> = {}): 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()
})
})
@@ -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> = {}): 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()
})
})
@@ -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> = {}): 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()
})
})
+110
View File
@@ -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> = {}): 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()
})
})
+108
View File
@@ -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> = {}): 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()
})
})
@@ -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> = {}): 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()
})
})
@@ -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> = {}): 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()
})
})
+29 -27
View File
@@ -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()
})
})
+76
View File
@@ -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> = {}): 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()
})
})
+123
View File
@@ -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> = {}): 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()
})
})
@@ -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> = {}): 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()
})
})
+17 -14
View File
@@ -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()
})
})
@@ -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> = {}): 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()
})
})
@@ -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> = {}): 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()
})
})
@@ -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> = {}): 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()
})
})
@@ -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> = {}): 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()
})
})
@@ -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> = {}): 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()
})
})
@@ -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> = {}): 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()
})
})
+106
View File
@@ -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> = {}): 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()
})
})
+81
View File
@@ -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> = {}): 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()
})
})
@@ -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> = {}): 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()
})
})
@@ -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> = {}): 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()
})
})
@@ -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> = {}): 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()
})
})
@@ -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> = {}): 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()
})
})
@@ -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> = {}): 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()
})
})
@@ -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> = {}): 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()
})
})
@@ -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> = {}): 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()
})
})
@@ -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> = {}): 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()
})
})
@@ -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> = {}): 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()
})
})
+100
View File
@@ -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> = {}): 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()
})
})
@@ -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> = {}): 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()
})
})
+87
View File
@@ -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> = {}): 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()
})
})
+120
View File
@@ -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> = {}): 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()
})
})
@@ -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> = {}): 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()
})
})
+91
View File
@@ -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> = {}): 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()
})
})
@@ -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> = {}): 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()
})
})
@@ -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> = {}): 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()
})
})
+91
View File
@@ -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<Reader extends EventReader> {
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<string>) {
this.groupTag = group ? ["h", group] : undefined
return this
}
protect(protect: boolean) {
this.protectTag = protect ? ["-"] : undefined
return this
}
expires(expires: Maybe<number>) {
this.expiresTag = expires ? ["expiration", String(expires)] : undefined
return this
}
protected buildTags(signer?: ISigner): MaybeAsync<string[][]> {
return []
}
protected buildContent(signer?: ISigner): MaybeAsync<string> {
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<EventTemplate> {
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<HashedEvent> {
const [template, pubkey] = await Promise.all([this.toTemplate(signer), signer.getPubkey()])
return prep(template, pubkey)
}
async toEvent(signer: ISigner): Promise<SignedEvent> {
return signer.sign(stamp(await this.toTemplate(signer)))
}
}
+65
View File
@@ -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<T extends EventReader>(
this: (new (event: TrustedEvent) => T),
event: TrustedEvent,
signer?: ISigner,
): Promise<T> {
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<void> {}
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<EventReader>
}
-187
View File
@@ -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<ListPlain> => {
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<ListPlain> {
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<B extends ListBuilder>(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<ListPlain> {
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<string> {
// 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))
}
}
+118
View File
@@ -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<Reader extends ListReader = ListReader> extends EventBuilder<Reader> {
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<string> {
// 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))
}
}
+40
View File
@@ -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
}
-154
View File
@@ -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> = {}): 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<ProfileValues> {
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<ProfileValues> {
static kind = PROFILE
values: ProfileValues = makeProfileValues()
setValues(values: Partial<ProfileValues>) {
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)
}
}
-22
View File
@@ -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 []
}
}
-210
View File
@@ -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<P = undefined> {
// Concrete subclasses declare `static kind = SOME_KIND`.
plain!: P
constructor(readonly event: TrustedEvent) {}
static async fromEvent<T extends EventReader<unknown>>(
this: (new (event: TrustedEvent) => T) & {kind: number},
event: TrustedEvent,
signer?: ISigner,
): Promise<T> {
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<P> {
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<B extends EventBuilder<P>>(builder: B): B {
builder.group = this.group()
builder.protect = this.protect()
builder.expires = this.expires()
builder.extraTags = this.extraTags()
return builder
}
abstract builder(): EventBuilder<P>
}
/**
* 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<P = undefined> {
// 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<string[][]>
// The event content. Override for JSON metadata or encrypted content.
protected buildContent(_signer?: ISigner): string | Promise<string> {
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<EventTemplate> {
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<HashedEvent> {
const [template, pubkey] = await Promise.all([this.toTemplate(signer), signer.getPubkey()])
return prep(template, pubkey)
}
async toEvent(signer: ISigner): Promise<SignedEvent> {
return signer.sign(stamp(await this.toTemplate(signer)))
}
}
+47 -45
View File
@@ -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"
@@ -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<BlockedRelayList> {
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)]))
}
}
@@ -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<BlossomServerList> {
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)]))
}
}
@@ -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<BookmarkList> {
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))
}
}
@@ -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<Classified> {
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
@@ -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<Comment> {
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
@@ -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<EmojiList> {
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))
}
}
@@ -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<Feed> {
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
@@ -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<FeedList> {
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))
}
}
@@ -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<FollowList> {
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))
}
}
@@ -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<GroupList> {
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))
}
}
@@ -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<HandlerMeta> {
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<HandlerMeta> {
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<HandlerMeta> {
}
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<HandlerMeta> {
static kind = HANDLER_INFORMATION
export class HandlerBuilder extends EventBuilder<Handler> {
readonly kind = HANDLER_INFORMATION
name?: string
about?: string
@@ -82,6 +75,18 @@ export class HandlerBuilder extends EventBuilder<HandlerMeta> {
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
@@ -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<HandlerRecommendation> {
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) {
@@ -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<MessagingRelayList> {
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)]))
}
}
@@ -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<MuteList> {
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))
}
}
@@ -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<PinList> {
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))
}
}
@@ -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<Poll> {
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) {
@@ -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<PollResponse> {
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
+135
View File
@@ -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<string, any> = {}): Maybe<string> => {
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<string, any> = {}
protected async parse(signer?: ISigner) {
const json = parseJson(this.event.content)
if (isPojo(json)) {
Object.assign(this.values, json)
}
}
name(): Maybe<string> {
return this.values.name || this.values.display_name
}
nip05(): Maybe<string> {
return this.values.nip05
}
lnurl(): Maybe<string> {
return parseLnUrl(this.values)
}
about(): Maybe<string> {
return this.values.about
}
banner(): Maybe<string> {
return this.values.banner
}
picture(): Maybe<string> {
return this.values.picture
}
website(): Maybe<string> {
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<Profile> {
readonly kind = PROFILE
values: Record<string, any>
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)
}
}
@@ -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<RelayInvite> {
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
@@ -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<string | undefined> {
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<string | undefined> {
static kind = RELAY_JOIN
export class RelayJoinBuilder extends EventBuilder<RelayJoin> {
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
@@ -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<RelayLeave> {
readonly kind = RELAY_LEAVE
}
@@ -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<RelayList> {
readonly kind = RELAYS
// Relays usable for reading: includes modeless (both) entries.
readUrls() {
@@ -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<RelayMembers> {
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])
@@ -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<RelayMembershipOp> {
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
}
@@ -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<RelaySet> {
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() {
@@ -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<Report> {
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
@@ -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<RoomAdmins> {
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
+20
View File
@@ -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<RoomCreate> {
readonly kind = ROOM_CREATE
}
@@ -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<RoomCreatePermission> {
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
@@ -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<RoomDelete> {
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)
@@ -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<RoomJoin> {
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")
}
}
@@ -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<RoomLeave> {
readonly kind = ROOM_LEAVE
protected validate() {
if (!this.group) {
if (!this.groupTag) {
throw new Error("RoomLeave requires an h identifier")
}
}
protected buildTags() {
return []
}
}
@@ -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<RoomList> {
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))
}
}
@@ -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<RoomMembers> {
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])
@@ -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<RoomMembershipOp> {
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
}
@@ -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<RoomMeta> {
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
@@ -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<SearchRelayList> {
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)]))
}
}
@@ -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<Thread> {
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
@@ -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<TimeEvent> {
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
@@ -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<TopicList> {
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))
}
}
@@ -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<ZapGoal> {
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) {
@@ -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<TrustedEvent | undefined> {
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<TrustedEvent | undefined> {
}
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<TrustedEvent | undefined> {
static kind = ZAP_RECEIPT
export class ZapReceiptBuilder extends EventBuilder<ZapReceipt> {
readonly kind = ZAP_RECEIPT
bolt11?: string
description?: string
@@ -134,6 +126,19 @@ export class ZapReceiptBuilder extends EventBuilder<TrustedEvent | undefined> {
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
@@ -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<ZapRequest> {
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