From bfd91f2d397c67cee841e0fd77c3b80009f6f3a8 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Fri, 19 Jun 2026 00:35:06 +0000 Subject: [PATCH] Rewrite domain objects as a Reader/Builder split Replace the single DomainObject/EncryptableList classes with a read/write split that removes the optional-event ambiguity: - base.ts: EventReader

(static kind; fromEvent(event, signer?) eagerly computes a generic `plain`, validates leniently, throws-or-passes; lazy method accessors; group/protect/expires + extraTags carry-over; builder()) and EventBuilder

(chainable setters, buildTags/buildContent, validate-on-emit). - List.ts: ListReader/ListBuilder for NIP-51 lists (decrypt-on-read into `plain`, re-encrypt-on-emit, tag mutators). - Every kind converted to a reader + Builder pair; membership ops split into per-kind reader/builder pairs over a shared abstract base. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01V67tPYdvh1qCkjEBhJGZUR --- packages/domain/src/BlockedRelayList.ts | 20 +- packages/domain/src/BlossomServerList.ts | 18 +- packages/domain/src/BookmarkList.ts | 14 +- packages/domain/src/Classified.ts | 197 ++++++++------ packages/domain/src/Comment.ts | 171 ++++++------ packages/domain/src/EmojiList.ts | 16 +- packages/domain/src/Feed.ts | 137 +++++----- packages/domain/src/FeedList.ts | 25 +- packages/domain/src/FollowList.ts | 20 +- packages/domain/src/GroupList.ts | 17 +- packages/domain/src/Handler.ts | 147 +++++++---- packages/domain/src/HandlerRecommendation.ts | 90 +++---- packages/domain/src/List.ts | 135 ++++++---- packages/domain/src/MessagingRelayList.ts | 16 +- packages/domain/src/MuteList.ts | 16 +- packages/domain/src/PinList.ts | 24 +- packages/domain/src/Poll.ts | 158 +++++++---- packages/domain/src/PollResponse.ts | 85 +++--- packages/domain/src/Profile.ts | 107 ++++++-- packages/domain/src/RelayInvite.ts | 64 ++--- packages/domain/src/RelayJoin.ts | 85 +++--- packages/domain/src/RelayLeave.ts | 40 +-- packages/domain/src/RelayList.ts | 56 +++- packages/domain/src/RelayMembers.ts | 83 +++--- packages/domain/src/RelayMembershipOp.ts | 86 +++--- packages/domain/src/RelaySet.ts | 107 ++++++-- packages/domain/src/Report.ts | 112 ++++---- packages/domain/src/RoomAdmins.ts | 109 ++++---- packages/domain/src/RoomCreate.ts | 56 ++-- packages/domain/src/RoomCreatePermission.ts | 64 +++-- packages/domain/src/RoomDelete.ts | 90 ++++--- packages/domain/src/RoomJoin.ts | 104 ++++---- packages/domain/src/RoomLeave.ts | 59 ++--- packages/domain/src/RoomList.ts | 18 +- packages/domain/src/RoomMembers.ts | 81 +++--- packages/domain/src/RoomMembershipOp.ts | 82 +++--- packages/domain/src/RoomMeta.ts | 213 ++++++++------- packages/domain/src/SearchRelayList.ts | 20 +- packages/domain/src/Thread.ts | 75 +++--- packages/domain/src/TimeEvent.ts | 153 ++++++----- packages/domain/src/TopicList.ts | 30 ++- packages/domain/src/ZapGoal.ts | 118 ++++++--- packages/domain/src/ZapReceipt.ts | 156 +++++++---- packages/domain/src/ZapRequest.ts | 193 +++++++------- packages/domain/src/base.ts | 264 +++++++++++-------- 45 files changed, 2305 insertions(+), 1626 deletions(-) diff --git a/packages/domain/src/BlockedRelayList.ts b/packages/domain/src/BlockedRelayList.ts index bc0b3dc..8fdee6c 100644 --- a/packages/domain/src/BlockedRelayList.ts +++ b/packages/domain/src/BlockedRelayList.ts @@ -1,23 +1,35 @@ import {uniqBy} from "@welshman/lib" import {BLOCKED_RELAYS, getTagValues, normalizeRelayUrl} from "@welshman/util" -import {EncryptableList} from "./List.js" +import {ListReader, ListBuilder} from "./List.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 EncryptableList { - readonly kind = BLOCKED_RELAYS +export class BlockedRelayList extends ListReader { + static kind = BLOCKED_RELAYS urls() { return uniqBy(normalizeRelayUrl, getTagValues("relay", this.tags())) } + includes(url: string) { + return this.urls().includes(normalizeRelayUrl(url)) + } + + builder() { + return this.seedList(new BlockedRelayListBuilder()) + } +} + +export class BlockedRelayListBuilder extends ListBuilder { + static kind = BLOCKED_RELAYS + addRelay(url: string) { return this.addPublicTags(["relay", normalizeRelayUrl(url)]) } removeRelay(url: string) { - return this.removeTagsWithValue(url) + return this.removeTagsWithValue(normalizeRelayUrl(url)) } setRelays(urls: string[]) { diff --git a/packages/domain/src/BlossomServerList.ts b/packages/domain/src/BlossomServerList.ts index daf89b4..7ab86ad 100644 --- a/packages/domain/src/BlossomServerList.ts +++ b/packages/domain/src/BlossomServerList.ts @@ -1,27 +1,35 @@ import {uniq} from "@welshman/lib" import {BLOSSOM_SERVERS, getTagValues, normalizeRelayUrl} from "@welshman/util" -import {EncryptableList} from "./List.js" +import {ListReader, ListBuilder} from "./List.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 EncryptableList { - readonly kind = BLOSSOM_SERVERS +export class BlossomServerList extends ListReader { + static kind = BLOSSOM_SERVERS servers() { return uniq(getTagValues("server", this.tags()).map(normalizeRelayUrl)) } includes(url: string) { - return this.servers().includes(url) + return this.servers().includes(normalizeRelayUrl(url)) } + builder() { + return this.seedList(new BlossomServerListBuilder()) + } +} + +export class BlossomServerListBuilder extends ListBuilder { + static kind = BLOSSOM_SERVERS + addServer(url: string) { return this.addPublicTags(["server", normalizeRelayUrl(url)]) } removeServer(url: string) { - return this.removeTagsWithValue(url) + return this.removeTagsWithValue(normalizeRelayUrl(url)) } setServers(urls: string[]) { diff --git a/packages/domain/src/BookmarkList.ts b/packages/domain/src/BookmarkList.ts index 2237a1a..ca3344c 100644 --- a/packages/domain/src/BookmarkList.ts +++ b/packages/domain/src/BookmarkList.ts @@ -6,13 +6,13 @@ import { getTopicTagValues, getTagValues, } from "@welshman/util" -import {EncryptableList} from "./List.js" +import {ListReader, ListBuilder} from "./List.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 EncryptableList { - readonly kind = BOOKMARKS +export class BookmarkList extends ListReader { + static kind = BOOKMARKS ids() { return uniq(getEventTagValues(this.tags())) @@ -30,6 +30,14 @@ export class BookmarkList extends EncryptableList { return uniq(getTagValues("r", this.tags())) } + builder() { + return this.seedList(new BookmarkListBuilder()) + } +} + +export class BookmarkListBuilder extends ListBuilder { + static kind = BOOKMARKS + bookmarkPublicly(tag: string[]) { return this.addPublicTags(tag) } diff --git a/packages/domain/src/Classified.ts b/packages/domain/src/Classified.ts index c2ef3d8..280b0bf 100644 --- a/packages/domain/src/Classified.ts +++ b/packages/domain/src/Classified.ts @@ -1,125 +1,158 @@ -import { - CLASSIFIED, - getIdentifier, - getTag, - getTagValue, - getTagValues, - getTopicTagValues, -} from "@welshman/util" -import type {EventTemplate, TrustedEvent} from "@welshman/util" -import {DomainObject} from "./base.js" +import {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" -export type ClassifiedValues = { - identifier: string - title?: string - summary?: string - content: string - price?: {amount: number; currency: string} - status?: string - images: string[] - topics: string[] +export type ClassifiedPrice = { + amount: number + currency: string } -export const makeClassifiedValues = ( - values: Partial = {}, -): ClassifiedValues => ({ - identifier: "", - content: "", - images: [], - topics: [], - ...values, -}) +// NIP-99 kind-30402 addressable classified listing. Addressable via the "d" tag; +// the listing description lives in `content` as plain text (not JSON). The price +// is carried in a ["price", amount, currency] tag with the currency defaulting to +// "SAT", images in repeated "image" tags, and topics in "t" tags; room scoping is +// handled by the base `group` behavior tag. Plain-text content, so it extends +// EventReader/EventBuilder directly. +export class Classified extends EventReader { + static kind = CLASSIFIED -// NIP-99 kind-30402 addressable classified listing. Addressable via the "d" -// tag; the listing description lives in `content` as plain text (not JSON). The -// price is carried in a ["price", amount, currency] tag with the currency -// defaulting to "SAT", images in repeated "image" tags, and topics in "t" tags; -// room scoping is handled by the base `group` behavior tag. Tags-only metadata, -// so it extends DomainObject directly. Commented via "#A" (kind 1111 comments). -export class Classified extends DomainObject { - readonly kind = CLASSIFIED - values = makeClassifiedValues() - - protected normalizeValues(values: Partial = {}) { - return makeClassifiedValues(values) - } - - protected parseEvent(event: TrustedEvent): Partial { - const priceTag = getTag("price", event.tags) - - return { - identifier: getIdentifier(event) || "", - title: getTagValue("title", event.tags), - summary: getTagValue("summary", event.tags), - content: event.content || "", - price: priceTag - ? {amount: parseFloat(priceTag[1]) || 0, currency: priceTag[2] || "SAT"} - : undefined, - status: getTagValue("status", event.tags), - images: getTagValues("image", event.tags), - topics: getTopicTagValues(event.tags), + protected validate() { + if (!this.identifier()) { + throw new Error("Classified requires a d tag") } } - identifier() { - return this.values.identifier + protected reservedTagKeys() { + return ["d", "title", "summary", "price", "status", "image", "t"] } title() { - return this.values.title + return getTagValue("title", this.event.tags) } summary() { - return this.values.summary + return getTagValue("summary", this.event.tags) } content() { - return this.values.content + return this.event.content } - price() { - return this.values.price + price(): ClassifiedPrice | undefined { + const tag = getTag("price", this.event.tags) + + return tag ? {amount: parseFloat(tag[1]) || 0, currency: tag[2] || "SAT"} : undefined } status() { - return this.values.status + return getTagValue("status", this.event.tags) } images() { - return this.values.images + return getTagValues("image", this.event.tags) } topics() { - return this.values.topics + return getTopicTagValues(this.event.tags) } - async toTemplate(): Promise { - const tags: string[][] = [["d", this.values.identifier]] + builder() { + const builder = new ClassifiedBuilder() - if (this.values.title) { - tags.push(["title", this.values.title]) + 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) + } +} + +export class ClassifiedBuilder extends EventBuilder { + static kind = CLASSIFIED + + identifier = randomId() + title?: string + summary?: string + content = "" + price?: ClassifiedPrice + status?: string + images: string[] = [] + topics: string[] = [] + + setTitle(title: string) { + this.title = title + + return this + } + + setSummary(summary: string) { + this.summary = summary + + return this + } + + setContent(content: string) { + this.content = content + + return this + } + + setPrice(amount: number, currency = "SAT") { + this.price = {amount, currency} + + return this + } + + setStatus(status: string) { + this.status = status + + return this + } + + setImages(images: string[]) { + this.images = images + + return this + } + + setTopics(topics: string[]) { + this.topics = topics + + return this + } + + protected validate() { + if (!this.identifier) { + throw new Error("Classified requires a d identifier") } + } - if (this.values.summary) { - tags.push(["summary", this.values.summary]) - } + protected buildContent(_signer?: ISigner) { + return this.content + } - if (this.values.price) { - tags.push(["price", String(this.values.price.amount), this.values.price.currency]) - } + protected buildTags() { + const tags: string[][] = [["d", this.identifier]] - if (this.values.status) { - tags.push(["status", this.values.status]) - } + if (this.title) tags.push(["title", this.title]) + if (this.summary) tags.push(["summary", this.summary]) + if (this.price) tags.push(["price", String(this.price.amount), this.price.currency]) + if (this.status) tags.push(["status", this.status]) - for (const topic of this.values.topics) { + for (const topic of this.topics) { tags.push(["t", topic]) } - for (const image of this.values.images) { + for (const image of this.images) { tags.push(["image", image]) } - return {kind: this.kind, content: this.values.content, tags} + return tags } } diff --git a/packages/domain/src/Comment.ts b/packages/domain/src/Comment.ts index 7d40450..1b1828e 100644 --- a/packages/domain/src/Comment.ts +++ b/packages/domain/src/Comment.ts @@ -1,6 +1,7 @@ import {COMMENT, Address, getAddress, getTagValue, isReplaceableKind} from "@welshman/util" -import type {EventTemplate, TrustedEvent} from "@welshman/util" -import {DomainObject} from "./base.js" +import type {TrustedEvent} from "@welshman/util" +import type {ISigner} from "@welshman/signer" +import {EventReader, EventBuilder} from "./base.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,7 +13,7 @@ export type CommentRef = { } // The tag keys NIP-22 uses for the root (uppercase) and parent (lowercase) -// references; stripped on parse and rebuilt from the structs on serialize. +// 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 @@ -24,99 +25,120 @@ const refFromEvent = (event: TrustedEvent): CommentRef => ({ address: isReplaceableKind(event.kind) ? getAddress(event) : undefined, }) -export type CommentValues = { - content: string - root: CommentRef - parent: CommentRef +// Build the NIP-22 reference tags for one struct: pass uppercase keys for the +// root, lowercase for the parent. +const refTags = (ref: CommentRef, [idKey, addressKey, kindKey, pubkeyKey]: string[]) => { + const tags: string[][] = [] + + if (ref.id) tags.push([idKey, ref.id]) + if (ref.address) tags.push([addressKey, ref.address]) + if (ref.kind) tags.push([kindKey, ref.kind]) + if (ref.pubkey) tags.push([pubkeyKey, ref.pubkey]) + + return tags } -export const makeCommentValues = (values: Partial = {}): CommentValues => ({ - content: "", - root: {}, - parent: {}, - ...values, -}) - -// NIP-22 kind-1111 generic comment, flotilla's universal reply primitive: threads, -// goals, and polls reference their root event via uppercase E/A/K/P tags, while -// classifieds and calendar events reference addressable roots via #A. Uppercase -// tags (E/A/K/P) name the root of the thread; lowercase tags (e/a/k/p) name the -// immediate parent. The comment body lives in `content` as plain text (not JSON). +// Read side of NIP-22 kind-1111 generic comment, flotilla's universal reply +// primitive: threads, goals, and polls reference their root event via uppercase +// E/A/K/P tags, while classifieds and calendar events reference addressable +// roots via #A. Uppercase tags (E/A/K/P) name the root of the thread; lowercase +// 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 parsed into the `root`/`parent` structs and rebuilt -// from them in toTemplate; any other tags round-trip via the base `extraTags` -// (REF_TAG_KEYS is declared as reserved so they aren't double-counted). Use -// setRoot/setParent (or the *FromEvent variants) to populate them programmatically. -export class Comment extends DomainObject { - readonly kind = COMMENT - values = makeCommentValues() - - protected normalizeValues(values: Partial = {}) { - return makeCommentValues(values) - } +// 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. +export class Comment extends EventReader { + static kind = COMMENT protected reservedTagKeys() { return REF_TAG_KEYS } - protected parseEvent(event: TrustedEvent): Partial { - return { - content: event.content || "", - root: { - id: getTagValue("E", event.tags), - address: getTagValue("A", event.tags), - kind: getTagValue("K", event.tags), - pubkey: getTagValue("P", event.tags), - }, - parent: { - id: getTagValue("e", event.tags), - address: getTagValue("a", event.tags), - kind: getTagValue("k", event.tags), - pubkey: getTagValue("p", event.tags), - }, - } - } - content() { - return this.values.content + return this.event.content || "" } rootId() { - return this.values.root.id + return getTagValue("E", this.event.tags) } rootAddress() { - return this.values.root.address + return getTagValue("A", this.event.tags) } rootKind() { - return this.values.root.kind + return getTagValue("K", this.event.tags) } rootPubkey() { - return this.values.root.pubkey + return getTagValue("P", this.event.tags) } parentId() { - return this.values.parent.id + return getTagValue("e", this.event.tags) } parentAddress() { - return this.values.parent.address + return getTagValue("a", this.event.tags) } parentKind() { - return this.values.parent.kind + return getTagValue("k", this.event.tags) } parentPubkey() { - return this.values.parent.pubkey + return getTagValue("p", this.event.tags) + } + + root(): CommentRef { + return { + id: this.rootId(), + address: this.rootAddress(), + kind: this.rootKind(), + pubkey: this.rootPubkey(), + } + } + + parent(): CommentRef { + return { + id: this.parentId(), + address: this.parentAddress(), + kind: this.parentKind(), + pubkey: this.parentPubkey(), + } + } + + builder() { + const builder = new CommentBuilder() + + builder.content = this.content() + builder.root = this.root() + builder.parent = this.parent() + + return this.seedBuilder(builder) + } +} + +// 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 + + content = "" + root: CommentRef = {} + parent: CommentRef = {} + + setContent(content: string) { + this.content = content + + return this } // Set the thread root reference, deriving the address from kind/pubkey/identifier // when the referenced event is addressable. setRoot(kind: number, id: string, pubkey: string, identifier?: string) { - this.values.root = { + this.root = { id, pubkey, kind: String(kind), @@ -128,7 +150,7 @@ export class Comment extends DomainObject { // Set the immediate parent reference, deriving the address as above. setParent(kind: number, id: string, pubkey: string, identifier?: string) { - this.values.parent = { + this.parent = { id, pubkey, kind: String(kind), @@ -140,39 +162,26 @@ export class Comment extends DomainObject { // Set the thread root reference from a full event. setRootFromEvent(event: TrustedEvent) { - this.values.root = refFromEvent(event) + this.root = refFromEvent(event) return this } // Set the immediate parent reference from a full event. setParentFromEvent(event: TrustedEvent) { - this.values.parent = refFromEvent(event) + this.parent = refFromEvent(event) return this } - // Build the NIP-22 reference tags for one struct: uppercase keys for the root, - // lowercase for the parent. - private refTags(ref: CommentRef, [idKey, addressKey, kindKey, pubkeyKey]: string[]) { - const tags: string[][] = [] - - if (ref.id) tags.push([idKey, ref.id]) - if (ref.address) tags.push([addressKey, ref.address]) - if (ref.kind) tags.push([kindKey, ref.kind]) - if (ref.pubkey) tags.push([pubkeyKey, ref.pubkey]) - - return tags + protected buildContent(_signer?: ISigner) { + return this.content } - async toTemplate(): Promise { - return { - kind: this.kind, - content: this.values.content, - tags: [ - ...this.refTags(this.values.root, ["E", "A", "K", "P"]), - ...this.refTags(this.values.parent, ["e", "a", "k", "p"]), - ], - } + protected buildTags() { + return [ + ...refTags(this.root, ["E", "A", "K", "P"]), + ...refTags(this.parent, ["e", "a", "k", "p"]), + ] } } diff --git a/packages/domain/src/EmojiList.ts b/packages/domain/src/EmojiList.ts index 77bfece..c94ebc3 100644 --- a/packages/domain/src/EmojiList.ts +++ b/packages/domain/src/EmojiList.ts @@ -1,20 +1,30 @@ import {uniq, spec} from "@welshman/lib" import {EMOJIS, getAddressTagValues} from "@welshman/util" -import {EncryptableList} from "./List.js" +import {ListReader, ListBuilder} from "./List.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 EncryptableList { - readonly kind = EMOJIS +export class EmojiList extends ListReader { + static kind = EMOJIS + // Addresses of referenced emoji sets (kind 30030). addresses() { return uniq(getAddressTagValues(this.tags())) } + // Inline emoji tags: ["emoji", shortcode, url]. emojis() { return this.tags().filter(spec(["emoji"])) } + builder() { + return this.seedList(new EmojiListBuilder()) + } +} + +export class EmojiListBuilder extends ListBuilder { + static kind = EMOJIS + addEmoji(shortcode: string, url: string) { return this.addPublicTags(["emoji", shortcode, url]) } diff --git a/packages/domain/src/Feed.ts b/packages/domain/src/Feed.ts index 23aed0e..d114767 100644 --- a/packages/domain/src/Feed.ts +++ b/packages/domain/src/Feed.ts @@ -1,26 +1,7 @@ -import {parseJson} from "@welshman/lib" -import {FEED, getIdentifier, getTagValue} from "@welshman/util" -import type {EventTemplate, TrustedEvent} from "@welshman/util" -import {DomainObject} from "./base.js" +import {randomId, parseJson} from "@welshman/lib" +import {FEED, getTagValue} from "@welshman/util" -export type FeedValues = { - identifier: string - title: string - description: string - // The feed definition is a @welshman/feeds `IFeed` AST. That package is not a - // dependency of @welshman/domain, so it is typed as `unknown` here. - definition: unknown -} - -export const makeFeedValues = (values: Partial = {}): FeedValues => ({ - identifier: "", - title: "", - description: "", - // Default to an empty @welshman/feeds feed (a union of nothing). That package - // isn't a dependency here, so the AST is written structurally. - definition: ["union"], - ...values, -}) +import {EventReader, EventBuilder} from "./base.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" @@ -28,59 +9,97 @@ export const makeFeedValues = (values: Partial = {}): FeedValues => // kind-10014 FEEDS favorites list (FeedList.ts) which references these by // address. Flotilla's isTopicFeed/isMentionFeed/isAddressFeed/isContextFeed/ // isPeopleFeed are pure functions over the IFeed AST and stay in flotilla's lib, -// not on this class. Tags-only, so it extends DomainObject directly. -export class Feed extends DomainObject { - readonly kind = FEED - values = makeFeedValues() +// not on this class. Tags-only, so it extends EventReader directly. +export class Feed extends EventReader { + static kind = FEED - protected normalizeValues(values: Partial = {}) { - return makeFeedValues(values) - } - - protected parseEvent(event: TrustedEvent): Partial { - const feed = getTagValue("feed", event.tags) - - if (feed == null) { - throw new Error(`Expected a "feed" tag on kind ${this.kind} event`) + protected validate() { + if (!this.identifier()) { + throw new Error("Feed requires a d tag") } - return { - identifier: getIdentifier(event) || "", - title: getTagValue("title", event.tags) || "", - description: getTagValue("description", event.tags) || "", - definition: parseJson(feed), + if (getTagValue("feed", this.event.tags) == null) { + throw new Error("Feed requires a feed tag") } } - identifier() { - return this.values.identifier + protected reservedTagKeys() { + return ["d", "alt", "title", "description", "feed"] } title() { - return this.values.title + return getTagValue("title", this.event.tags) || "" } description() { - return this.values.description + return getTagValue("description", this.event.tags) || "" } - definition() { - return this.values.definition + // The feed definition is a @welshman/feeds `IFeed` AST. That package is not a + // dependency of @welshman/domain, so it is typed as `unknown` here. + definition(): unknown { + return parseJson(getTagValue("feed", this.event.tags)) } - async toTemplate(): Promise { - const {identifier, title, description, definition} = this.values + builder() { + const builder = new FeedBuilder() - return { - kind: this.kind, - content: "", - tags: [ - ["d", identifier], - ["alt", title], - ["title", title], - ["description", description], - ["feed", JSON.stringify(definition)], - ], - } + builder.identifier = this.identifier() || "" + builder.title = this.title() + builder.description = this.description() + builder.definition = this.definition() + + return this.seedBuilder(builder) + } +} + +export class FeedBuilder extends EventBuilder { + static kind = FEED + + identifier = randomId() + title = "" + description = "" + // Default to an empty @welshman/feeds feed (a union of nothing). That package + // isn't a dependency here, so the AST is written structurally. + definition: unknown = ["union"] + + setIdentifier(identifier: string) { + this.identifier = identifier + + return this + } + + setTitle(title: string) { + this.title = title + + return this + } + + setDescription(description: string) { + this.description = description + + return this + } + + setDefinition(definition: unknown) { + this.definition = definition + + return this + } + + protected validate() { + if (!this.identifier) { + throw new Error("Feed requires a d identifier") + } + } + + protected buildTags() { + return [ + ["d", this.identifier], + ["alt", this.title], + ["title", this.title], + ["description", this.description], + ["feed", JSON.stringify(this.definition)], + ] } } diff --git a/packages/domain/src/FeedList.ts b/packages/domain/src/FeedList.ts index ebfddf8..0cce808 100644 --- a/packages/domain/src/FeedList.ts +++ b/packages/domain/src/FeedList.ts @@ -1,20 +1,37 @@ import {uniq} from "@welshman/lib" import {FEEDS, getAddressTagValues} from "@welshman/util" -import {EncryptableList} from "./List.js" +import {ListReader, ListBuilder} from "./List.js" // NIP-51 kind-10014 saved feeds list. Entries are `a` tags pointing at kind 31890 -// FEED definitions. Extends EncryptableList; exposes the addresses as a merged set. -export class FeedList extends EncryptableList { - readonly kind = FEEDS +// 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 addresses() { return uniq(getAddressTagValues(this.tags())) } + includes(address: string) { + return this.addresses().includes(address) + } + + builder() { + return this.seedList(new FeedListBuilder()) + } +} + +export class FeedListBuilder extends ListBuilder { + static kind = FEEDS + addFeed(address: string, relayHint?: string) { return this.addPublicTags(["a", address, relayHint || ""]) } + addFeedPrivately(address: string, relayHint?: string) { + return this.addPrivateTags(["a", address, relayHint || ""]) + } + removeFeed(address: string) { return this.removeTagsWithValue(address) } diff --git a/packages/domain/src/FollowList.ts b/packages/domain/src/FollowList.ts index 04b770c..6031dee 100644 --- a/packages/domain/src/FollowList.ts +++ b/packages/domain/src/FollowList.ts @@ -1,13 +1,13 @@ import {uniq} from "@welshman/lib" import {FOLLOWS, getPubkeyTagValues} from "@welshman/util" -import {EncryptableList} from "./List.js" +import {ListReader, ListBuilder} from "./List.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 `follow` accepts a full tag and `unfollow` removes by value. -export class FollowList extends EncryptableList { - readonly kind = FOLLOWS +// hashtags), so `addFollow` accepts a full tag and `removeFollow` removes by value. +export class FollowList extends ListReader { + static kind = FOLLOWS pubkeys() { return uniq(getPubkeyTagValues(this.tags())) @@ -17,11 +17,19 @@ export class FollowList extends EncryptableList { return this.pubkeys().includes(pubkey) } - follow(tag: string[]) { + builder() { + return this.seedList(new FollowListBuilder()) + } +} + +export class FollowListBuilder extends ListBuilder { + static kind = FOLLOWS + + addFollow(tag: string[]) { return this.addPublicTags(tag) } - unfollow(value: string) { + removeFollow(value: string) { return this.removeTagsWithValue(value) } } diff --git a/packages/domain/src/GroupList.ts b/packages/domain/src/GroupList.ts index 57e7ad2..e564243 100644 --- a/packages/domain/src/GroupList.ts +++ b/packages/domain/src/GroupList.ts @@ -1,16 +1,25 @@ import {uniq} from "@welshman/lib" import {COMMUNITIES, getAddressTagValues} from "@welshman/util" -import {EncryptableList} from "./List.js" +import {ListReader, ListBuilder} from "./List.js" // NIP-51 kind-10004 group (community) membership list. Entries are `a` tags -// pointing at kind-34550 community definitions. -export class GroupList extends EncryptableList { - readonly kind = COMMUNITIES +// pointing at kind-34550 community definitions, merged across public tags and +// decrypted private content. +export class GroupList extends ListReader { + static kind = COMMUNITIES addresses() { return uniq(getAddressTagValues(this.tags())) } + builder() { + return this.seedList(new GroupListBuilder()) + } +} + +export class GroupListBuilder extends ListBuilder { + static kind = COMMUNITIES + addGroup(address: string, relayHint?: string) { return this.addPublicTags(["a", address, relayHint || ""]) } diff --git a/packages/domain/src/Handler.ts b/packages/domain/src/Handler.ts index 4a5dfa7..1d12ac4 100644 --- a/packages/domain/src/Handler.ts +++ b/packages/domain/src/Handler.ts @@ -1,97 +1,136 @@ import {parseJson} from "@welshman/lib" -import {HANDLER_INFORMATION, getKindTagValues, getTagValue} from "@welshman/util" -import type {EventTemplate, TrustedEvent} from "@welshman/util" -import {DomainObject} from "./base.js" +import {HANDLER_INFORMATION, getKindTagValues} from "@welshman/util" +import {EventReader, EventBuilder} from "./base.js" -export type HandlerValues = { +// 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 +// single canonical accessor. +export type HandlerMeta = { name?: string about?: string image?: string website?: string lud16?: string nip05?: string - kinds: number[] } -export const makeHandlerValues = (values: Partial = {}): HandlerValues => ({ - kinds: [], - ...values, -}) - // NIP-89 kind-31990 handler information. Addressable (has a `d` tag); content is a -// JSON metadata blob like a profile. Holds one object with the full set of handled -// `kinds`, rather than the legacy per-kind fan-out. -export class Handler extends DomainObject { - readonly kind = HANDLER_INFORMATION - values = makeHandlerValues() +// JSON metadata blob like a profile, and the handled `kinds` are stored as `k` +// tags. `plain` is the parsed metadata object. +export class Handler extends EventReader { + static kind = HANDLER_INFORMATION - protected normalizeValues(values: Partial = {}) { - return makeHandlerValues(values) + protected async parsePlain(): Promise { + return parseJson(this.event.content) || {} } protected reservedTagKeys() { return ["k"] } - protected parseEvent(event: TrustedEvent): Partial { - const meta = parseJson(event.content) || {} - - return { - name: meta.name || meta.display_name, - about: meta.about, - image: meta.image || meta.picture, - website: meta.website, - lud16: meta.lud16, - nip05: meta.nip05, - kinds: getKindTagValues(event.tags), - } - } - name() { - return this.values.name + return this.plain.name || (this.plain as {display_name?: string}).display_name } about() { - return this.values.about + return this.plain.about } image() { - return this.values.image + return this.plain.image || (this.plain as {picture?: string}).picture } website() { - return this.values.website + return this.plain.website } lud16() { - return this.values.lud16 + return this.plain.lud16 } nip05() { - return this.values.nip05 + return this.plain.nip05 } kinds() { - return this.values.kinds + return getKindTagValues(this.event.tags) } - identifier() { - return getTagValue("d", this.extraTags) - } + builder() { + const builder = new HandlerBuilder() - async toTemplate(): Promise { - const {name, about, image, website, lud16, nip05} = this.values + 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() - const content = JSON.stringify({name, about, image, website, lud16, nip05}) - - // Rebuild `k` tags from values.kinds; everything else carries over via the - // base extraTags, appended in addBehaviorTags. - const kindTags = this.values.kinds.map(kind => ["k", String(kind)]) - - return { - kind: this.kind, - content, - tags: kindTags, - } + return this.seedBuilder(builder) + } +} + +export class HandlerBuilder extends EventBuilder { + static kind = HANDLER_INFORMATION + + name?: string + about?: string + image?: string + website?: string + lud16?: string + nip05?: string + kinds: number[] = [] + + setName(name: string) { + this.name = name + + return this + } + + setAbout(about: string) { + this.about = about + + return this + } + + setImage(image: string) { + this.image = image + + return this + } + + setWebsite(website: string) { + this.website = website + + return this + } + + setLud16(lud16: string) { + this.lud16 = lud16 + + return this + } + + setNip05(nip05: string) { + this.nip05 = nip05 + + return this + } + + setKinds(kinds: number[]) { + this.kinds = kinds + + return this + } + + protected buildContent() { + const {name, about, image, website, lud16, nip05} = this + + return JSON.stringify({name, about, image, website, lud16, nip05}) + } + + protected buildTags() { + return this.kinds.map(kind => ["k", String(kind)]) } } diff --git a/packages/domain/src/HandlerRecommendation.ts b/packages/domain/src/HandlerRecommendation.ts index a928bd8..dd0dc84 100644 --- a/packages/domain/src/HandlerRecommendation.ts +++ b/packages/domain/src/HandlerRecommendation.ts @@ -1,80 +1,82 @@ import {last} from "@welshman/lib" -import {HANDLER_RECOMMENDATION, getIdentifier, getAddressTags, getAddressTagValues} from "@welshman/util" -import type {EventTemplate, TrustedEvent} from "@welshman/util" -import {DomainObject} from "./base.js" - -export type HandlerRecommendationValues = { - // The recommended kind, stored in the `d` tag. - identifier: string - // Raw `a` tags: ["a", address, relay?, platform?]. - addresses: string[][] -} - -export const makeHandlerRecommendationValues = ( - values: Partial = {}, -): HandlerRecommendationValues => ({ - identifier: "", - addresses: [], - ...values, -}) +import {HANDLER_RECOMMENDATION, getAddressTags, getAddressTagValues} from "@welshman/util" +import {EventReader, EventBuilder} from "./base.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 DomainObject { - readonly kind = HANDLER_RECOMMENDATION - values = makeHandlerRecommendationValues() +export class HandlerRecommendation extends EventReader { + static kind = HANDLER_RECOMMENDATION - protected normalizeValues(values: Partial = {}) { - return makeHandlerRecommendationValues(values) - } - - protected parseEvent(event: TrustedEvent): Partial { - return { - identifier: getIdentifier(event) || "", - addresses: getAddressTags(event.tags), + protected validate() { + if (!this.identifier()) { + throw new Error("HandlerRecommendation requires a d tag") } } - identifier() { - return this.values.identifier + protected reservedTagKeys() { + return ["d", "a"] + } + + // Raw `a` tags: ["a", address, relay?, platform?]. + addressTags() { + return getAddressTags(this.event.tags) } addresses() { - return getAddressTagValues(this.values.addresses) + return getAddressTagValues(this.event.tags) } // Prefer the recommendation marked as a "web" handler, otherwise fall back to // the first recommendation. handlerAddress() { - const tag = this.values.addresses.find(t => last(t) === "web") || this.values.addresses[0] + const tags = this.addressTags() + const tag = tags.find(t => last(t) === "web") || tags[0] return tag?.[1] } + builder() { + const builder = new HandlerRecommendationBuilder(this.identifier() || "") + + builder.addressTags = this.addressTags() + + return this.seedBuilder(builder) + } +} + +export class HandlerRecommendationBuilder extends EventBuilder { + static kind = HANDLER_RECOMMENDATION + + // Raw `a` tags: ["a", address, relay?, platform?]. + addressTags: string[][] = [] + + constructor(public identifier: string) { + super() + } + addRecommendation(address: string, relay?: string, platform?: string) { - if (!this.values.addresses.some(t => t[1] === address)) { - this.values.addresses = [ - ...this.values.addresses, - ["a", address, relay || "", platform || ""], - ] + if (!this.addressTags.some(t => t[1] === address)) { + this.addressTags = [...this.addressTags, ["a", address, relay || "", platform || ""]] } return this } removeRecommendation(address: string) { - this.values.addresses = this.values.addresses.filter(t => t[1] !== address) + this.addressTags = this.addressTags.filter(t => t[1] !== address) return this } - async toTemplate(): Promise { - return { - kind: this.kind, - tags: [["d", this.values.identifier], ...this.values.addresses], - content: "", + protected validate() { + if (!this.identifier) { + throw new Error("HandlerRecommendation requires a d identifier") } } + + protected buildTags() { + return [["d", this.identifier], ...this.addressTags] + } } diff --git a/packages/domain/src/List.ts b/packages/domain/src/List.ts index 2839181..3cf1a30 100644 --- a/packages/domain/src/List.ts +++ b/packages/domain/src/List.ts @@ -1,34 +1,27 @@ import {nthEq, parseJson} from "@welshman/lib" import {uniqTags} from "@welshman/util" -import type {EventTemplate, TrustedEvent} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" import {decrypt} from "@welshman/signer" import type {ISigner} from "@welshman/signer" -import {DomainObject} from "./base.js" +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") -export type ListValues = { - publicTags: 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[][] - // True when `privateTags` reflects decrypted content; false when we hold - // ciphertext we couldn't read (so private entries are unknown). decrypted: boolean } -export const makeListValues = (values: Partial = {}): ListValues => ({ - publicTags: [], - privateTags: [], - decrypted: true, - ...values, -}) - // 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> => { +): Promise => { if (!event.content) return {privateTags: [], decrypted: true} if (!signer) return {privateTags: [], decrypted: false} @@ -43,46 +36,79 @@ export const decryptListContent = async ( } } -// Base for NIP-51 lists: public entries in tags, private entries as an encrypted -// JSON array in content. Subclasses fix the kind and add domain accessors. -export abstract class EncryptableList extends DomainObject { - values = makeListValues() - - protected normalizeValues(values: Partial = {}) { - return makeListValues(values) +// Read side for NIP-51 lists: public entries in tags, private entries decrypted +// from content into `plain`. Subclasses declare the kind and add domain accessors +// over `tags()`. +export abstract class ListReader extends EventReader { + protected parsePlain(signer?: ISigner) { + return decryptListContent(this.event, signer) } - protected async parseEvent(event: TrustedEvent, signer?: ISigner): Promise> { - const {privateTags, decrypted} = await decryptListContent(event, signer) + publicTags() { + return this.event.tags + } - return {publicTags: event.tags, privateTags, decrypted} + privateTags() { + return this.plain.privateTags + } + + decrypted() { + return this.plain.decrypted } tags() { - return [...this.values.publicTags, ...this.values.privateTags] + return [...this.event.tags, ...this.plain.privateTags] + } + + // Seed a list builder from this reader: public tags, decrypted private tags, + // the original ciphertext (for the undecrypted case) and the behavior tags. + protected seedList(builder: B): B { + builder.publicTags = [...this.event.tags] + builder.plain = {privateTags: [...this.plain.privateTags], decrypted: this.plain.decrypted} + builder.originalContent = this.event.content + builder.group = this.group() + builder.protect = this.protect() + builder.expires = this.expires() + + return builder + } + + abstract builder(): ListBuilder +} + +// Write side for NIP-51 lists: mutate public/private tag sets, re-encrypt the +// private set into content on emit. +export abstract class ListBuilder extends EventBuilder { + publicTags: string[][] = [] + plain: ListPlain = {privateTags: [], decrypted: true} + // Preserved ciphertext when the source list was never decrypted. + originalContent?: string + + tags() { + return [...this.publicTags, ...this.plain.privateTags] } addPublicTags(...tags: string[][]) { - this.values.publicTags = uniqTags([...this.values.publicTags, ...tags]) + this.publicTags = uniqTags([...this.publicTags, ...tags]) return this } addPrivateTags(...tags: string[][]) { - if (!this.values.decrypted) { + if (!this.plain.decrypted) { throw new Error("Cannot modify the private entries of a list that has not been decrypted") } - this.values.privateTags = uniqTags([...this.values.privateTags, ...tags]) + this.plain.privateTags = uniqTags([...this.plain.privateTags, ...tags]) return this } keepTags(pred: (tag: string[]) => boolean) { - this.values.publicTags = this.values.publicTags.filter(t => pred(t)) + this.publicTags = this.publicTags.filter(t => pred(t)) - if (this.values.decrypted) { - this.values.privateTags = this.values.privateTags.filter(t => pred(t)) + if (this.plain.decrypted) { + this.plain.privateTags = this.plain.privateTags.filter(t => pred(t)) } return this @@ -97,10 +123,10 @@ export abstract class EncryptableList extends DomainObject { } removeTags(pred: (tag: string[]) => boolean) { - this.values.publicTags = this.values.publicTags.filter(t => !pred(t)) + this.publicTags = this.publicTags.filter(t => !pred(t)) - if (this.values.decrypted) { - this.values.privateTags = this.values.privateTags.filter(t => !pred(t)) + if (this.plain.decrypted) { + this.plain.privateTags = this.plain.privateTags.filter(t => !pred(t)) } return this @@ -115,54 +141,47 @@ export abstract class EncryptableList extends DomainObject { } clearPublicTags() { - this.values.publicTags = [] + this.publicTags = [] return this } clearPrivateTags() { - if (!this.values.decrypted) { + if (!this.plain.decrypted) { throw new Error("Cannot modify the private entries of a list that has not been decrypted") } - this.values.privateTags = [] + this.plain.privateTags = [] return this } - // Remove every entry. Public tags always; private tags only when decrypted - // (an undecrypted list's ciphertext is preserved by toTemplate), mirroring how - // removeTags/keepTags leave undecrypted private entries untouched. clearTags() { - this.values.publicTags = [] + this.publicTags = [] - if (this.values.decrypted) { - this.values.privateTags = [] + if (this.plain.decrypted) { + this.plain.privateTags = [] } return this } - async toTemplate(signer?: ISigner): Promise { - const tags = this.values.publicTags + protected buildTags() { + return this.publicTags + } + protected async buildContent(signer?: ISigner): Promise { // Preserve the original ciphertext when we never decrypted it. - let content = this.event?.content || "" + if (!this.plain.decrypted) return this.originalContent || "" - if (this.values.decrypted) { - if (this.values.privateTags.length === 0) { - content = "" - } else { - if (!signer) { - throw new Error("A signer is required to encrypt the private entries of a list") - } + if (this.plain.privateTags.length === 0) return "" - const pubkey = await signer.getPubkey() - - content = await signer.nip44.encrypt(pubkey, JSON.stringify(this.values.privateTags)) - } + if (!signer) { + throw new Error("A signer is required to encrypt the private entries of a list") } - return {kind: this.kind, tags, content} + const pubkey = await signer.getPubkey() + + return signer.nip44.encrypt(pubkey, JSON.stringify(this.plain.privateTags)) } } diff --git a/packages/domain/src/MessagingRelayList.ts b/packages/domain/src/MessagingRelayList.ts index 0bbe402..b66380e 100644 --- a/packages/domain/src/MessagingRelayList.ts +++ b/packages/domain/src/MessagingRelayList.ts @@ -1,25 +1,33 @@ import {uniqBy} from "@welshman/lib" import {MESSAGING_RELAYS, getTagValues, normalizeRelayUrl} from "@welshman/util" -import {EncryptableList} from "./List.js" +import {ListReader, ListBuilder} from "./List.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 // RelayMode.Messaging marker is not used per-tag here). `urls()` drives where // encrypted DM gift-wraps are sent and fetched, so it stays a flat, normalized // set. Identical structure to BlockedRelayList/SearchRelayList. -export class MessagingRelayList extends EncryptableList { - readonly kind = MESSAGING_RELAYS +export class MessagingRelayList extends ListReader { + static kind = MESSAGING_RELAYS urls() { return uniqBy(normalizeRelayUrl, getTagValues("relay", this.tags())) } + builder() { + return this.seedList(new MessagingRelayListBuilder()) + } +} + +export class MessagingRelayListBuilder extends ListBuilder { + static kind = MESSAGING_RELAYS + addRelay(url: string) { return this.addPublicTags(["relay", normalizeRelayUrl(url)]) } removeRelay(url: string) { - return this.removeTagsWithValue(url) + return this.removeTagsWithValue(normalizeRelayUrl(url)) } setRelays(urls: string[]) { diff --git a/packages/domain/src/MuteList.ts b/packages/domain/src/MuteList.ts index 15154f7..674dc9a 100644 --- a/packages/domain/src/MuteList.ts +++ b/packages/domain/src/MuteList.ts @@ -1,11 +1,11 @@ import {uniq} from "@welshman/lib" import {MUTES, getPubkeyTagValues} from "@welshman/util" -import {EncryptableList} from "./List.js" +import {ListReader, ListBuilder} from "./List.js" // NIP-51 kind-10000 mute list. Pubkeys can be muted publicly (tags) or privately -// (encrypted content); the accessors treat both as one merged set. -export class MuteList extends EncryptableList { - readonly kind = MUTES +// (encrypted content); the reader treats both as one merged set. +export class MuteList extends ListReader { + static kind = MUTES pubkeys() { return uniq(getPubkeyTagValues(this.tags())) @@ -15,6 +15,14 @@ export class MuteList extends EncryptableList { return this.pubkeys().includes(pubkey) } + builder() { + return this.seedList(new MuteListBuilder()) + } +} + +export class MuteListBuilder extends ListBuilder { + static kind = MUTES + mutePublicly(pubkey: string) { return this.addPublicTags(["p", pubkey]) } diff --git a/packages/domain/src/PinList.ts b/packages/domain/src/PinList.ts index f5420b3..1799962 100644 --- a/packages/domain/src/PinList.ts +++ b/packages/domain/src/PinList.ts @@ -1,12 +1,13 @@ import {uniq} from "@welshman/lib" import {PINS, getEventTagValues, getAddressTagValues} from "@welshman/util" -import {EncryptableList} from "./List.js" +import {ListReader, ListBuilder} from "./List.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. -export class PinList extends EncryptableList { - readonly kind = PINS +// 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 ids() { return uniq(getEventTagValues(this.tags())) @@ -16,11 +17,24 @@ export class PinList extends EncryptableList { return uniq(getAddressTagValues(this.tags())) } + builder() { + return this.seedList(new PinListBuilder()) + } +} + +export class PinListBuilder extends ListBuilder { + static kind = PINS + // Pin a full tag (e.g. ["e", id, ...] or ["a", address, ...]) publicly. - pin(tag: string[]) { + pinPublicly(tag: string[]) { return this.addPublicTags(tag) } + // Pin a full tag (e.g. ["e", id, ...] or ["a", address, ...]) privately. + pinPrivately(tag: string[]) { + return this.addPrivateTags(tag) + } + unpin(value: string) { return this.removeTagsWithValue(value) } diff --git a/packages/domain/src/Poll.ts b/packages/domain/src/Poll.ts index 4efb535..78a583e 100644 --- a/packages/domain/src/Poll.ts +++ b/packages/domain/src/Poll.ts @@ -1,7 +1,8 @@ -import {now, uniq} from "@welshman/lib" +import {now, uniq, randomId} from "@welshman/lib" import {POLL, getTagValue, getTagValues} from "@welshman/util" -import type {EventTemplate, TrustedEvent} from "@welshman/util" -import {DomainObject} from "./base.js" +import type {TrustedEvent} from "@welshman/util" +import type {ISigner} from "@welshman/signer" +import {EventReader, EventBuilder} from "./base.js" export type PollType = "singlechoice" | "multiplechoice" @@ -15,79 +16,61 @@ export type PollResult = { voters: number } -export type PollValues = { - title: string - options: PollOption[] - pollType: PollType - endsAt?: number - relays: string[] -} - -export const makePollValues = (values: Partial = {}): PollValues => ({ - title: "", - options: [], - pollType: "singlechoice", - relays: [], - ...values, -}) - // NIP-88 kind-1068 poll. The poll title/question lives in `content` as plain // 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 DomainObject { - readonly kind = POLL - values = makePollValues() +export class Poll extends EventReader { + static kind = POLL - protected normalizeValues(values: Partial = {}) { - return makePollValues(values) - } - - protected parseEvent(event: TrustedEvent): Partial { - const endsAtRaw = getTagValue("endsAt", event.tags) - const endsAt = endsAtRaw == null ? NaN : parseInt(endsAtRaw) - - return { - title: event.content || "", - options: event.tags - .filter(t => t[0] === "option") - .map(t => ({id: t[1], label: t[2] || t[1]})), - pollType: (getTagValue("polltype", event.tags) as PollType) || "singlechoice", - endsAt: Number.isNaN(endsAt) ? undefined : endsAt, - relays: getTagValues("relay", event.tags), + protected validate() { + if (this.options().length === 0) { + throw new Error("Poll requires at least one option tag") } } + protected reservedTagKeys() { + return ["option", "polltype", "endsAt", "relay"] + } + + // The poll title/question is plain-text content. title() { - return this.values.title + return this.event.content || "" } - options() { - return this.values.options + options(): PollOption[] { + return this.event.tags + .filter(t => t[0] === "option") + .map(t => ({id: t[1], label: t[2] || t[1]})) } - pollType() { - return this.values.pollType + pollType(): PollType { + return (getTagValue("polltype", this.event.tags) as PollType) || "singlechoice" } endsAt() { - return this.values.endsAt + const endsAt = parseInt(getTagValue("endsAt", this.event.tags) ?? "") + + return isNaN(endsAt) ? undefined : endsAt } isClosed() { - return this.values.endsAt != null && this.values.endsAt <= now() + const endsAt = this.endsAt() + + return endsAt != null && endsAt <= now() } relays() { - return this.values.relays + return getTagValues("relay", this.event.tags) } // Tally the latest response per pubkey across the poll options. Each response // is a kind-1018 event whose "response" tags name selected option ids; // single-choice polls only honor the first selection. results(responses: TrustedEvent[]): PollResult { - const options = this.values.options.map(option => ({...option, votes: 0})) + const options = this.options().map(option => ({...option, votes: 0})) const counts = new Map(options.map(option => [option.id, option])) const latestByPubkey = new Map() + const pollType = this.pollType() for (const response of responses) { const current = latestByPubkey.get(response.pubkey) @@ -99,8 +82,7 @@ export class Poll extends DomainObject { for (const response of latestByPubkey.values()) { const selections = getTagValues("response", response.tags) - const ids = - this.values.pollType === "singlechoice" ? selections.slice(0, 1) : uniq(selections) + const ids = pollType === "singlechoice" ? selections.slice(0, 1) : uniq(selections) for (const id of ids) { const option = counts.get(id) @@ -114,20 +96,84 @@ export class Poll extends DomainObject { return {options, voters: latestByPubkey.size} } - async toTemplate(): Promise { + 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) + } +} + +export class PollBuilder extends EventBuilder { + static kind = POLL + + options: PollOption[] = [] + pollType: PollType = "singlechoice" + endsAt?: number + relays: string[] = [] + + constructor(public title = "") { + super() + } + + setTitle(title: string) { + this.title = title + + return this + } + + addOption(label: string, id = randomId()) { + this.options = [...this.options, {id, label}] + + return this + } + + setPollType(pollType: PollType) { + this.pollType = pollType + + return this + } + + setEndsAt(endsAt: number) { + this.endsAt = endsAt + + return this + } + + setRelays(relays: string[]) { + this.relays = relays + + return this + } + + protected validate() { + if (this.options.length === 0) { + throw new Error("Poll requires at least one option") + } + } + + protected buildContent(_signer?: ISigner) { + return this.title + } + + protected buildTags() { const tags: string[][] = [ - ...this.values.options.map(o => ["option", o.id, o.label]), - ["polltype", this.values.pollType], + ...this.options.map(o => ["option", o.id, o.label]), + ["polltype", this.pollType], ] - if (this.values.endsAt != null) { - tags.push(["endsAt", String(this.values.endsAt)]) + if (this.endsAt != null) { + tags.push(["endsAt", String(this.endsAt)]) } - for (const relay of this.values.relays) { + for (const relay of this.relays) { tags.push(["relay", relay]) } - return {kind: this.kind, content: this.values.title, tags} + return tags } } diff --git a/packages/domain/src/PollResponse.ts b/packages/domain/src/PollResponse.ts index 2842912..99cbc3f 100644 --- a/packages/domain/src/PollResponse.ts +++ b/packages/domain/src/PollResponse.ts @@ -1,55 +1,66 @@ import {uniq} from "@welshman/lib" import {POLL_RESPONSE, getTagValue, getTagValues} from "@welshman/util" -import type {EventTemplate, TrustedEvent} from "@welshman/util" -import {DomainObject} from "./base.js" - -export type PollResponseValues = { - pollId: string - selections: string[] -} - -export const makePollResponseValues = ( - values: Partial = {}, -): PollResponseValues => ({ - pollId: "", - selections: [], - ...values, -}) +import {EventReader, EventBuilder} from "./base.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 DomainObject directly rather than the encryptable list base. -export class PollResponse extends DomainObject { - readonly kind = POLL_RESPONSE - values = makePollResponseValues() +// content, so it extends EventReader/EventBuilder directly. +export class PollResponse extends EventReader { + static kind = POLL_RESPONSE - protected normalizeValues(values: Partial = {}) { - return makePollResponseValues(values) + protected validate() { + if (!this.pollId()) { + throw new Error("PollResponse requires an e tag") + } } - protected parseEvent(event: TrustedEvent): Partial { - return { - pollId: getTagValue("e", event.tags) || "", - selections: getTagValues("response", event.tags), - } + protected reservedTagKeys() { + return ["e", "response"] } pollId() { - return this.values.pollId + return getTagValue("e", this.event.tags) || "" } selections() { - return uniq(this.values.selections) + return uniq(getTagValues("response", this.event.tags)) } - async toTemplate(): Promise { - return { - kind: this.kind, - content: "", - tags: [ - ["e", this.values.pollId], - ...this.values.selections.map(id => ["response", id]), - ], - } + builder() { + const builder = new PollResponseBuilder() + + builder.pollId = this.pollId() + builder.selections = this.selections() + + return this.seedBuilder(builder) + } +} + +export class PollResponseBuilder extends EventBuilder { + static kind = POLL_RESPONSE + + pollId = "" + selections: string[] = [] + + setPollId(pollId: string) { + this.pollId = pollId + + return this + } + + addSelection(id: string) { + this.selections = uniq([...this.selections, id]) + + return this + } + + protected validate() { + if (!this.pollId) { + throw new Error("PollResponse requires a pollId") + } + } + + protected buildTags() { + return [["e", this.pollId], ...this.selections.map(id => ["response", id])] } } diff --git a/packages/domain/src/Profile.ts b/packages/domain/src/Profile.ts index 0a47f5c..d3abede 100644 --- a/packages/domain/src/Profile.ts +++ b/packages/domain/src/Profile.ts @@ -1,8 +1,8 @@ import {npubEncode} from "nostr-tools/nip19" import {ellipsize, parseJson} from "@welshman/lib" import {PROFILE, getLnUrl} from "@welshman/util" -import type {EventTemplate, TrustedEvent} from "@welshman/util" -import {DomainObject} from "./base.js" +import type {ISigner} from "@welshman/signer" +import {EventReader, EventBuilder} from "./base.js" export type ProfileValues = { name?: string @@ -40,60 +40,115 @@ export const displayPubkey = (pubkey: string) => { return d.slice(0, 8) + "…" + d.slice(-5) } -export class Profile extends DomainObject { - readonly kind = PROFILE - values = makeProfileValues() +// Read side for a NIP-01 kind-0 profile. The metadata lives in the JSON content, +// parsed once into `plain` (a ProfileValues with `lnurl` derived from lud06/lud16). +// Accessors read `this.plain`; there are no represented tags. +export class Profile extends EventReader { + static kind = PROFILE - protected normalizeValues(values: Partial = {}) { - return makeProfileValues(values) - } - - protected parseEvent(event: TrustedEvent): Partial { - return parseJson(event.content) || {} + protected parsePlain() { + return makeProfileValues(parseJson(this.event.content) || {}) } name() { - return this.values.name || this.values.display_name + return this.plain.name || this.plain.display_name } nip05() { - return this.values.nip05 + return this.plain.nip05 } lnurl() { - return this.values.lnurl + return this.plain.lnurl } about() { - return this.values.about + return this.plain.about } banner() { - return this.values.banner + return this.plain.banner } picture() { - return this.values.picture + return this.plain.picture } website() { - return this.values.website + return this.plain.website } display(fallback = "") { const name = this.name() if (name) return ellipsize(name, 60).trim() - if (this.event) return displayPubkey(this.event.pubkey).trim() - return fallback.trim() + return displayPubkey(this.event.pubkey).trim() || fallback.trim() } - async toTemplate(): Promise { - return { - kind: this.kind, - content: JSON.stringify(this.values), - tags: this.event?.tags || [], - } + builder() { + const builder = new ProfileBuilder() + + builder.values = makeProfileValues(this.plain) + + return this.seedBuilder(builder) + } +} + +// Write side for a NIP-01 kind-0 profile. Holds the profile fields and serializes +// them to JSON content; emits no profile-specific tags. +export class ProfileBuilder extends EventBuilder { + static kind = PROFILE + + values: ProfileValues = makeProfileValues() + + setValues(values: Partial) { + this.values = makeProfileValues(values) + + return this + } + + setName(name: string) { + this.values = makeProfileValues({...this.values, name}) + + return this + } + + setNip05(nip05: string) { + this.values = makeProfileValues({...this.values, nip05}) + + return this + } + + setAbout(about: string) { + this.values = makeProfileValues({...this.values, about}) + + return this + } + + setBanner(banner: string) { + this.values = makeProfileValues({...this.values, banner}) + + return this + } + + setPicture(picture: string) { + this.values = makeProfileValues({...this.values, picture}) + + return this + } + + setWebsite(website: string) { + this.values = makeProfileValues({...this.values, website}) + + return this + } + + protected buildTags() { + return [] + } + + protected buildContent(_signer?: ISigner) { + return JSON.stringify(this.values) } } diff --git a/packages/domain/src/RelayInvite.ts b/packages/domain/src/RelayInvite.ts index d0f047b..9400c32 100644 --- a/packages/domain/src/RelayInvite.ts +++ b/packages/domain/src/RelayInvite.ts @@ -1,44 +1,46 @@ import {RELAY_INVITE, getTagValue} from "@welshman/util" -import type {EventTemplate, TrustedEvent} from "@welshman/util" -import {DomainObject} from "./base.js" - -export type RelayInviteValues = { - claim?: string -} - -export const makeRelayInviteValues = ( - values: Partial = {}, -): RelayInviteValues => ({ - ...values, -}) +import {EventReader, EventBuilder} from "./base.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 DomainObject directly. -export class RelayInvite extends DomainObject { - readonly kind = RELAY_INVITE - values = makeRelayInviteValues() +// Tags-only content, so it extends EventReader/EventBuilder directly. +export class RelayInvite extends EventReader { + static kind = RELAY_INVITE - protected normalizeValues(values: Partial = {}) { - return makeRelayInviteValues(values) - } - - protected parseEvent(event: TrustedEvent): Partial { - return { - claim: getTagValue("claim", event.tags), - } + protected reservedTagKeys() { + return ["claim"] } claim() { - return this.values.claim + return getTagValue("claim", this.event.tags) } - async toTemplate(): Promise { - return { - kind: this.kind, - tags: this.values.claim ? [["claim", this.values.claim]] : [], - content: "", - } + builder() { + const builder = new RelayInviteBuilder() + + builder.claim = this.claim() + + return this.seedBuilder(builder) + } +} + +export class RelayInviteBuilder extends EventBuilder { + static kind = RELAY_INVITE + + claim?: string + + setClaim(claim: string) { + this.claim = claim + + return this + } + + protected buildTags() { + const tags: string[][] = [] + + if (this.claim) tags.push(["claim", this.claim]) + + return tags } } diff --git a/packages/domain/src/RelayJoin.ts b/packages/domain/src/RelayJoin.ts index 80e304e..e495366 100644 --- a/packages/domain/src/RelayJoin.ts +++ b/packages/domain/src/RelayJoin.ts @@ -1,55 +1,70 @@ import {RELAY_JOIN, getTagValue} from "@welshman/util" -import type {EventTemplate, TrustedEvent} from "@welshman/util" -import {DomainObject} from "./base.js" - -export type RelayJoinValues = { - claim?: string - reason?: string -} - -export const makeRelayJoinValues = (values: Partial = {}): RelayJoinValues => ({ - ...values, -}) +import type {ISigner} from "@welshman/signer" +import {EventReader, EventBuilder} from "./base.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). Tags-plus-content, so it extends -// DomainObject directly. -export class RelayJoin extends DomainObject { - readonly kind = RELAY_JOIN - values = makeRelayJoinValues() +// machine (RELAY_JOIN -> Pending/Granted). The content is the plain free-text +// reason, so `plain` is the (possibly undefined) reason string. +export class RelayJoin extends EventReader { + static kind = RELAY_JOIN - protected normalizeValues(values: Partial = {}) { - return makeRelayJoinValues(values) + protected async parsePlain() { + return this.event.content || undefined } - protected parseEvent(event: TrustedEvent): Partial { - return { - claim: getTagValue("claim", event.tags), - reason: event.content || undefined, - } + protected reservedTagKeys() { + return ["claim"] } claim() { - return this.values.claim + return getTagValue("claim", this.event.tags) } reason() { - return this.values.reason + return this.plain } - async toTemplate(): Promise { - const tags: string[][] = [] + builder() { + const builder = new RelayJoinBuilder() - if (this.values.claim) { - tags.push(["claim", this.values.claim]) - } + builder.claim = this.claim() + builder.reason = this.reason() - return { - kind: this.kind, - tags, - content: this.values.reason || "", - } + builder.plain = this.plain + + return this.seedBuilder(builder) + } +} + +export class RelayJoinBuilder extends EventBuilder { + static kind = RELAY_JOIN + + claim?: string + reason?: string + + setClaim(claim: string) { + this.claim = claim + + return this + } + + setReason(reason: string) { + this.reason = reason + + return this + } + + protected buildTags() { + const tags: string[][] = [] + + if (this.claim) tags.push(["claim", this.claim]) + + return tags + } + + protected buildContent(_signer?: ISigner) { + return this.reason || "" } } diff --git a/packages/domain/src/RelayLeave.ts b/packages/domain/src/RelayLeave.ts index f90d8d7..3a71d29 100644 --- a/packages/domain/src/RelayLeave.ts +++ b/packages/domain/src/RelayLeave.ts @@ -1,34 +1,22 @@ import {RELAY_LEAVE} from "@welshman/util" -import type {EventTemplate} from "@welshman/util" -import {DomainObject} from "./base.js" - -export type RelayLeaveValues = {} - -export const makeRelayLeaveValues = (values: Partial = {}): RelayLeaveValues => ({ - ...values, -}) +import {EventReader, EventBuilder} from "./base.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). State-free, so it extends DomainObject directly. -export class RelayLeave extends DomainObject { - readonly kind = RELAY_LEAVE - values = makeRelayLeaveValues() +// Initial). Tags-only (in fact tag-free) content. +export class RelayLeave extends EventReader { + static kind = RELAY_LEAVE - protected normalizeValues(values: Partial = {}) { - return makeRelayLeaveValues(values) - } - - protected parseEvent(): Partial { - return {} - } - - async toTemplate(): Promise { - return { - kind: this.kind, - tags: [], - content: "", - } + builder() { + return this.seedBuilder(new RelayLeaveBuilder()) + } +} + +export class RelayLeaveBuilder extends EventBuilder { + static kind = RELAY_LEAVE + + protected buildTags() { + return [] as string[][] } } diff --git a/packages/domain/src/RelayList.ts b/packages/domain/src/RelayList.ts index bc06116..4645ac8 100644 --- a/packages/domain/src/RelayList.ts +++ b/packages/domain/src/RelayList.ts @@ -1,13 +1,13 @@ import {uniq, uniqBy} from "@welshman/lib" import {RELAYS, RelayMode, getRelayTags, getRelayTagValues, normalizeRelayUrl} from "@welshman/util" -import {EncryptableList} from "./List.js" +import {ListReader, ListBuilder} from "./List.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 EncryptableList { - readonly kind = RELAYS +export class RelayList extends ListReader { + static kind = RELAYS // All relay urls, deduped by normalized url. urls() { @@ -34,26 +34,56 @@ export class RelayList extends EncryptableList { ) } + builder() { + return this.seedList(new RelayListBuilder()) + } +} + +export class RelayListBuilder extends ListBuilder { + static kind = RELAYS + + // Relays usable for reading: includes modeless (both) entries. + readUrls() { + return uniqBy( + normalizeRelayUrl, + getRelayTags(this.publicTags) + .filter(t => !t[2] || t[2] === RelayMode.Read) + .map(t => t[1]), + ) + } + + // Relays usable for writing: includes modeless (both) entries. + writeUrls() { + return uniqBy( + normalizeRelayUrl, + getRelayTags(this.publicTags) + .filter(t => !t[2] || t[2] === RelayMode.Write) + .map(t => t[1]), + ) + } + // Upsert a relay for a given mode. If an existing entry already covered the // complementary mode (or was modeless), collapse to a modeless ["r", url] tag; // otherwise store ["r", url, mode]. addRelay(url: string, mode: RelayMode) { const normalized = normalizeRelayUrl(url) - const existing = getRelayTags(this.values.publicTags).filter( + const existing = getRelayTags(this.publicTags).filter( t => normalizeRelayUrl(t[1]) === normalized, ) // Modes already covered by existing entries (undefined marker = both). - const priorModes = new Set(existing.map(t => t[2] as RelayMode | undefined)) + const priorModes = new Set( + existing.map(t => t[2] as RelayMode | undefined), + ) const alt = mode === RelayMode.Read ? RelayMode.Write : RelayMode.Read const coversAlt = priorModes.has(undefined) || priorModes.has(alt) - this.values.publicTags = this.values.publicTags.filter( + this.publicTags = this.publicTags.filter( t => !(t[0] === "r" && normalizeRelayUrl(t[1]) === normalized), ) - this.values.publicTags.push(coversAlt ? ["r", url] : ["r", url, mode]) + this.publicTags.push(coversAlt ? ["r", url] : ["r", url, mode]) return this } @@ -63,7 +93,7 @@ export class RelayList extends EncryptableList { // covered `mode` is fully removed. removeRelay(url: string, mode: RelayMode) { const normalized = normalizeRelayUrl(url) - const existing = getRelayTags(this.values.publicTags).filter( + const existing = getRelayTags(this.publicTags).filter( t => normalizeRelayUrl(t[1]) === normalized, ) @@ -72,12 +102,12 @@ export class RelayList extends EncryptableList { // Keep the alternate if any existing entry was modeless/both or the alt mode. const keepAlt = existing.some(t => !t[2] || t[2] === alt) - this.values.publicTags = this.values.publicTags.filter( + this.publicTags = this.publicTags.filter( t => !(t[0] === "r" && normalizeRelayUrl(t[1]) === normalized), ) if (keepAlt) { - this.values.publicTags.push(["r", url, alt]) + this.publicTags.push(["r", url, alt]) } return this @@ -101,7 +131,7 @@ export class RelayList extends EncryptableList { private setRelaysForModes(readUrls: string[], writeUrls: string[]) { const read = new Set(readUrls.map(normalizeRelayUrl)) const write = new Set(writeUrls.map(normalizeRelayUrl)) - const otherTags = this.values.publicTags.filter(t => t[0] !== "r") + const otherTags = this.publicTags.filter(t => t[0] !== "r") const relayTags = uniq([...read, ...write]).map(url => read.has(url) && write.has(url) ? ["r", url] @@ -110,14 +140,14 @@ export class RelayList extends EncryptableList { : ["r", url, RelayMode.Write], ) - this.values.publicTags = [...otherTags, ...relayTags] + this.publicTags = [...otherTags, ...relayTags] return this } // Replace the entire public tag set. setRelays(tags: string[][]) { - this.values.publicTags = tags + this.publicTags = tags return this } diff --git a/packages/domain/src/RelayMembers.ts b/packages/domain/src/RelayMembers.ts index 2db6b6d..7b6a7d5 100644 --- a/packages/domain/src/RelayMembers.ts +++ b/packages/domain/src/RelayMembers.ts @@ -1,61 +1,52 @@ import {uniq} from "@welshman/lib" -import {RELAY_MEMBERS, getTagValues} from "@welshman/util" -import type {EventTemplate, TrustedEvent} from "@welshman/util" -import {DomainObject} from "./base.js" - -export type RelayMembersValues = { - members: string[] -} - -export const makeRelayMembersValues = ( - values: Partial = {}, -): RelayMembersValues => ({ - members: [], - ...values, -}) +import {RELAY_MEMBERS, getPubkeyTagValues} from "@welshman/util" +import {EventReader, EventBuilder} from "./base.js" // Flotilla relay-wide (space) member-list snapshot, replaceable kind 13534. -// Members are stored under "member" tags (tag[0] === "member"), NOT "p" tags, -// so parsing uses getTagValues("member", ...) rather than getPubkeyTagValues. -// Not addressable (no "d" tag); tags-only content, so it extends DomainObject -// directly rather than the encryptable list base. -export class RelayMembers extends DomainObject { - readonly kind = RELAY_MEMBERS - values = makeRelayMembersValues() +// 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 normalizeValues(values: Partial = {}) { - return makeRelayMembersValues(values) + protected reservedTagKeys() { + return ["p"] } - protected parseEvent(event: TrustedEvent): Partial { - return { - members: uniq(getTagValues("member", event.tags)), - } - } - - members() { - return this.values.members + pubkeys() { + return uniq(getPubkeyTagValues(this.event.tags)) } isMember(pubkey: string) { - return this.values.members.includes(pubkey) + return this.pubkeys().includes(pubkey) } - addMember(pubkey: string) { - this.values.members = uniq([...this.values.members, pubkey]) + builder() { + const builder = new RelayMembersBuilder() - return this - } + builder.pubkeys = this.pubkeys() - removeMember(pubkey: string) { - this.values.members = this.values.members.filter(pk => pk !== pubkey) - - return this - } - - async toTemplate(): Promise { - const tags: string[][] = this.values.members.map(pk => ["member", pk]) - - return {kind: this.kind, tags, content: ""} + return this.seedBuilder(builder) + } +} + +export class RelayMembersBuilder extends EventBuilder { + static kind = RELAY_MEMBERS + + pubkeys: string[] = [] + + addPubkey(pubkey: string) { + this.pubkeys = uniq([...this.pubkeys, pubkey]) + + return this + } + + removePubkey(pubkey: string) { + this.pubkeys = this.pubkeys.filter(pk => pk !== pubkey) + + return this + } + + protected buildTags() { + return this.pubkeys.map(pk => ["p", pk]) } } diff --git a/packages/domain/src/RelayMembershipOp.ts b/packages/domain/src/RelayMembershipOp.ts index 1d9e923..40f814b 100644 --- a/packages/domain/src/RelayMembershipOp.ts +++ b/packages/domain/src/RelayMembershipOp.ts @@ -1,54 +1,72 @@ import {uniq} from "@welshman/lib" import {RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, getPubkeyTagValues} from "@welshman/util" -import type {EventTemplate, TrustedEvent} from "@welshman/util" -import {DomainObject} from "./base.js" +import {EventReader, EventBuilder} from "./base.js" -export type RelayMembershipValues = { - pubkeys: string[] -} - -export const makeRelayMembershipValues = ( - values: Partial = {}, -): RelayMembershipValues => ({ - pubkeys: [], - ...values, -}) - -// Relay/space-level moderation op carrying the affected pubkeys in "p" tags. Add -// (kind 8000) and remove (kind 8001) are regular (non-addressable) events that -// share this shape; each is its own concrete class fixing the kind. +// Relay/space-level moderation op for adding (kind 8000) or removing (kind 8001) +// members. Regular (non-addressable) events carrying the affected pubkeys in "p" +// tags. Unlike RoomMembershipOp these are relay-scoped, not room-scoped, so there +// is no group ("h") tag — just the "p" tags. Add and remove share this shape; +// each is its own concrete reader/builder fixing the kind via a static field. // // Flotilla's deriveUserSpaceMembershipStatus replays this history (RelayAddMember // => isMember true, RelayRemoveMember => isMember false) when no RELAY_MEMBERS // snapshot is available. -export abstract class RelayMembershipOp extends DomainObject { - values = makeRelayMembershipValues() - - protected normalizeValues(values: Partial = {}) { - return makeRelayMembershipValues(values) - } - - protected parseEvent(event: TrustedEvent): Partial { - return {pubkeys: uniq(getPubkeyTagValues(event.tags))} +export abstract class RelayMembershipOp extends EventReader { + protected reservedTagKeys() { + return ["p"] } + // The affected pubkeys, deduped. pubkeys() { - return this.values.pubkeys + return uniq(getPubkeyTagValues(this.event.tags)) } - async toTemplate(): Promise { - return { - kind: this.kind, - tags: this.values.pubkeys.map(pk => ["p", pk]), - content: "", - } + abstract builder(): RelayMembershipOpBuilder +} + +// Shared write side: collect pubkeys, emit them as "p" tags. +export abstract class RelayMembershipOpBuilder extends EventBuilder { + pubkeys: string[] = [] + + addPubkey(pubkey: string) { + this.pubkeys = uniq([...this.pubkeys, pubkey]) + + return this + } + + protected buildTags() { + return this.pubkeys.map(pk => ["p", pk]) } } export class RelayAddMember extends RelayMembershipOp { - readonly kind = RELAY_ADD_MEMBER + static kind = RELAY_ADD_MEMBER + + builder() { + const builder = new RelayAddMemberBuilder() + + builder.pubkeys = this.pubkeys() + + return this.seedBuilder(builder) + } +} + +export class RelayAddMemberBuilder extends RelayMembershipOpBuilder { + static kind = RELAY_ADD_MEMBER } export class RelayRemoveMember extends RelayMembershipOp { - readonly kind = RELAY_REMOVE_MEMBER + static kind = RELAY_REMOVE_MEMBER + + builder() { + const builder = new RelayRemoveMemberBuilder() + + builder.pubkeys = this.pubkeys() + + return this.seedBuilder(builder) + } +} + +export class RelayRemoveMemberBuilder extends RelayMembershipOpBuilder { + static kind = RELAY_REMOVE_MEMBER } diff --git a/packages/domain/src/RelaySet.ts b/packages/domain/src/RelaySet.ts index af0b820..f9e9206 100644 --- a/packages/domain/src/RelaySet.ts +++ b/packages/domain/src/RelaySet.ts @@ -1,35 +1,94 @@ -import {uniqBy} from "@welshman/lib" +import {randomId, uniqBy} from "@welshman/lib" import {NAMED_RELAYS, getTagValue, getTagValues, normalizeRelayUrl} from "@welshman/util" -import {EncryptableList} from "./List.js" +import {ListReader, ListBuilder} from "./List.js" // 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 // the other NIP-51 relay lists, NOT NIP-65 'r' tags with read/write markers). // It also carries optional set metadata (title/description/image) used to label // the set in UIs. -export class RelaySet extends EncryptableList { - readonly kind = NAMED_RELAYS +export class RelaySet extends ListReader { + static kind = NAMED_RELAYS - identifier() { - return getTagValue("d", this.tags()) || "" + protected validate() { + if (!this.identifier()) { + throw new Error("RelaySet requires a d tag") + } + } + + protected reservedTagKeys() { + return ["d", "title", "description", "image", "relay"] } title() { - return getTagValue("title", this.tags()) + return getTagValue("title", this.event.tags) } description() { - return getTagValue("description", this.tags()) + return getTagValue("description", this.event.tags) } image() { - return getTagValue("image", this.tags()) + return getTagValue("image", this.event.tags) } urls() { return uniqBy(normalizeRelayUrl, getTagValues("relay", this.tags())) } + 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 + } +} + +export class RelaySetBuilder extends ListBuilder { + static kind = NAMED_RELAYS + + identifier = randomId() + title?: string + description?: string + image?: string + + setIdentifier(identifier: string) { + this.identifier = identifier + + return this + } + + setTitle(title: string) { + this.title = title + + return this + } + + setDescription(description: string) { + this.description = description + + return this + } + + setImage(image: string) { + this.image = image + + return this + } + addRelay(url: string) { return this.addPublicTags(["relay", normalizeRelayUrl(url)]) } @@ -45,27 +104,21 @@ export class RelaySet extends EncryptableList { return this.addPublicTags(...urls.map(url => ["relay", normalizeRelayUrl(url)])) } - setIdentifier(identifier: string) { - this.removeTagsWithKey("d") - - return this.addPublicTags(["d", identifier]) + protected validate() { + if (!this.identifier) { + throw new Error("RelaySet requires a d identifier") + } } - setTitle(title: string) { - this.removeTagsWithKey("title") + protected buildTags() { + const tags: string[][] = [["d", this.identifier]] - return this.addPublicTags(["title", title]) - } + if (this.title) tags.push(["title", this.title]) + if (this.description) tags.push(["description", this.description]) + if (this.image) tags.push(["image", this.image]) - setDescription(description: string) { - this.removeTagsWithKey("description") - - return this.addPublicTags(["description", description]) - } - - setImage(image: string) { - this.removeTagsWithKey("image") - - return this.addPublicTags(["image", image]) + // Append the public list entries (relay tags); the base re-encrypts the + // private tags into content separately. + return [...tags, ...this.publicTags] } } diff --git a/packages/domain/src/Report.ts b/packages/domain/src/Report.ts index 0e00c6a..bca540f 100644 --- a/packages/domain/src/Report.ts +++ b/packages/domain/src/Report.ts @@ -1,79 +1,99 @@ import {REPORT, getTag, getTagValue} from "@welshman/util" -import type {EventTemplate, TrustedEvent} from "@welshman/util" -import {DomainObject} from "./base.js" - -export type ReportValues = { - pubkey?: string - eventId?: string - reason?: string - content: string -} - -export const makeReportValues = (values: Partial = {}): ReportValues => ({ - content: "", - ...values, -}) +import {EventReader, EventBuilder} from "./base.js" // NIP-56 kind-1984 report, feeding flotilla's admin action-items / moderation // review queue (see app/actionItems.ts `deriveSpaceActionItems`). The reported // author is named in the "p" tag and the reported event in the "e" tag, with the // 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; `reason()` centralizes that access. The report body lives in -// `content` as plain text (not JSON). -export class Report extends DomainObject { - readonly kind = REPORT - values = makeReportValues() +// ReportMenu.svelte; the accessors centralize that access. The report body lives +// in `content` as plain text (not JSON), so there's no `plain` representation. +export class Report extends EventReader { + static kind = REPORT - protected normalizeValues(values: Partial = {}) { - return makeReportValues(values) + protected reservedTagKeys() { + return ["p", "e"] } - protected parseEvent(event: TrustedEvent): Partial { - const eTag = getTag("e", event.tags) - - return { - pubkey: getTagValue("p", event.tags), - eventId: eTag?.[1], - reason: eTag?.[2], - content: event.content || "", - } - } - - pubkey() { - return this.values.pubkey + // The reported author. Distinct from the base `pubkey()` (the reporter). + reportedPubkey() { + return getTagValue("p", this.event.tags) } + // The reported event, if any. eventId() { - return this.values.eventId + return getTag("e", this.event.tags)?.[1] } + // The report reason, carried as the 3rd element of the "e" tag. reason() { - return this.values.reason + return getTag("e", this.event.tags)?.[2] } + // The report body, plain text. content() { - return this.values.content + return this.event.content || "" } - setContent(content: string) { - this.values.content = content + 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) + } +} + +export class ReportBuilder extends EventBuilder { + static kind = REPORT + + reportedPubkey?: string + eventId?: string + reason?: string + content = "" + + setReportedPubkey(reportedPubkey: string) { + this.reportedPubkey = reportedPubkey return this } - async toTemplate(): Promise { - const {pubkey, eventId, reason, content} = this.values + setEventId(eventId: string) { + this.eventId = eventId + + return this + } + + setReason(reason: string) { + this.reason = reason + + return this + } + + setContent(content: string) { + this.content = content + + return this + } + + protected buildTags() { const tags: string[][] = [] - if (pubkey) { - tags.push(["p", pubkey]) + if (this.reportedPubkey) { + tags.push(["p", this.reportedPubkey]) } - if (eventId) { - tags.push(["e", eventId, ...(reason ? [reason] : [])]) + if (this.eventId) { + tags.push(["e", this.eventId, ...(this.reason ? [this.reason] : [])]) } - return {kind: this.kind, content, tags} + return tags + } + + protected buildContent() { + return this.content } } diff --git a/packages/domain/src/RoomAdmins.ts b/packages/domain/src/RoomAdmins.ts index f88beff..32dd7de 100644 --- a/packages/domain/src/RoomAdmins.ts +++ b/packages/domain/src/RoomAdmins.ts @@ -1,71 +1,68 @@ -import {uniq} from "@welshman/lib" -import {ROOM_ADMINS, getIdentifier, getPubkeyTagValues} from "@welshman/util" -import type {EventTemplate, TrustedEvent} from "@welshman/util" -import {DomainObject} from "./base.js" - -export type RoomAdminsValues = { - h: string - admins: string[] -} - -export const makeRoomAdminsValues = ( - values: Partial = {}, -): RoomAdminsValues => ({ - h: "", - admins: [], - ...values, -}) +import {randomId, uniq} from "@welshman/lib" +import {ROOM_ADMINS, getPubkeyTagValues} from "@welshman/util" +import {EventReader, EventBuilder} from "./base.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, so it -// extends DomainObject directly rather than the encryptable list base. -export class RoomAdmins extends DomainObject { - readonly kind = ROOM_ADMINS - values = makeRoomAdminsValues() +// 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 normalizeValues(values: Partial = {}) { - return makeRoomAdminsValues(values) - } - - protected parseEvent(event: TrustedEvent): Partial { - return { - h: getIdentifier(event) || "", - admins: uniq(getPubkeyTagValues(event.tags)), + protected validate() { + if (!this.identifier()) { + throw new Error("RoomAdmins requires a d tag") } } + protected reservedTagKeys() { + return ["d", "p"] + } + + // The group id is the addressable identifier (the "d" tag). h() { - return this.values.h + return this.identifier() } - admins() { - return this.values.admins + pubkeys() { + return uniq(getPubkeyTagValues(this.event.tags)) } - isAdmin(pubkey: string) { - return this.values.admins.includes(pubkey) - } + builder() { + const builder = new RoomAdminsBuilder() - addAdmin(pubkey: string) { - if (!this.values.admins.includes(pubkey)) { - this.values.admins.push(pubkey) - } + builder.h = this.identifier() || "" + builder.pubkeys = this.pubkeys() - return this - } - - removeAdmin(pubkey: string) { - this.values.admins = this.values.admins.filter(pk => pk !== pubkey) - - return this - } - - async toTemplate(): Promise { - const tags: string[][] = [ - ["d", this.values.h], - ...this.values.admins.map(pk => ["p", pk]), - ] - - return {kind: this.kind, tags, content: ""} + return this.seedBuilder(builder) + } +} + +export class RoomAdminsBuilder extends EventBuilder { + static kind = ROOM_ADMINS + + h = randomId() + pubkeys: string[] = [] + + setH(h: string) { + this.h = h + + return this + } + + addPubkey(pubkey: string) { + if (!this.pubkeys.includes(pubkey)) { + this.pubkeys.push(pubkey) + } + + return this + } + + protected validate() { + if (!this.h) { + throw new Error("RoomAdmins requires an h/d identifier") + } + } + + protected buildTags() { + return [["d", this.h], ...this.pubkeys.map(pk => ["p", pk])] } } diff --git a/packages/domain/src/RoomCreate.ts b/packages/domain/src/RoomCreate.ts index 1187d70..3e3f5c6 100644 --- a/packages/domain/src/RoomCreate.ts +++ b/packages/domain/src/RoomCreate.ts @@ -1,44 +1,22 @@ -import {ROOM_CREATE, getTagValue} from "@welshman/util" -import type {EventTemplate, TrustedEvent} from "@welshman/util" -import {DomainObject} from "./base.js" - -export type RoomCreateValues = { - h: string -} - -export const makeRoomCreateValues = ( - values: Partial = {}, -): RoomCreateValues => ({ - h: "", - ...values, -}) +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. Tags-only content, so it extends -// DomainObject directly rather than the encryptable list base. -export class RoomCreate extends DomainObject { - readonly kind = ROOM_CREATE - values = makeRoomCreateValues() +// 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 - protected normalizeValues(values: Partial = {}) { - return makeRoomCreateValues(values) - } - - protected parseEvent(event: TrustedEvent): Partial { - return { - h: getTagValue("h", event.tags) || "", - } - } - - h() { - return this.values.h - } - - async toTemplate(): Promise { - return { - kind: this.kind, - tags: [["h", this.values.h]], - content: "", - } + builder() { + return this.seedBuilder(new RoomCreateBuilder()) + } +} + +export class RoomCreateBuilder extends EventBuilder { + static kind = ROOM_CREATE + + protected buildTags() { + return [] } } diff --git a/packages/domain/src/RoomCreatePermission.ts b/packages/domain/src/RoomCreatePermission.ts index 06c4389..16652ee 100644 --- a/packages/domain/src/RoomCreatePermission.ts +++ b/packages/domain/src/RoomCreatePermission.ts @@ -1,50 +1,46 @@ import {uniq} from "@welshman/lib" import {ROOM_CREATE_PERMISSION, getPubkeyTagValues} from "@welshman/util" -import type {EventTemplate, TrustedEvent} from "@welshman/util" -import {DomainObject} from "./base.js" - -export type RoomCreatePermissionValues = { - pubkeys: string[] -} - -export const makeRoomCreatePermissionValues = ( - values: Partial = {}, -): RoomCreatePermissionValues => ({ - pubkeys: [], - ...values, -}) +import {EventReader, EventBuilder} from "./base.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, so it extends DomainObject directly rather than -// the encryptable list base. -export class RoomCreatePermission extends DomainObject { - readonly kind = ROOM_CREATE_PERMISSION - values = makeRoomCreatePermissionValues() +// in practice. Tags-only content. +export class RoomCreatePermission extends EventReader { + static kind = ROOM_CREATE_PERMISSION - protected normalizeValues(values: Partial = {}) { - return makeRoomCreatePermissionValues(values) - } - - protected parseEvent(event: TrustedEvent): Partial { - return { - pubkeys: uniq(getPubkeyTagValues(event.tags)), - } + protected reservedTagKeys() { + return ["p"] } pubkeys() { - return this.values.pubkeys + return uniq(getPubkeyTagValues(this.event.tags)) } canCreate(pubkey: string) { - return this.values.pubkeys.includes(pubkey) + return this.pubkeys().includes(pubkey) } - async toTemplate(): Promise { - return { - kind: this.kind, - tags: this.values.pubkeys.map(pk => ["p", pk]), - content: "", - } + builder() { + const builder = new RoomCreatePermissionBuilder() + + builder.pubkeys = this.pubkeys() + + return this.seedBuilder(builder) + } +} + +export class RoomCreatePermissionBuilder extends EventBuilder { + static kind = ROOM_CREATE_PERMISSION + + pubkeys: string[] = [] + + setPubkeys(pubkeys: string[]) { + this.pubkeys = pubkeys + + return this + } + + protected buildTags() { + return uniq(this.pubkeys).map(pk => ["p", pk]) } } diff --git a/packages/domain/src/RoomDelete.ts b/packages/domain/src/RoomDelete.ts index d7abca1..9eb5c94 100644 --- a/packages/domain/src/RoomDelete.ts +++ b/packages/domain/src/RoomDelete.ts @@ -1,63 +1,79 @@ import {ROOM_DELETE, getTagValues} from "@welshman/util" -import type {EventTemplate, TrustedEvent} from "@welshman/util" -import {DomainObject} from "./base.js" - -export type RoomDeleteValues = { - hs: string[] -} - -export const makeRoomDeleteValues = ( - values: Partial = {}, -): RoomDeleteValues => ({ - hs: [], - ...values, -}) +import {EventReader, EventBuilder} from "./base.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 -// several rooms at once. Tags-only content, so it extends DomainObject directly -// rather than the encryptable list base. -export class RoomDelete extends DomainObject { - readonly kind = ROOM_DELETE - values = makeRoomDeleteValues() +// several rooms at once. Tags-only content. +// +// 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. +export class RoomDelete extends EventReader { + static kind = ROOM_DELETE - protected normalizeValues(values: Partial = {}) { - return makeRoomDeleteValues(values) - } - - protected parseEvent(event: TrustedEvent): Partial { - return { - hs: getTagValues("h", event.tags), + protected validate() { + if (this.hs().length === 0) { + throw new Error("RoomDelete requires at least one h tag") } } - hs() { - return this.values.hs + protected reservedTagKeys() { + return ["h"] } - h() { - return this.values.hs[0] + // All group ids tombstoned by this event. + hs() { + return getTagValues("h", this.event.tags) } + // Convenience accessor for the first group id. + h() { + return this.hs()[0] + } + + 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 + } +} + +export class RoomDeleteBuilder extends EventBuilder { + static kind = ROOM_DELETE + + hs: string[] = [] + addRoom(h: string) { - if (!this.values.hs.includes(h)) { - this.values.hs.push(h) + if (!this.hs.includes(h)) { + this.hs.push(h) } return this } removeRoom(h: string) { - this.values.hs = this.values.hs.filter(value => value !== h) + this.hs = this.hs.filter(value => value !== h) return this } - async toTemplate(): Promise { - return { - kind: this.kind, - tags: this.values.hs.map(h => ["h", h]), - content: "", + protected validate() { + if (this.hs.length === 0) { + throw new Error("RoomDelete requires at least one h tag") } } + + protected buildTags() { + return this.hs.map(h => ["h", h]) + } } diff --git a/packages/domain/src/RoomJoin.ts b/packages/domain/src/RoomJoin.ts index 80788b2..b5d2698 100644 --- a/packages/domain/src/RoomJoin.ts +++ b/packages/domain/src/RoomJoin.ts @@ -1,62 +1,78 @@ import {ROOM_JOIN, getTagValue} from "@welshman/util" -import type {EventTemplate, TrustedEvent} from "@welshman/util" -import {DomainObject} from "./base.js" - -export type RoomJoinValues = { - h: string - claim?: string - reason?: string -} - -export const makeRoomJoinValues = (values: Partial = {}): RoomJoinValues => ({ - h: "", - ...values, -}) +import type {ISigner} from "@welshman/signer" +import {EventReader, EventBuilder} from "./base.js" // NIP-29 kind-9021 room join request. A regular (read-and-written) event -// carrying the target group id ("h") tag, an optional invite "claim" tag, and a -// free-text reason in the event content. Drives the membership state machine -// (ROOM_JOIN -> Pending/Granted) and the pending-join admin queue, grouped by -// h + pubkey. Tags-plus-content, so it extends DomainObject directly. -export class RoomJoin extends DomainObject { - readonly kind = ROOM_JOIN - values = makeRoomJoinValues() +// carrying the target group id ("h", handled by the base group accessor), an +// optional invite "claim" tag (exposed as code), and a free-text reason in the +// 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 normalizeValues(values: Partial = {}) { - return makeRoomJoinValues(values) - } - - protected parseEvent(event: TrustedEvent): Partial { - return { - h: getTagValue("h", event.tags) || "", - claim: getTagValue("claim", event.tags), - reason: event.content || undefined, + protected validate() { + if (!this.group()) { + throw new Error("RoomJoin requires an h tag") } } - h() { - return this.values.h + protected reservedTagKeys() { + return ["claim"] } - claim() { - return this.values.claim + // The invite "claim" tag. + code() { + return getTagValue("claim", this.event.tags) } + // Free-text reason carried in the event content. reason() { - return this.values.reason + return this.event.content || undefined } - async toTemplate(): Promise { - const tags: string[][] = [["h", this.values.h]] + builder() { + const builder = new RoomJoinBuilder() - if (this.values.claim) { - tags.push(["claim", this.values.claim]) - } + builder.code = this.code() + builder.reason = this.reason() - return { - kind: this.kind, - tags, - content: this.values.reason || "", - } + return this.seedBuilder(builder) + } +} + +export class RoomJoinBuilder extends EventBuilder { + static kind = ROOM_JOIN + + code?: string + reason?: string + + setCode(code: string) { + this.code = code + + return this + } + + setReason(reason: string) { + this.reason = reason + + return this + } + + protected validate() { + if (!this.group) { + throw new Error("RoomJoin requires an h/group") + } + } + + protected buildContent(_signer?: ISigner) { + return this.reason || "" + } + + protected buildTags() { + const tags: string[][] = [] + + if (this.code) tags.push(["claim", this.code]) + + return tags } } diff --git a/packages/domain/src/RoomLeave.ts b/packages/domain/src/RoomLeave.ts index 1923fe4..614f029 100644 --- a/packages/domain/src/RoomLeave.ts +++ b/packages/domain/src/RoomLeave.ts @@ -1,42 +1,41 @@ -import {ROOM_LEAVE, getTagValue} from "@welshman/util" -import type {EventTemplate, TrustedEvent} from "@welshman/util" -import {DomainObject} from "./base.js" - -export type RoomLeaveValues = { - h: string -} - -export const makeRoomLeaveValues = (values: Partial = {}): RoomLeaveValues => ({ - h: "", - ...values, -}) +import {ROOM_LEAVE} from "@welshman/util" +import {EventReader, EventBuilder} from "./base.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). Tags-only, so it extends DomainObject directly. -export class RoomLeave extends DomainObject { - readonly kind = ROOM_LEAVE - values = makeRoomLeaveValues() +// 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 normalizeValues(values: Partial = {}) { - return makeRoomLeaveValues(values) - } - - protected parseEvent(event: TrustedEvent): Partial { - return { - h: getTagValue("h", event.tags) || "", + protected validate() { + if (!this.group()) { + throw new Error("RoomLeave requires an h tag") } } + // The group id ("h") is read via the base group() accessor. h() { - return this.values.h + return this.group() } - async toTemplate(): Promise { - return { - kind: this.kind, - tags: [["h", this.values.h]], - content: "", - } + builder() { + const builder = new RoomLeaveBuilder() + + return this.seedBuilder(builder) + } +} + +export class RoomLeaveBuilder extends EventBuilder { + static kind = ROOM_LEAVE + + protected validate() { + if (!this.group) { + throw new Error("RoomLeave requires an h identifier") + } + } + + protected buildTags() { + return [] } } diff --git a/packages/domain/src/RoomList.ts b/packages/domain/src/RoomList.ts index 4653ecd..66077a2 100644 --- a/packages/domain/src/RoomList.ts +++ b/packages/domain/src/RoomList.ts @@ -1,11 +1,11 @@ import {ROOMS, getGroupTags, getGroupTagValues} from "@welshman/util" -import {EncryptableList} from "./List.js" +import {ListReader, ListBuilder} from "./List.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). -// Distinct from the NIP-29 room management events, which are not lists. -export class RoomList extends EncryptableList { - readonly kind = ROOMS +// 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 groups() { return getGroupTagValues(this.tags()) @@ -15,6 +15,14 @@ export class RoomList extends EncryptableList { return getGroupTags(this.tags()) } + builder() { + return this.seedList(new RoomListBuilder()) + } +} + +export class RoomListBuilder extends ListBuilder { + static kind = ROOMS + join(groupId: string, relayUrl: string) { return this.addPublicTags(["group", groupId, relayUrl]) } diff --git a/packages/domain/src/RoomMembers.ts b/packages/domain/src/RoomMembers.ts index a216115..b296da1 100644 --- a/packages/domain/src/RoomMembers.ts +++ b/packages/domain/src/RoomMembers.ts @@ -1,70 +1,71 @@ import {uniq} from "@welshman/lib" -import {ROOM_MEMBERS, getIdentifier, getPubkeyTagValues} from "@welshman/util" -import type {EventTemplate, TrustedEvent} from "@welshman/util" -import {DomainObject} from "./base.js" - -export type RoomMembersValues = { - h: string - members: string[] -} - -export const makeRoomMembersValues = ( - values: Partial = {}, -): RoomMembersValues => ({ - h: "", - members: [], - ...values, -}) +import {ROOM_MEMBERS, getPubkeyTagValues} from "@welshman/util" +import {EventReader, EventBuilder} from "./base.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, so it extends DomainObject directly rather than the -// encryptable list base. -export class RoomMembers extends DomainObject { - readonly kind = ROOM_MEMBERS - values = makeRoomMembersValues() +// Tags-only content. +export class RoomMembers extends EventReader { + static kind = ROOM_MEMBERS - protected normalizeValues(values: Partial = {}) { - return makeRoomMembersValues(values) - } - - protected parseEvent(event: TrustedEvent): Partial { - return { - h: getIdentifier(event) || "", - members: uniq(getPubkeyTagValues(event.tags)), + protected validate() { + if (!this.identifier()) { + throw new Error("RoomMembers requires a d tag") } } + protected reservedTagKeys() { + return ["d", "p"] + } + + // The group id is the addressable identifier (the "d" tag). h() { - return this.values.h + return this.identifier() } members() { - return this.values.members + return uniq(getPubkeyTagValues(this.event.tags)) } isMember(pubkey: string) { - return this.values.members.includes(pubkey) + return this.members().includes(pubkey) } + builder() { + const builder = new RoomMembersBuilder() + + builder.h = this.identifier() || "" + builder.members = this.members() + + return this.seedBuilder(builder) + } +} + +export class RoomMembersBuilder extends EventBuilder { + static kind = ROOM_MEMBERS + + h = "" + members: string[] = [] + addMember(pubkey: string) { - this.values.members = uniq([...this.values.members, pubkey]) + this.members = uniq([...this.members, pubkey]) return this } removeMember(pubkey: string) { - this.values.members = this.values.members.filter(pk => pk !== pubkey) + this.members = this.members.filter(pk => pk !== pubkey) return this } - async toTemplate(): Promise { - const tags: string[][] = [ - ["d", this.values.h], - ...this.values.members.map(pk => ["p", pk]), - ] + protected validate() { + if (!this.h) { + throw new Error("RoomMembers requires an h/d identifier") + } + } - return {kind: this.kind, tags, content: ""} + protected buildTags() { + return [["d", this.h], ...this.members.map(pk => ["p", pk])] } } diff --git a/packages/domain/src/RoomMembershipOp.ts b/packages/domain/src/RoomMembershipOp.ts index 723bd07..5d23129 100644 --- a/packages/domain/src/RoomMembershipOp.ts +++ b/packages/domain/src/RoomMembershipOp.ts @@ -1,54 +1,72 @@ import {uniq} from "@welshman/lib" import {ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER, getPubkeyTagValues} from "@welshman/util" -import type {EventTemplate, TrustedEvent} from "@welshman/util" -import {DomainObject} from "./base.js" - -export type RoomMembershipValues = { - pubkeys: string[] -} - -export const makeRoomMembershipValues = ( - values: Partial = {}, -): RoomMembershipValues => ({ - pubkeys: [], - ...values, -}) +import {EventReader, EventBuilder} from "./base.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" // tags; the target group id is the base `group` ("h") behavior tag. Add and -// remove share this shape; each is its own concrete class fixing the kind. +// remove share this shape; each is its own concrete reader/builder fixing the +// kind via a static field. // // Flotilla's membership replay treats RoomAddMember => member, RoomRemoveMember // => not a member. -export abstract class RoomMembershipOp extends DomainObject { - values = makeRoomMembershipValues() - - protected normalizeValues(values: Partial = {}) { - return makeRoomMembershipValues(values) - } - - protected parseEvent(event: TrustedEvent): Partial { - return {pubkeys: uniq(getPubkeyTagValues(event.tags))} +export abstract class RoomMembershipOp extends EventReader { + protected reservedTagKeys() { + return ["p"] } + // The affected pubkeys, deduped. pubkeys() { - return this.values.pubkeys + return uniq(getPubkeyTagValues(this.event.tags)) } - async toTemplate(): Promise { - return { - kind: this.kind, - tags: this.values.pubkeys.map(pk => ["p", pk]), - content: "", - } + abstract builder(): RoomMembershipOpBuilder +} + +// 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 { + pubkeys: string[] = [] + + addPubkey(pubkey: string) { + this.pubkeys = uniq([...this.pubkeys, pubkey]) + + return this + } + + protected buildTags() { + return this.pubkeys.map(pk => ["p", pk]) } } export class RoomAddMember extends RoomMembershipOp { - readonly kind = ROOM_ADD_MEMBER + static kind = ROOM_ADD_MEMBER + + builder() { + const builder = new RoomAddMemberBuilder() + + builder.pubkeys = this.pubkeys() + + return this.seedBuilder(builder) + } +} + +export class RoomAddMemberBuilder extends RoomMembershipOpBuilder { + static kind = ROOM_ADD_MEMBER } export class RoomRemoveMember extends RoomMembershipOp { - readonly kind = ROOM_REMOVE_MEMBER + static kind = ROOM_REMOVE_MEMBER + + builder() { + const builder = new RoomRemoveMemberBuilder() + + builder.pubkeys = this.pubkeys() + + return this.seedBuilder(builder) + } +} + +export class RoomRemoveMemberBuilder extends RoomMembershipOpBuilder { + static kind = ROOM_REMOVE_MEMBER } diff --git a/packages/domain/src/RoomMeta.ts b/packages/domain/src/RoomMeta.ts index ae50c20..a4d8a1b 100644 --- a/packages/domain/src/RoomMeta.ts +++ b/packages/domain/src/RoomMeta.ts @@ -1,115 +1,134 @@ import {randomId} from "@welshman/lib" -import {ROOM_META, getIdentifier, getTag, getTagValue} from "@welshman/util" -import type {EventTemplate, TrustedEvent} from "@welshman/util" -import {DomainObject} from "./base.js" +import {ROOM_META, getTag, getTagValue} from "@welshman/util" +import {EventReader, EventBuilder} from "./base.js" -export type RoomMetaValues = { - h: string +// 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"] + } + + // The group id is the addressable identifier (the "d" tag). + h() { + return this.identifier() + } + + name() { + return getTagValue("name", this.event.tags) + } + + about() { + return getTagValue("about", this.event.tags) + } + + picture() { + return getTag("picture", this.event.tags)?.[1] + } + + pictureMeta() { + const tag = getTag("picture", this.event.tags) + + return tag ? tag.slice(2) : undefined + } + + isClosed() { + return this.event.tags.some(t => t[0] === "closed") + } + + isHidden() { + return this.event.tags.some(t => t[0] === "hidden") + } + + isPrivate() { + return this.event.tags.some(t => t[0] === "private") + } + + isRestricted() { + return this.event.tags.some(t => t[0] === "restricted") + } + + livekit() { + return this.event.tags.some(t => t[0] === "livekit") + } + + 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) + } +} + +export class RoomMetaBuilder extends EventBuilder { + static kind = ROOM_META + + h = randomId() name?: string about?: string picture?: string pictureMeta?: string[] - isClosed: boolean - isHidden: boolean - isPrivate: boolean - isRestricted: boolean - livekit: boolean -} + closed = false + hidden = false + isPrivate = false + restricted = false + livekit = false -export const makeRoomMetaValues = (values: Partial = {}): RoomMetaValues => ({ - h: values.h || randomId(), - isClosed: false, - isHidden: false, - isPrivate: false, - isRestricted: false, - livekit: false, - ...values, -}) + setName(name: string) { + this.name = name -// NIP-29 kind-39000 relay-generated group metadata. Addressable, with the group -// id ("h") stored in the "d" tag. Tags-only content, so it extends DomainObject -// directly rather than the encryptable list base. -export class RoomMeta extends DomainObject { - readonly kind = ROOM_META - values = makeRoomMetaValues() - - protected normalizeValues(values: Partial = {}) { - return makeRoomMetaValues(values) + return this } - protected parseEvent(event: TrustedEvent): Partial { - const pic = getTag("picture", event.tags) + setAbout(about: string) { + this.about = about - return { - h: getIdentifier(event) || "", - name: getTagValue("name", event.tags), - about: getTagValue("about", event.tags), - picture: pic?.[1], - pictureMeta: pic ? pic.slice(2) : undefined, - isClosed: Boolean(getTag("closed", event.tags)), - isHidden: Boolean(getTag("hidden", event.tags)), - isPrivate: Boolean(getTag("private", event.tags)), - isRestricted: Boolean(getTag("restricted", event.tags)), - livekit: Boolean(getTag("livekit", event.tags)), + return this + } + + setPicture(picture: string, meta?: string[]) { + this.picture = picture + this.pictureMeta = meta + + return this + } + + protected validate() { + if (!this.h) { + throw new Error("RoomMeta requires an h/d identifier") } } - h() { - return this.values.h - } + protected buildTags() { + const tags: string[][] = [["d", this.h]] - name() { - return this.values.name - } + if (this.name) tags.push(["name", this.name]) + if (this.about) tags.push(["about", this.about]) + if (this.picture) tags.push(["picture", this.picture, ...(this.pictureMeta || [])]) + if (this.closed) tags.push(["closed"]) + if (this.hidden) tags.push(["hidden"]) + if (this.isPrivate) tags.push(["private"]) + if (this.restricted) tags.push(["restricted"]) + if (this.livekit) tags.push(["livekit"]) - about() { - return this.values.about - } - - picture() { - return this.values.picture - } - - pictureMeta() { - return this.values.pictureMeta - } - - isClosed() { - return this.values.isClosed - } - - isHidden() { - return this.values.isHidden - } - - isPrivate() { - return this.values.isPrivate - } - - isRestricted() { - return this.values.isRestricted - } - - livekit() { - return this.values.livekit - } - - async toTemplate(): Promise { - const tags: string[][] = [["d", this.values.h]] - - if (this.values.name) tags.push(["name", this.values.name]) - if (this.values.about) tags.push(["about", this.values.about]) - - if (this.values.picture) { - tags.push(["picture", this.values.picture, ...(this.values.pictureMeta || [])]) - } - - if (this.values.isClosed) tags.push(["closed"]) - if (this.values.isHidden) tags.push(["hidden"]) - if (this.values.isPrivate) tags.push(["private"]) - if (this.values.isRestricted) tags.push(["restricted"]) - if (this.values.livekit) tags.push(["livekit"]) - - return {kind: this.kind, tags, content: ""} + return tags } } diff --git a/packages/domain/src/SearchRelayList.ts b/packages/domain/src/SearchRelayList.ts index d55cf0d..93541fa 100644 --- a/packages/domain/src/SearchRelayList.ts +++ b/packages/domain/src/SearchRelayList.ts @@ -1,23 +1,35 @@ import {uniqBy} from "@welshman/lib" import {SEARCH_RELAYS, getTagValues, normalizeRelayUrl} from "@welshman/util" -import {EncryptableList} from "./List.js" +import {ListReader, ListBuilder} from "./List.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 EncryptableList { - readonly kind = SEARCH_RELAYS +export class SearchRelayList extends ListReader { + static kind = SEARCH_RELAYS urls() { return uniqBy(normalizeRelayUrl, getTagValues("relay", this.tags())) } + includes(url: string) { + return this.urls().includes(normalizeRelayUrl(url)) + } + + builder() { + return this.seedList(new SearchRelayListBuilder()) + } +} + +export class SearchRelayListBuilder extends ListBuilder { + static kind = SEARCH_RELAYS + addRelay(url: string) { return this.addPublicTags(["relay", normalizeRelayUrl(url)]) } removeRelay(url: string) { - return this.removeTagsWithValue(url) + return this.removeTagsWithValue(normalizeRelayUrl(url)) } setRelays(urls: string[]) { diff --git a/packages/domain/src/Thread.ts b/packages/domain/src/Thread.ts index e60de81..3a863cb 100644 --- a/packages/domain/src/Thread.ts +++ b/packages/domain/src/Thread.ts @@ -1,16 +1,5 @@ import {THREAD, getTagValue} from "@welshman/util" -import type {EventTemplate, TrustedEvent} from "@welshman/util" -import {DomainObject} from "./base.js" - -export type ThreadValues = { - title?: string - content: string -} - -export const makeThreadValues = (values: Partial = {}): ThreadValues => ({ - content: "", - ...values, -}) +import {EventReader, EventBuilder} from "./base.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 @@ -18,40 +7,58 @@ export const makeThreadValues = (values: Partial = {}): ThreadValu // 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). -export class Thread extends DomainObject { - readonly kind = THREAD - values = makeThreadValues() - - protected normalizeValues(values: Partial = {}) { - return makeThreadValues(values) - } +export class Thread extends EventReader { + static kind = THREAD protected reservedTagKeys() { return ["title"] } - protected parseEvent(event: TrustedEvent): Partial { - return { - title: getTagValue("title", event.tags), - content: event.content || "", - } - } - title() { - return this.values.title + return getTagValue("title", this.event.tags) } content() { - return this.values.content + return this.event.content || "" } - async toTemplate(): Promise { - const tags: string[][] = [] + builder() { + const builder = new ThreadBuilder() - if (this.values.title) { - tags.push(["title", this.values.title]) - } + builder.title = this.title() + builder.content = this.content() - return {kind: this.kind, content: this.values.content, tags} + return this.seedBuilder(builder) + } +} + +export class ThreadBuilder extends EventBuilder { + static kind = THREAD + + title?: string + content = "" + + setTitle(title: string) { + this.title = title + + return this + } + + setContent(content: string) { + this.content = content + + return this + } + + protected buildTags() { + const tags: string[][] = [] + + if (this.title) tags.push(["title", this.title]) + + return tags + } + + protected buildContent() { + return this.content } } diff --git a/packages/domain/src/TimeEvent.ts b/packages/domain/src/TimeEvent.ts index 4017ac5..b901a68 100644 --- a/packages/domain/src/TimeEvent.ts +++ b/packages/domain/src/TimeEvent.ts @@ -1,101 +1,124 @@ -import {range, DAY} from "@welshman/lib" -import {EVENT_TIME, getIdentifier, getTagValue} from "@welshman/util" -import type {EventTemplate, TrustedEvent} from "@welshman/util" -import {DomainObject} from "./base.js" - -export type TimeEventValues = { - identifier: string - title?: string - location?: string - content: string - start?: number - end?: number -} - -export const makeTimeEventValues = ( - values: Partial = {}, -): TimeEventValues => ({ - identifier: "", - content: "", - ...values, -}) +import {randomId, range, DAY} from "@welshman/lib" +import {EVENT_TIME, getTagValue} from "@welshman/util" +import {EventReader, EventBuilder} from "./base.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 // (parsed with parseInt), `title` falls back to the legacy "name" tag, and the -// plain-text body lives in `content`. Room scoping is handled by the base -// `group` behavior tag. Named -// TimeEvent (not CalendarEvent) to leave room for a future date-based event -// (EVENT_DATE 31922); CALENDAR 31924 / EVENT_RSVP 31925 are not used. Tags + -// plain content, so it extends DomainObject directly. +// plain-text body lives in the event content. Room scoping is handled by the +// base `group` behavior tag. Named TimeEvent (not CalendarEvent) to leave room +// for a future date-based event (EVENT_DATE 31922); CALENDAR 31924 / +// EVENT_RSVP 31925 are not used. Tags + plain-text content, so it extends +// EventReader/EventBuilder directly (no parsed `plain`). // // The "D" day tags are NOT intrinsic state — they're a derived index over // start..end used purely so calendar events can be filtered by day, so they're -// dropped on parse and recomputed in toTemplate (matching flotilla's +// 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 DomainObject { - readonly kind = EVENT_TIME - values = makeTimeEventValues() +export class TimeEvent extends EventReader { + static kind = EVENT_TIME - protected normalizeValues(values: Partial = {}) { - return makeTimeEventValues(values) - } - - protected parseEvent(event: TrustedEvent): Partial { - const start = parseInt(getTagValue("start", event.tags)!) - const end = parseInt(getTagValue("end", event.tags)!) - - return { - identifier: getIdentifier(event) || "", - title: getTagValue("title", event.tags) || getTagValue("name", event.tags), - location: getTagValue("location", event.tags), - content: event.content || "", - start: isNaN(start) ? undefined : start, - end: isNaN(end) ? undefined : end, - } - } - - identifier() { - return this.values.identifier + protected reservedTagKeys() { + return ["d", "title", "name", "location", "start", "end", "D"] } title() { - return this.values.title + return getTagValue("title", this.event.tags) || getTagValue("name", this.event.tags) } location() { - return this.values.location + return getTagValue("location", this.event.tags) } content() { - return this.values.content + return this.event.content || "" } start() { - return this.values.start + const start = parseInt(getTagValue("start", this.event.tags)!) + + return isNaN(start) ? undefined : start } end() { - return this.values.end + const end = parseInt(getTagValue("end", this.event.tags)!) + + return isNaN(end) ? undefined : end } - async toTemplate(): Promise { - const {identifier, title, location, content, start, end} = this.values + builder() { + const builder = new TimeEventBuilder() - const tags: string[][] = [["d", identifier]] + builder.identifier = this.identifier() || "" + builder.title = this.title() + builder.location = this.location() + builder.content = this.content() + builder.start = this.start() + builder.end = this.end() - if (title) tags.push(["title", title]) - if (location) tags.push(["location", location]) - if (start != null) tags.push(["start", String(start)]) - if (end != null) tags.push(["end", String(end)]) + return this.seedBuilder(builder) + } +} + +export class TimeEventBuilder extends EventBuilder { + static kind = EVENT_TIME + + identifier = randomId() + title?: string + location?: string + content = "" + start?: number + end?: number + + setTitle(title: string) { + this.title = title + + return this + } + + setLocation(location: string) { + this.location = location + + return this + } + + setContent(content: string) { + this.content = content + + return this + } + + setStart(start: number) { + this.start = start + + return this + } + + setEnd(end: number) { + this.end = end + + return this + } + + protected buildContent() { + return this.content + } + + protected buildTags() { + const tags: string[][] = [["d", this.identifier]] + + if (this.title) tags.push(["title", this.title]) + if (this.location) tags.push(["location", this.location]) + if (this.start != null) tags.push(["start", String(this.start)]) + if (this.end != null) tags.push(["end", String(this.end)]) // Derived day index for filtering: one "D" tag per epoch-day the event spans. - if (start != null && end != null) { - for (const t of range(start, end, DAY)) { + if (this.start != null && this.end != null) { + for (const t of range(this.start, this.end, DAY)) { tags.push(["D", String(Math.floor(t / DAY))]) } } - return {kind: this.kind, content, tags} + return tags } } diff --git a/packages/domain/src/TopicList.ts b/packages/domain/src/TopicList.ts index f930af8..887cb4e 100644 --- a/packages/domain/src/TopicList.ts +++ b/packages/domain/src/TopicList.ts @@ -1,13 +1,13 @@ import {uniq} from "@welshman/lib" import {TOPICS, getTopicTagValues, getAddressTagValues} from "@welshman/util" -import {EncryptableList} from "./List.js" +import {ListReader, ListBuilder} from "./List.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 EncryptableList so entries may be public (tags) or private +// 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 EncryptableList { - readonly kind = TOPICS +export class TopicList extends ListReader { + static kind = TOPICS topics() { return uniq(getTopicTagValues(this.tags())) @@ -17,10 +17,30 @@ export class TopicList extends EncryptableList { return uniq(getAddressTagValues(this.tags())) } - follow(topic: string) { + includes(topic: string) { + return this.topics().includes(topic) + } + + builder() { + return this.seedList(new TopicListBuilder()) + } +} + +export class TopicListBuilder extends ListBuilder { + static kind = TOPICS + + followPublicly(topic: string) { return this.addPublicTags(["t", topic]) } + followPrivately(topic: string) { + return this.addPrivateTags(["t", topic]) + } + + follow(topic: string) { + return this.followPublicly(topic) + } + unfollow(topic: string) { return this.removeTagsWithValue(topic) } diff --git a/packages/domain/src/ZapGoal.ts b/packages/domain/src/ZapGoal.ts index d839e13..11428cc 100644 --- a/packages/domain/src/ZapGoal.ts +++ b/packages/domain/src/ZapGoal.ts @@ -1,20 +1,5 @@ import {ZAP_GOAL, getTagValue, getTagValues} from "@welshman/util" -import type {EventTemplate, TrustedEvent} from "@welshman/util" -import {DomainObject} from "./base.js" - -export type ZapGoalValues = { - title: string - summary?: string - amount: number - relays: string[] -} - -export const makeZapGoalValues = (values: Partial = {}): ZapGoalValues => ({ - title: "", - amount: 0, - relays: [], - ...values, -}) +import {EventReader, EventBuilder} from "./base.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 @@ -23,53 +8,104 @@ export const makeZapGoalValues = (values: Partial = {}): ZapGoalV // "relays" tags; room scoping is handled by the base `group` behavior tag. // Non-addressable (referenced by event id via "#E"); the funding tally is // computed elsewhere from sibling zap receipts (ZAP_RESPONSE) and is not modeled -// here. Tags-only metadata, so it extends DomainObject directly. -export class ZapGoal extends DomainObject { - readonly kind = ZAP_GOAL - values = makeZapGoalValues() +// here. Tags + plain-text content, so it extends EventReader/EventBuilder. +export class ZapGoal extends EventReader { + static kind = ZAP_GOAL - protected normalizeValues(values: Partial = {}) { - return makeZapGoalValues(values) - } - - protected parseEvent(event: TrustedEvent): Partial { - return { - title: event.content || "", - summary: getTagValue("summary", event.tags), - amount: parseInt(getTagValue("amount", event.tags) || "0") || 0, - relays: getTagValues("relays", event.tags), + protected validate() { + if (!this.title()) { + throw new Error("ZapGoal requires a title") } } + protected reservedTagKeys() { + return ["summary", "amount", "relays"] + } + + // The goal title is plain-text content, not JSON or encrypted. title() { - return this.values.title + return this.event.content || "" } summary() { - return this.values.summary + return getTagValue("summary", this.event.tags) } amount() { - return this.values.amount + return parseInt(getTagValue("amount", this.event.tags) || "0") || 0 } relays() { - return this.values.relays + return getTagValues("relays", this.event.tags) } - async toTemplate(): Promise { + builder() { + const builder = new ZapGoalBuilder(this.title()) + + builder.summary = this.summary() + builder.amount = this.amount() + builder.relays = this.relays() + + return this.seedBuilder(builder) + } +} + +export class ZapGoalBuilder extends EventBuilder { + static kind = ZAP_GOAL + + summary?: string + amount = 0 + relays: string[] = [] + + constructor(public title = "") { + super() + } + + setTitle(title: string) { + this.title = title + + return this + } + + setSummary(summary: string) { + this.summary = summary + + return this + } + + setAmount(amount: number) { + this.amount = amount + + return this + } + + setRelays(relays: string[]) { + this.relays = relays + + return this + } + + protected validate() { + if (!this.title) { + throw new Error("ZapGoal requires a title") + } + } + + protected buildContent() { + return this.title + } + + protected buildTags() { const tags: string[][] = [] - if (this.values.summary) { - tags.push(["summary", this.values.summary]) - } + if (this.summary) tags.push(["summary", this.summary]) - tags.push(["amount", String(this.values.amount)]) + tags.push(["amount", String(this.amount)]) - for (const relay of this.values.relays) { + for (const relay of this.relays) { tags.push(["relays", relay]) } - return {kind: this.kind, content: this.values.title, tags} + return tags } } diff --git a/packages/domain/src/ZapReceipt.ts b/packages/domain/src/ZapReceipt.ts index 4646ab9..62669b5 100644 --- a/packages/domain/src/ZapReceipt.ts +++ b/packages/domain/src/ZapReceipt.ts @@ -1,87 +1,78 @@ import {parseJson} from "@welshman/lib" import {ZAP_RECEIPT, getTagValue, getInvoiceAmount} from "@welshman/util" -import type {EventTemplate, TrustedEvent, Zapper} from "@welshman/util" -import {DomainObject} from "./base.js" +import type {TrustedEvent, Zapper} from "@welshman/util" +import {EventReader, EventBuilder} from "./base.js" -export type ZapReceiptValues = { - bolt11?: string - invoiceAmount?: number - request?: TrustedEvent - recipient?: string - eventId?: string - preimage?: string -} +// NIP-57 kind-9735 zap receipt. Relay/LN-generated, so it's effectively read-only: +// we parse the bolt11 invoice and the embedded kind-9734 zap request (carried in +// the JSON "description" tag, which we expose as `plain`). The builder exists for +// completeness but is rarely used in practice. +export class ZapReceipt extends EventReader { + static kind = ZAP_RECEIPT -export const makeZapReceiptValues = ( - values: Partial = {}, -): ZapReceiptValues => ({...values}) + // The embedded kind-9734 zap request lives in the "description" tag as JSON. + protected parsePlain() { + const description = getTagValue("description", this.event.tags) -// NIP-57 kind-9735 zap receipt. Relay/LN-generated, so it's read-only in spirit: -// we parse the bolt11 invoice and the embedded kind-9734 request, and round-trip -// the source event when serializing. -export class ZapReceipt extends DomainObject { - readonly kind = ZAP_RECEIPT - values = makeZapReceiptValues() - - protected normalizeValues(values: Partial = {}) { - return makeZapReceiptValues(values) + return description ? parseJson(description) || undefined : undefined } - protected parseEvent(event: TrustedEvent): Partial { - const bolt11 = getTagValue("bolt11", event.tags) - const description = getTagValue("description", event.tags) - - return { - bolt11, - invoiceAmount: bolt11 ? getInvoiceAmount(bolt11) : undefined, - request: description ? parseJson(description) || undefined : undefined, - recipient: getTagValue("p", event.tags), - eventId: getTagValue("e", event.tags), - preimage: getTagValue("preimage", event.tags), - } + protected reservedTagKeys() { + return ["bolt11", "description", "preimage", "p", "e"] } bolt11() { - return this.values.bolt11 + return getTagValue("bolt11", this.event.tags) } - // Invoice amount in millisats. + // Invoice amount in millisats. getInvoiceAmount throws on a malformed bolt11, + // so swallow that and report undefined (matches zapFromEvent's try/catch). invoiceAmount() { - return this.values.invoiceAmount + const bolt11 = this.bolt11() + + if (!bolt11) return undefined + + try { + return getInvoiceAmount(bolt11) + } catch { + return undefined + } } // The embedded kind-9734 zap request. request() { - return this.values.request + return this.plain } // The pubkey that requested the zap. sender() { - return this.values.request?.pubkey + return this.plain?.pubkey } recipient() { - return this.values.recipient + return getTagValue("p", this.event.tags) } // The zapped event, if any. eventId() { - return this.values.eventId + return getTagValue("e", this.event.tags) } // The comment the sender attached to the zap request. comment() { - return this.values.request?.content + return this.plain?.content } preimage() { - return this.values.preimage + return getTagValue("preimage", this.event.tags) } // Port of zapFromEvent's NIP-57 verification (util/src/Zaps.ts). Returns false // unless the receipt is a legitimate, unforged zap from the given zapper. - validate(zapper: Zapper): boolean { - const {request, invoiceAmount, recipient} = this.values + verify(zapper: Zapper): boolean { + const request = this.request() + const invoiceAmount = this.invoiceAmount() + const recipient = this.recipient() // We need a parsed request and a parsed invoice amount to verify anything. if (!request || invoiceAmount === undefined) { @@ -102,7 +93,7 @@ export class ZapReceipt extends DomainObject { } // If the recipient and the zapper are the same person, it's legit. - if (recipient === this.event?.pubkey) { + if (recipient === this.event.pubkey) { return true } @@ -112,19 +103,76 @@ export class ZapReceipt extends DomainObject { } // Verify that the receipt actually came from the recipient's zapper. - if (this.event?.pubkey !== zapper.nostrPubkey) { + if (this.event.pubkey !== zapper.nostrPubkey) { return false } return true } - // Receipts are relay/LN-generated; round-trip the source event verbatim. - async toTemplate(): Promise { - return { - kind: this.kind, - content: this.event?.content || "", - tags: this.event?.tags || [], - } + 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) + } +} + +// Write side for a zap receipt. Receipts are normally relay/LN-generated, so this +// is rarely used; it builds the represented tags from raw draft fields. +export class ZapReceiptBuilder extends EventBuilder { + static kind = ZAP_RECEIPT + + bolt11?: string + description?: string + recipient?: string + eventId?: string + preimage?: string + + setBolt11(bolt11: string) { + this.bolt11 = bolt11 + + return this + } + + setDescription(description: string) { + this.description = description + + return this + } + + setRecipient(recipient: string) { + this.recipient = recipient + + return this + } + + setEventId(eventId: string) { + this.eventId = eventId + + return this + } + + setPreimage(preimage: string) { + this.preimage = preimage + + return this + } + + protected buildTags() { + const tags: string[][] = [] + + if (this.bolt11) tags.push(["bolt11", this.bolt11]) + if (this.description) tags.push(["description", this.description]) + if (this.recipient) tags.push(["p", this.recipient]) + if (this.eventId) tags.push(["e", this.eventId]) + if (this.preimage) tags.push(["preimage", this.preimage]) + + return tags } } diff --git a/packages/domain/src/ZapRequest.ts b/packages/domain/src/ZapRequest.ts index f26d0fd..edd40e3 100644 --- a/packages/domain/src/ZapRequest.ts +++ b/packages/domain/src/ZapRequest.ts @@ -1,146 +1,129 @@ import {ZAP_REQUEST, getTag, getTagValue} from "@welshman/util" -import type {EventTemplate, TrustedEvent} from "@welshman/util" -import {DomainObject} from "./base.js" - -export type ZapRequestValues = { - amount?: number - lnurl?: string - recipient?: string - relays: string[] - eventId?: string - anonymous: boolean - content: string -} - -export const makeZapRequestValues = ( - values: Partial = {}, -): ZapRequestValues => ({ - relays: [], - anonymous: false, - content: "", - ...values, -}) +import {EventReader, EventBuilder} from "./base.js" // NIP-57 kind-9734 zap request: zap metadata in tags plus an optional comment in -// content. `amount` is in millisats. -export class ZapRequest extends DomainObject { - readonly kind = ZAP_REQUEST - values = makeZapRequestValues() +// 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 normalizeValues(values: Partial = {}) { - return makeZapRequestValues(values) - } - - protected parseEvent(event: TrustedEvent): Partial { - const amount = getTagValue("amount", event.tags) - const relaysTag = getTag("relays", event.tags) - - return { - amount: amount ? parseInt(amount) : undefined, - lnurl: getTagValue("lnurl", event.tags), - recipient: getTagValue("p", event.tags), - relays: relaysTag ? relaysTag.slice(1) : [], - eventId: getTagValue("e", event.tags), - anonymous: Boolean(event.tags.find(t => t[0] === "anon")), - content: event.content, - } + protected reservedTagKeys() { + return ["amount", "lnurl", "p", "e", "relays", "anon"] } amount() { - return this.values.amount - } + const amount = getTagValue("amount", this.event.tags) - setAmount(amount: number) { - this.values.amount = amount - - return this + return amount ? parseInt(amount) : undefined } lnurl() { - return this.values.lnurl - } - - setLnurl(lnurl: string) { - this.values.lnurl = lnurl - - return this + return getTagValue("lnurl", this.event.tags) } recipient() { - return this.values.recipient - } - - setRecipient(recipient: string) { - this.values.recipient = recipient - - return this - } - - relays() { - return this.values.relays - } - - setRelays(relays: string[]) { - this.values.relays = relays - - return this + return getTagValue("p", this.event.tags) } eventId() { - return this.values.eventId + return getTagValue("e", this.event.tags) } - setEventId(eventId: string) { - this.values.eventId = eventId + relays() { + const tag = getTag("relays", this.event.tags) - return this + return tag ? tag.slice(1) : [] } isAnonymous() { - return this.values.anonymous - } - - setAnonymous(anonymous: boolean) { - this.values.anonymous = anonymous - - return this + return this.event.tags.some(t => t[0] === "anon") } comment() { - return this.values.content + return this.event.content } - setComment(content: string) { - this.values.content = content + 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) + } +} + +export class ZapRequestBuilder extends EventBuilder { + static kind = ZAP_REQUEST + + amount?: number + lnurl?: string + recipient?: string + eventId?: string + relays: string[] = [] + anonymous = false + comment = "" + + setAmount(amount: number) { + this.amount = amount return this } - async toTemplate(): Promise { - const {amount, lnurl, recipient, relays, eventId, anonymous, content} = this.values + setLnurl(lnurl: string) { + this.lnurl = lnurl - const tags: string[][] = [["relays", ...relays]] + return this + } - if (amount !== undefined) { - tags.push(["amount", String(amount)]) - } + setRecipient(recipient: string) { + this.recipient = recipient - if (lnurl !== undefined) { - tags.push(["lnurl", lnurl]) - } + return this + } - if (recipient !== undefined) { - tags.push(["p", recipient]) - } + setEventId(eventId: string) { + this.eventId = eventId - if (eventId) { - tags.push(["e", eventId]) - } + return this + } - if (anonymous) { - tags.push(["anon"]) - } + setRelays(relays: string[]) { + this.relays = relays - return {kind: this.kind, tags, content} + return this + } + + setAnonymous(anonymous = true) { + this.anonymous = anonymous + + return this + } + + setComment(comment: string) { + this.comment = comment + + return this + } + + protected buildTags() { + const tags: string[][] = [["relays", ...this.relays]] + + if (this.amount !== undefined) tags.push(["amount", String(this.amount)]) + if (this.lnurl !== undefined) tags.push(["lnurl", this.lnurl]) + if (this.recipient !== undefined) tags.push(["p", this.recipient]) + if (this.eventId) tags.push(["e", this.eventId]) + if (this.anonymous) tags.push(["anon"]) + + return tags + } + + protected buildContent() { + return this.comment } } diff --git a/packages/domain/src/base.ts b/packages/domain/src/base.ts index 40005f8..23c525d 100644 --- a/packages/domain/src/base.ts +++ b/packages/domain/src/base.ts @@ -1,123 +1,152 @@ -import {stamp, prep, getTagValue} from "@welshman/util" +import {stamp, prep, getTagValue, getAddress} from "@welshman/util" import type {EventTemplate, SignedEvent, HashedEvent, TrustedEvent} from "@welshman/util" import type {ISigner} from "@welshman/signer" -// The tag keys the base owns as publish-time behavior tags (group/protect/expires). -const BEHAVIOR_TAG_KEYS = ["h", "-", "expiration"] +// Tag keys the base owns as publish-time behavior tags (group/protect/expires). +export const BEHAVIOR_TAG_KEYS = ["h", "-", "expiration"] /** - * The base class for domain objects. + * Read side of a domain object: a lazy, read-only view over a single nostr event. * - * A domain object is an in-memory, mutable view of a single nostr event whose - * state lives in a plain `values` property. The pattern is "decrypt on parse, - * mutate in memory, encrypt on serialize": concrete subclasses decrypt private - * content up front (in `parse`), expose synchronous accessors and mutators over - * `values`, and only touch the signer again when building an 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. * - * There are two construction entry points, both of which populate `values` and - * return `this`: + * 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 * - * - `init(values?)` builds a fresh object from raw input - * - `parse(event, signer?)` reads (and, when possible, decrypts) an event - * - * Subclasses also implement `toTemplate(signer?)` to build (and, when needed, - * encrypt) the event template; the base provides the signing/wrapping - * orchestration on top of it. + * `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 DomainObject> { - abstract readonly kind: number - abstract values: V - event?: TrustedEvent +export abstract class EventReader

{ + // Concrete subclasses declare `static kind = SOME_KIND`. + plain!: P - // Publish-time behavior tags, shared by every kind and applied to the template - // at serialization time via addBehaviorTags rather than being baked into each - // subclass's content schema. They are read back from the event on parse. - group?: string // NIP-29 room scope -> ["h", group] - protect = false // NIP-70 protected -> ["-"] - expires?: number // NIP-40 expiration -> ["expiration", expires] + constructor(readonly event: TrustedEvent) {} - // Tags not represented by any other domain attribute, carried over verbatim. - // Handled the same way as the behavior tags above: parsed in the base (minus - // the behavior keys and the subclass's reserved keys) and re-emitted in - // addBehaviorTags. Empty unless the subclass opts in via reservedTagKeys(). - extraTags: string[][] = [] - - static init>>( - this: new () => T, - values?: Partial, - ): T { - return new this().init(values) - } - - static parse>>( - this: new () => T, + static async fromEvent>( + this: (new (event: TrustedEvent) => T) & {kind: number}, event: TrustedEvent, signer?: ISigner, ): Promise { - return new this().parse(event, signer) - } - - init(values: Partial = {}) { - this.values = this.normalizeValues(values) - - return this - } - - async parse(event: TrustedEvent, signer?: ISigner) { if (event.kind !== this.kind) { throw new Error(`Expected a kind ${this.kind} event, got kind ${event.kind}`) } - this.event = event - this.group = getTagValue("h", event.tags) - this.protect = event.tags.some(t => t[0] === "-") + const reader = new this(event) - const expiration = parseInt(getTagValue("expiration", event.tags) ?? "") - this.expires = isNaN(expiration) ? undefined : expiration + reader.plain = (await reader.parsePlain(signer)) as T["plain"] + reader.validate() - const reserved = this.reservedTagKeys() - this.extraTags = - reserved == null - ? [] - : event.tags.filter(t => ![...BEHAVIOR_TAG_KEYS, ...reserved].includes(t[0])) - - this.values = this.normalizeValues(await this.parseEvent(event, signer)) - - return this + return reader } - protected abstract normalizeValues(values?: Partial): V - - // Tag keys a subclass parses into dedicated attributes (and rebuilds in - // toTemplate); the base behavior keys are always reserved too. Return null - // (the default) to opt out of extra-tag passthrough — the subclass owns all - // of its tags and `extraTags` stays empty. - protected reservedTagKeys(): string[] | null { - return null + // Eagerly compute the `plain` representation (decrypt and/or parse content). + // Default: nothing to compute. Runs once in fromEvent. + protected async parsePlain(_signer?: ISigner): Promise

{ + return undefined as P } - protected abstract parseEvent( - event: TrustedEvent, - signer?: ISigner, - ): Partial | Promise> + // Throw on missing required tags. Lenient by default — keep "required" narrow. + protected validate(): void {} - abstract toTemplate(signer?: ISigner): Promise - - // Append the publish-time behavior tags to a freshly built template, just - // before hashing/signing. A tag is skipped when the subclass's toTemplate - // already emitted that key, so kinds that own "h" as core content (NIP-29 - // group events) don't get a duplicate. - private addBehaviorTags(template: EventTemplate): EventTemplate { - const tags = [...template.tags, ...this.extraTags] - const has = (key: string) => tags.some(t => t[0] === key) - - if (this.group && !has("h")) tags.push(["h", this.group]) - if (this.protect && !has("-")) tags.push(["-"]) - if (this.expires != null && !has("expiration")) tags.push(["expiration", String(this.expires)]) - - return {...template, tags} + // Tag keys this kind represents via dedicated accessors; combined with the + // behavior keys, these are excluded from extraTags() so a reader -> builder -> + // event round-trip doesn't lose or duplicate unknown tags. Default: none. + protected reservedTagKeys(): string[] { + return [] } + // Tags not represented by any accessor, for lossless carry-over into a builder. + extraTags(): string[][] { + const reserved = [...BEHAVIOR_TAG_KEYS, ...this.reservedTagKeys()] + + return this.event.tags.filter(t => !reserved.includes(t[0])) + } + + // Identity accessors — total, since the event is always present. + id() { + return this.event.id + } + + pubkey() { + return this.event.pubkey + } + + createdAt() { + return this.event.created_at + } + + identifier() { + return getTagValue("d", this.event.tags) + } + + address() { + return getAddress(this.event) + } + + // Behavior-tag accessors. + group() { + return getTagValue("h", this.event.tags) + } + + protect() { + return this.event.tags.some(t => t[0] === "-") + } + + expires() { + const expiration = parseInt(getTagValue("expiration", this.event.tags) ?? "") + + return isNaN(expiration) ? undefined : expiration + } + + // Copy the behavior tags + carry-over tags onto a freshly created builder. + // Concrete readers call this from builder() after setting kind-specific fields. + protected seedBuilder>(builder: B): B { + builder.group = this.group() + builder.protect = this.protect() + builder.expires = this.expires() + builder.extraTags = this.extraTags() + + return builder + } + + abstract builder(): EventBuilder

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

{ + // Concrete subclasses declare `static kind = SOME_KIND`. + group?: string + protect = false + expires?: number + extraTags: string[][] = [] + plain!: P + setGroup(group: string) { this.group = group @@ -136,25 +165,46 @@ export abstract class DomainObject> { return this } + // The tags built from this kind's own fields. Must NOT include behavior tags + // (h/-/expiration) or the carried-over extraTags — the base appends those. + // Receives the signer (like buildContent) for kinds that need to encrypt tags. + protected abstract buildTags(signer?: ISigner): string[][] | Promise + + // The event content. Override for JSON metadata or encrypted content. + protected buildContent(_signer?: ISigner): string | Promise { + return "" + } + + // Throw on an invalid draft. Runs only at emit time. + protected validate(): void {} + + private behaviorTags(): string[][] { + const tags: string[][] = [] + + if (this.group) tags.push(["h", this.group]) + if (this.protect) tags.push(["-"]) + if (this.expires != null) tags.push(["expiration", String(this.expires)]) + + return tags + } + + async toTemplate(signer?: ISigner): Promise { + this.validate() + + const kind = (this.constructor as unknown as {kind: number}).kind + const content = await this.buildContent(signer) + const tags = [...(await this.buildTags(signer)), ...this.extraTags, ...this.behaviorTags()] + + return {kind, content, tags} + } + async toRumor(signer: ISigner): Promise { const [template, pubkey] = await Promise.all([this.toTemplate(signer), signer.getPubkey()]) - return prep(this.addBehaviorTags(template), pubkey) + return prep(template, pubkey) } async toEvent(signer: ISigner): Promise { - const template = this.addBehaviorTags(await this.toTemplate(signer)) - - return signer.sign(stamp(template)) - } - - get(key: K): V[K] { - return this.values[key] - } - - set(key: K, value: V[K]) { - this.values[key] = value - - return this + return signer.sign(stamp(await this.toTemplate(signer))) } }