Refine domain classes: behavior tags, extra-tag passthrough, cleanups
tests / tests (push) Failing after 5m10s

Iterate on @welshman/domain following review:

- base: add `group`/`protect`/`expires` behavior tags (parsed in base, emitted
  via addBehaviorTags before hashing) and an `extraTags` passthrough (opt-in via
  reservedTagKeys) so tag carry-over lives in one place; migrate Handler, Comment,
  Thread onto it. Comment gains nested root/parent ref structs + setters.
- List: fix inverted keepTags; add clearTags/clearPublicTags/clearPrivateTags and
  use them in the relay/server list setters.
- RelayList: preserve complementary read/write capability instead of dropping
  modeless entries.
- Split Relay/Room membership ops into per-kind classes (RelayAddMember/
  RelayRemoveMember, RoomAddMember/RoomRemoveMember) over a shared base.
- TimeEvent (renamed from CalendarEvent): derive "D" day tags in toTemplate.
- Feed: default to an empty feed, fail parse when the "feed" tag is missing.
- RelaySet added; CommunityList renamed to GroupList; predicate bare add/remove
  mutators; RoomMeta uses randomId; PollResponse.selections drops pollType.
- Remove ChannelList, FileServerList, Settings, and event-asserting getAddress/
  display accessors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V67tPYdvh1qCkjEBhJGZUR
This commit is contained in:
2026-06-18 22:42:10 +00:00
parent 99f5233e05
commit 5e142e4db4
26 changed files with 353 additions and 582 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ export class BlockedRelayList extends EncryptableList {
} }
setRelays(urls: string[]) { setRelays(urls: string[]) {
this.keepTagsWithKey("relay") this.clearTags()
return this.addPublicTags(...urls.map(url => ["relay", normalizeRelayUrl(url)])) return this.addPublicTags(...urls.map(url => ["relay", normalizeRelayUrl(url)]))
} }
+1 -1
View File
@@ -25,7 +25,7 @@ export class BlossomServerList extends EncryptableList {
} }
setServers(urls: string[]) { setServers(urls: string[]) {
this.keepTagsWithKey("server") this.clearTags()
return this.addPublicTags(...urls.map(url => ["server", normalizeRelayUrl(url)])) return this.addPublicTags(...urls.map(url => ["server", normalizeRelayUrl(url)]))
} }
-21
View File
@@ -1,21 +0,0 @@
import {uniq} from "@welshman/lib"
import {CHANNELS, getEventTagValues} from "@welshman/util"
import {EncryptableList} from "./List.js"
// NIP-51 kind-10005 public chat channel list. Entries are `e` tags pointing at
// NIP-28 kind-40 channel-create events.
export class ChannelList extends EncryptableList {
readonly kind = CHANNELS
ids() {
return uniq(getEventTagValues(this.tags()))
}
add(id: string, relayHint?: string) {
return this.addPublicTags(["e", id, relayHint || ""])
}
remove(id: string) {
return this.removeTagsWithValue(id)
}
}
+3 -22
View File
@@ -1,6 +1,5 @@
import { import {
CLASSIFIED, CLASSIFIED,
getAddress,
getIdentifier, getIdentifier,
getTag, getTag,
getTagValue, getTagValue,
@@ -19,7 +18,6 @@ export type ClassifiedValues = {
status?: string status?: string
images: string[] images: string[]
topics: string[] topics: string[]
h?: string
} }
export const makeClassifiedValues = ( export const makeClassifiedValues = (
@@ -35,9 +33,9 @@ export const makeClassifiedValues = (
// NIP-99 kind-30402 addressable classified listing. Addressable via the "d" // NIP-99 kind-30402 addressable classified listing. Addressable via the "d"
// tag; the listing description lives in `content` as plain text (not JSON). The // 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 // price is carried in a ["price", amount, currency] tag with the currency
// defaulting to "SAT", images in repeated "image" tags, topics in "t" tags, and // defaulting to "SAT", images in repeated "image" tags, and topics in "t" tags;
// an optional "h" tag scopes the listing to a room. Tags-only metadata, so it // room scoping is handled by the base `group` behavior tag. Tags-only metadata,
// extends DomainObject directly. Commented via "#A" (kind 1111 comments). // so it extends DomainObject directly. Commented via "#A" (kind 1111 comments).
export class Classified extends DomainObject<ClassifiedValues> { export class Classified extends DomainObject<ClassifiedValues> {
readonly kind = CLASSIFIED readonly kind = CLASSIFIED
values = makeClassifiedValues() values = makeClassifiedValues()
@@ -60,7 +58,6 @@ export class Classified extends DomainObject<ClassifiedValues> {
status: getTagValue("status", event.tags), status: getTagValue("status", event.tags),
images: getTagValues("image", event.tags), images: getTagValues("image", event.tags),
topics: getTopicTagValues(event.tags), topics: getTopicTagValues(event.tags),
h: getTagValue("h", event.tags),
} }
} }
@@ -96,18 +93,6 @@ export class Classified extends DomainObject<ClassifiedValues> {
return this.values.topics return this.values.topics
} }
h() {
return this.values.h
}
room() {
return this.values.h
}
address() {
return getAddress(this.event!)
}
async toTemplate(): Promise<EventTemplate> { async toTemplate(): Promise<EventTemplate> {
const tags: string[][] = [["d", this.values.identifier]] const tags: string[][] = [["d", this.values.identifier]]
@@ -135,10 +120,6 @@ export class Classified extends DomainObject<ClassifiedValues> {
tags.push(["image", image]) tags.push(["image", image])
} }
if (this.values.h) {
tags.push(["h", this.values.h])
}
return {kind: this.kind, content: this.values.content, tags} return {kind: this.kind, content: this.values.content, tags}
} }
} }
+115 -30
View File
@@ -1,34 +1,52 @@
import {COMMENT, getTagValue} from "@welshman/util" import {COMMENT, Address, getAddress, getTagValue, isReplaceableKind} from "@welshman/util"
import type {EventTemplate, TrustedEvent} from "@welshman/util" import type {EventTemplate, TrustedEvent} from "@welshman/util"
import {DomainObject} from "./base.js" import {DomainObject} 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.
export type CommentRef = {
id?: string
address?: string
kind?: string
pubkey?: string
}
// The tag keys NIP-22 uses for the root (uppercase) and parent (lowercase)
// references; stripped on parse and rebuilt from the structs on serialize.
const REF_TAG_KEYS = ["E", "A", "K", "P", "e", "a", "k", "p"]
// Build a reference from a full event, deriving the address only when the event
// is addressable/replaceable.
const refFromEvent = (event: TrustedEvent): CommentRef => ({
id: event.id,
pubkey: event.pubkey,
kind: String(event.kind),
address: isReplaceableKind(event.kind) ? getAddress(event) : undefined,
})
export type CommentValues = { export type CommentValues = {
content: string content: string
rootId?: string root: CommentRef
rootAddress?: string parent: CommentRef
rootKind?: string
rootPubkey?: string
parentId?: string
parentAddress?: string
parentKind?: string
tags: string[][]
} }
export const makeCommentValues = (values: Partial<CommentValues> = {}): CommentValues => ({ export const makeCommentValues = (values: Partial<CommentValues> = {}): CommentValues => ({
content: "", content: "",
tags: [], root: {},
parent: {},
...values, ...values,
}) })
// NIP-22 kind-1111 generic comment, flotilla's universal reply primitive: threads, // 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 // 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 // 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) name the // 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). // immediate parent. The comment body lives in `content` as plain text (not JSON).
// //
// Flotilla builds the reference tags at call sites via @welshman/app's // The reference tags are parsed into the `root`/`parent` structs and rebuilt
// `tagEventForComment(event, url)`, so this class round-trips the raw `tags` // from them in toTemplate; any other tags round-trip via the base `extraTags`
// rather than reconstructing them. // (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<CommentValues> { export class Comment extends DomainObject<CommentValues> {
readonly kind = COMMENT readonly kind = COMMENT
values = makeCommentValues() values = makeCommentValues()
@@ -37,17 +55,25 @@ export class Comment extends DomainObject<CommentValues> {
return makeCommentValues(values) return makeCommentValues(values)
} }
protected reservedTagKeys() {
return REF_TAG_KEYS
}
protected parseEvent(event: TrustedEvent): Partial<CommentValues> { protected parseEvent(event: TrustedEvent): Partial<CommentValues> {
return { return {
content: event.content || "", content: event.content || "",
rootId: getTagValue("E", event.tags), root: {
rootAddress: getTagValue("A", event.tags), id: getTagValue("E", event.tags),
rootKind: getTagValue("K", event.tags), address: getTagValue("A", event.tags),
rootPubkey: getTagValue("P", event.tags), kind: getTagValue("K", event.tags),
parentId: getTagValue("e", event.tags), pubkey: getTagValue("P", event.tags),
parentAddress: getTagValue("a", event.tags), },
parentKind: getTagValue("k", event.tags), parent: {
tags: event.tags, id: getTagValue("e", event.tags),
address: getTagValue("a", event.tags),
kind: getTagValue("k", event.tags),
pubkey: getTagValue("p", event.tags),
},
} }
} }
@@ -56,38 +82,97 @@ export class Comment extends DomainObject<CommentValues> {
} }
rootId() { rootId() {
return this.values.rootId return this.values.root.id
} }
rootAddress() { rootAddress() {
return this.values.rootAddress return this.values.root.address
} }
rootKind() { rootKind() {
return this.values.rootKind return this.values.root.kind
} }
rootPubkey() { rootPubkey() {
return this.values.rootPubkey return this.values.root.pubkey
} }
parentId() { parentId() {
return this.values.parentId return this.values.parent.id
} }
parentAddress() { parentAddress() {
return this.values.parentAddress return this.values.parent.address
} }
parentKind() { parentKind() {
return this.values.parentKind return this.values.parent.kind
}
parentPubkey() {
return this.values.parent.pubkey
}
// 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 = {
id,
pubkey,
kind: String(kind),
address: identifier == null ? undefined : new Address(kind, pubkey, identifier).toString(),
}
return this
}
// Set the immediate parent reference, deriving the address as above.
setParent(kind: number, id: string, pubkey: string, identifier?: string) {
this.values.parent = {
id,
pubkey,
kind: String(kind),
address: identifier == null ? undefined : new Address(kind, pubkey, identifier).toString(),
}
return this
}
// Set the thread root reference from a full event.
setRootFromEvent(event: TrustedEvent) {
this.values.root = refFromEvent(event)
return this
}
// Set the immediate parent reference from a full event.
setParentFromEvent(event: TrustedEvent) {
this.values.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
} }
async toTemplate(): Promise<EventTemplate> { async toTemplate(): Promise<EventTemplate> {
return { return {
kind: this.kind, kind: this.kind,
content: this.values.content, content: this.values.content,
tags: this.values.tags, tags: [
...this.refTags(this.values.root, ["E", "A", "K", "P"]),
...this.refTags(this.values.parent, ["e", "a", "k", "p"]),
],
} }
} }
} }
+4 -4
View File
@@ -1,4 +1,4 @@
import {uniq} from "@welshman/lib" import {uniq, spec} from "@welshman/lib"
import {EMOJIS, getAddressTagValues} from "@welshman/util" import {EMOJIS, getAddressTagValues} from "@welshman/util"
import {EncryptableList} from "./List.js" import {EncryptableList} from "./List.js"
@@ -12,18 +12,18 @@ export class EmojiList extends EncryptableList {
} }
emojis() { emojis() {
return this.tags().filter(t => t[0] === "emoji") return this.tags().filter(spec(["emoji"]))
} }
addEmoji(shortcode: string, url: string) { addEmoji(shortcode: string, url: string) {
return this.addPublicTags(["emoji", shortcode, url]) return this.addPublicTags(["emoji", shortcode, url])
} }
addSet(address: string) { addEmojiSet(address: string) {
return this.addPublicTags(["a", address]) return this.addPublicTags(["a", address])
} }
remove(value: string) { removeEmoji(value: string) {
return this.removeTagsWithValue(value) return this.removeTagsWithValue(value)
} }
} }
+11 -11
View File
@@ -1,5 +1,5 @@
import {parseJson} from "@welshman/lib" import {parseJson} from "@welshman/lib"
import {FEED, getIdentifier, getTagValue, getAddress} from "@welshman/util" import {FEED, getIdentifier, getTagValue} from "@welshman/util"
import type {EventTemplate, TrustedEvent} from "@welshman/util" import type {EventTemplate, TrustedEvent} from "@welshman/util"
import {DomainObject} from "./base.js" import {DomainObject} from "./base.js"
@@ -16,7 +16,9 @@ export const makeFeedValues = (values: Partial<FeedValues> = {}): FeedValues =>
identifier: "", identifier: "",
title: "", title: "",
description: "", description: "",
definition: undefined, // 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, ...values,
}) })
@@ -36,11 +38,17 @@ export class Feed extends DomainObject<FeedValues> {
} }
protected parseEvent(event: TrustedEvent): Partial<FeedValues> { protected parseEvent(event: TrustedEvent): Partial<FeedValues> {
const feed = getTagValue("feed", event.tags)
if (feed == null) {
throw new Error(`Expected a "feed" tag on kind ${this.kind} event`)
}
return { return {
identifier: getIdentifier(event) || "", identifier: getIdentifier(event) || "",
title: getTagValue("title", event.tags) || "", title: getTagValue("title", event.tags) || "",
description: getTagValue("description", event.tags) || "", description: getTagValue("description", event.tags) || "",
definition: parseJson(getTagValue("feed", event.tags) || ""), definition: parseJson(feed),
} }
} }
@@ -60,14 +68,6 @@ export class Feed extends DomainObject<FeedValues> {
return this.values.definition return this.values.definition
} }
display() {
return this.values.title || "[no name]"
}
getAddress() {
return getAddress(this.event!)
}
async toTemplate(): Promise<EventTemplate> { async toTemplate(): Promise<EventTemplate> {
const {identifier, title, description, definition} = this.values const {identifier, title, description, definition} = this.values
+2 -2
View File
@@ -11,11 +11,11 @@ export class FeedList extends EncryptableList {
return uniq(getAddressTagValues(this.tags())) return uniq(getAddressTagValues(this.tags()))
} }
add(address: string, relayHint?: string) { addFeed(address: string, relayHint?: string) {
return this.addPublicTags(["a", address, relayHint || ""]) return this.addPublicTags(["a", address, relayHint || ""])
} }
remove(address: string) { removeFeed(address: string) {
return this.removeTagsWithValue(address) return this.removeTagsWithValue(address)
} }
} }
-33
View File
@@ -1,33 +0,0 @@
import {uniq} from "@welshman/lib"
import {FILE_SERVERS, getTagValues, normalizeRelayUrl} from "@welshman/util"
import {EncryptableList} from "./List.js"
// NIP-96 file storage server list (kind 10096). 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. Structurally identical to
// BlossomServerList; effectively public-only.
export class FileServerList extends EncryptableList {
readonly kind = FILE_SERVERS
servers() {
return uniq(getTagValues("server", this.tags()).map(normalizeRelayUrl))
}
includes(url: string) {
return this.servers().includes(url)
}
addServer(url: string) {
return this.addPublicTags(["server", normalizeRelayUrl(url)])
}
removeServer(url: string) {
return this.removeTagsWithValue(url)
}
setServers(urls: string[]) {
this.keepTagsWithKey("server")
return this.addPublicTags(...urls.map(url => ["server", normalizeRelayUrl(url)]))
}
}
@@ -2,20 +2,20 @@ import {uniq} from "@welshman/lib"
import {COMMUNITIES, getAddressTagValues} from "@welshman/util" import {COMMUNITIES, getAddressTagValues} from "@welshman/util"
import {EncryptableList} from "./List.js" import {EncryptableList} from "./List.js"
// NIP-51 kind-10004 community membership list. Entries are `a` tags pointing at // NIP-51 kind-10004 group (community) membership list. Entries are `a` tags
// kind-34550 community definitions. // pointing at kind-34550 community definitions.
export class CommunityList extends EncryptableList { export class GroupList extends EncryptableList {
readonly kind = COMMUNITIES readonly kind = COMMUNITIES
addresses() { addresses() {
return uniq(getAddressTagValues(this.tags())) return uniq(getAddressTagValues(this.tags()))
} }
add(address: string, relayHint?: string) { addGroup(address: string, relayHint?: string) {
return this.addPublicTags(["a", address, relayHint || ""]) return this.addPublicTags(["a", address, relayHint || ""])
} }
remove(address: string) { removeGroup(address: string) {
return this.removeTagsWithValue(address) return this.removeTagsWithValue(address)
} }
} }
+9 -17
View File
@@ -1,5 +1,5 @@
import {parseJson} from "@welshman/lib" import {parseJson} from "@welshman/lib"
import {HANDLER_INFORMATION, getKindTagValues, getIdentifier, getAddress} from "@welshman/util" import {HANDLER_INFORMATION, getKindTagValues, getTagValue} from "@welshman/util"
import type {EventTemplate, TrustedEvent} from "@welshman/util" import type {EventTemplate, TrustedEvent} from "@welshman/util"
import {DomainObject} from "./base.js" import {DomainObject} from "./base.js"
@@ -29,6 +29,10 @@ export class Handler extends DomainObject<HandlerValues> {
return makeHandlerValues(values) return makeHandlerValues(values)
} }
protected reservedTagKeys() {
return ["k"]
}
protected parseEvent(event: TrustedEvent): Partial<HandlerValues> { protected parseEvent(event: TrustedEvent): Partial<HandlerValues> {
const meta = parseJson(event.content) || {} const meta = parseJson(event.content) || {}
@@ -71,20 +75,8 @@ export class Handler extends DomainObject<HandlerValues> {
return this.values.kinds return this.values.kinds
} }
supportsKind(kind: number) {
return this.values.kinds.includes(kind)
}
identifier() { identifier() {
return getIdentifier(this.event!) return getTagValue("d", this.extraTags)
}
display(fallback = "") {
return this.name() || fallback
}
getAddress() {
return getAddress(this.event!)
} }
async toTemplate(): Promise<EventTemplate> { async toTemplate(): Promise<EventTemplate> {
@@ -92,14 +84,14 @@ export class Handler extends DomainObject<HandlerValues> {
const content = JSON.stringify({name, about, image, website, lud16, nip05}) const content = JSON.stringify({name, about, image, website, lud16, nip05})
// Rebuild `k` tags from values.kinds, preserving existing `d`/`a` tags. // Rebuild `k` tags from values.kinds; everything else carries over via the
const preservedTags = (this.event?.tags || []).filter(t => t[0] === "d" || t[0] === "a") // base extraTags, appended in addBehaviorTags.
const kindTags = this.values.kinds.map(kind => ["k", String(kind)]) const kindTags = this.values.kinds.map(kind => ["k", String(kind)])
return { return {
kind: this.kind, kind: this.kind,
content, content,
tags: [...preservedTags, ...kindTags], tags: kindTags,
} }
} }
} }
+31 -2
View File
@@ -79,10 +79,10 @@ export abstract class EncryptableList extends DomainObject<ListValues> {
} }
keepTags(pred: (tag: string[]) => boolean) { keepTags(pred: (tag: string[]) => boolean) {
this.values.publicTags = this.values.publicTags.filter(t => !pred(t)) this.values.publicTags = this.values.publicTags.filter(t => pred(t))
if (this.values.decrypted) { if (this.values.decrypted) {
this.values.privateTags = this.values.privateTags.filter(t => !pred(t)) this.values.privateTags = this.values.privateTags.filter(t => pred(t))
} }
return this return this
@@ -114,6 +114,35 @@ export abstract class EncryptableList extends DomainObject<ListValues> {
return this.removeTags(nthEq(1, value)) return this.removeTags(nthEq(1, value))
} }
clearPublicTags() {
this.values.publicTags = []
return this
}
clearPrivateTags() {
if (!this.values.decrypted) {
throw new Error("Cannot modify the private entries of a list that has not been decrypted")
}
this.values.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 = []
if (this.values.decrypted) {
this.values.privateTags = []
}
return this
}
async toTemplate(signer?: ISigner): Promise<EventTemplate> { async toTemplate(signer?: ISigner): Promise<EventTemplate> {
const tags = this.values.publicTags const tags = this.values.publicTags
+1 -1
View File
@@ -23,7 +23,7 @@ export class MessagingRelayList extends EncryptableList {
} }
setRelays(urls: string[]) { setRelays(urls: string[]) {
this.keepTagsWithKey("relay") this.clearTags()
return this.addPublicTags(...urls.map(url => ["relay", normalizeRelayUrl(url)])) return this.addPublicTags(...urls.map(url => ["relay", normalizeRelayUrl(url)]))
} }
-10
View File
@@ -21,7 +21,6 @@ export type PollValues = {
pollType: PollType pollType: PollType
endsAt?: number endsAt?: number
relays: string[] relays: string[]
h?: string
} }
export const makePollValues = (values: Partial<PollValues> = {}): PollValues => ({ export const makePollValues = (values: Partial<PollValues> = {}): PollValues => ({
@@ -55,7 +54,6 @@ export class Poll extends DomainObject<PollValues> {
pollType: (getTagValue("polltype", event.tags) as PollType) || "singlechoice", pollType: (getTagValue("polltype", event.tags) as PollType) || "singlechoice",
endsAt: Number.isNaN(endsAt) ? undefined : endsAt, endsAt: Number.isNaN(endsAt) ? undefined : endsAt,
relays: getTagValues("relay", event.tags), relays: getTagValues("relay", event.tags),
h: getTagValue("h", event.tags),
} }
} }
@@ -83,10 +81,6 @@ export class Poll extends DomainObject<PollValues> {
return this.values.relays return this.values.relays
} }
h() {
return this.values.h
}
// Tally the latest response per pubkey across the poll options. Each response // Tally the latest response per pubkey across the poll options. Each response
// is a kind-1018 event whose "response" tags name selected option ids; // is a kind-1018 event whose "response" tags name selected option ids;
// single-choice polls only honor the first selection. // single-choice polls only honor the first selection.
@@ -134,10 +128,6 @@ export class Poll extends DomainObject<PollValues> {
tags.push(["relay", relay]) tags.push(["relay", relay])
} }
if (this.values.h) {
tags.push(["h", this.values.h])
}
return {kind: this.kind, content: this.values.title, tags} return {kind: this.kind, content: this.values.title, tags}
} }
} }
+1 -5
View File
@@ -38,11 +38,7 @@ export class PollResponse extends DomainObject<PollResponseValues> {
return this.values.pollId return this.values.pollId
} }
selections(pollType?: "singlechoice" | "multiplechoice") { selections() {
if (pollType === "singlechoice") {
return this.values.selections.slice(0, 1)
}
return uniq(this.values.selections) return uniq(this.values.selections)
} }
+23 -50
View File
@@ -1,76 +1,41 @@
import {uniq} from "@welshman/lib" import {uniq} from "@welshman/lib"
import {RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, getPubkeyTagValues} from "@welshman/util" import {RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, getPubkeyTagValues} from "@welshman/util"
import type {EventTemplate, TrustedEvent} from "@welshman/util" import type {EventTemplate, TrustedEvent} from "@welshman/util"
import type {ISigner} from "@welshman/signer"
import {DomainObject} from "./base.js" import {DomainObject} from "./base.js"
export type RelayMembershipOpValues = { export type RelayMembershipValues = {
kind: number
pubkeys: string[] pubkeys: string[]
} }
export const makeRelayMembershipOpValues = ( export const makeRelayMembershipValues = (
values: Partial<RelayMembershipOpValues> = {}, values: Partial<RelayMembershipValues> = {},
): RelayMembershipOpValues => ({ ): RelayMembershipValues => ({
kind: RELAY_ADD_MEMBER,
pubkeys: [], pubkeys: [],
...values, ...values,
}) })
export const makeRelayAddMember = (pubkeys: string[]) => // Relay/space-level moderation op carrying the affected pubkeys in "p" tags. Add
RelayMembershipOp.init({kind: RELAY_ADD_MEMBER, pubkeys}) // (kind 8000) and remove (kind 8001) are regular (non-addressable) events that
// share this shape; each is its own concrete class fixing the kind.
export const makeRelayRemoveMember = (pubkeys: string[]) =>
RelayMembershipOp.init({kind: RELAY_REMOVE_MEMBER, pubkeys})
// Relay/space-level moderation op for adding (kind 8000) or removing (kind 8001)
// members. These are regular (non-addressable) events carrying the affected
// pubkeys in "p" tags. The two kinds share an identical shape, so they're merged
// into one kind-discriminated class.
// //
// Flotilla's deriveUserSpaceMembershipStatus replays this history (RELAY_ADD_MEMBER // Flotilla's deriveUserSpaceMembershipStatus replays this history (RelayAddMember
// => isMember true, RELAY_REMOVE_MEMBER => isMember false) when no RELAY_MEMBERS // => isMember true, RelayRemoveMember => isMember false) when no RELAY_MEMBERS
// snapshot is available. // snapshot is available.
// export abstract class RelayMembershipOp extends DomainObject<RelayMembershipValues> {
// Because the base DomainObject treats `kind` as a fixed value and asserts values = makeRelayMembershipValues()
// event.kind === this.kind in parse(), `kind` is a mutable instance field here:
// it's seeded from values.kind in normalizeValues, and parse() is overridden to
// adopt the event's kind before normalizing.
export class RelayMembershipOp extends DomainObject<RelayMembershipOpValues> {
kind = RELAY_ADD_MEMBER
values = makeRelayMembershipOpValues()
protected normalizeValues(values: Partial<RelayMembershipOpValues> = {}) { protected normalizeValues(values: Partial<RelayMembershipValues> = {}) {
const normalized = makeRelayMembershipOpValues(values) return makeRelayMembershipValues(values)
this.kind = normalized.kind
return normalized
} }
protected parseEvent(event: TrustedEvent): Partial<RelayMembershipOpValues> { protected parseEvent(event: TrustedEvent): Partial<RelayMembershipValues> {
return { return {pubkeys: uniq(getPubkeyTagValues(event.tags))}
kind: event.kind,
pubkeys: uniq(getPubkeyTagValues(event.tags)),
}
}
async parse(event: TrustedEvent, signer?: ISigner) {
this.event = event
this.kind = event.kind
this.values = this.normalizeValues(await this.parseEvent(event, signer))
return this
} }
pubkeys() { pubkeys() {
return this.values.pubkeys return this.values.pubkeys
} }
isAdd() {
return this.kind === RELAY_ADD_MEMBER
}
async toTemplate(): Promise<EventTemplate> { async toTemplate(): Promise<EventTemplate> {
return { return {
kind: this.kind, kind: this.kind,
@@ -79,3 +44,11 @@ export class RelayMembershipOp extends DomainObject<RelayMembershipOpValues> {
} }
} }
} }
export class RelayAddMember extends RelayMembershipOp {
readonly kind = RELAY_ADD_MEMBER
}
export class RelayRemoveMember extends RelayMembershipOp {
readonly kind = RELAY_REMOVE_MEMBER
}
+2 -1
View File
@@ -39,7 +39,8 @@ export class RelaySet extends EncryptableList {
} }
setRelays(urls: string[]) { setRelays(urls: string[]) {
this.keepTagsWithKey("relay") // Replace only the relay entries; preserve the set's d/title/description/image metadata.
this.removeTagsWithKey("relay")
return this.addPublicTags(...urls.map(url => ["relay", normalizeRelayUrl(url)])) return this.addPublicTags(...urls.map(url => ["relay", normalizeRelayUrl(url)]))
} }
+29 -60
View File
@@ -1,85 +1,54 @@
import {uniq} from "@welshman/lib" import {uniq} from "@welshman/lib"
import {ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER, getTagValue, getPubkeyTagValues} from "@welshman/util" import {ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER, getPubkeyTagValues} from "@welshman/util"
import type {EventTemplate, TrustedEvent} from "@welshman/util" import type {EventTemplate, TrustedEvent} from "@welshman/util"
import type {ISigner} from "@welshman/signer"
import {DomainObject} from "./base.js" import {DomainObject} from "./base.js"
export type RoomMembershipOpValues = { export type RoomMembershipValues = {
kind: number
h: string
pubkeys: string[] pubkeys: string[]
} }
export const makeRoomMembershipOpValues = ( export const makeRoomMembershipValues = (
values: Partial<RoomMembershipOpValues> = {}, values: Partial<RoomMembershipValues> = {},
): RoomMembershipOpValues => ({ ): RoomMembershipValues => ({
kind: ROOM_ADD_MEMBER,
h: "",
pubkeys: [], pubkeys: [],
...values, ...values,
}) })
export const makeRoomAddMember = (h: string, pubkeys: string[]) =>
RoomMembershipOp.init({kind: ROOM_ADD_MEMBER, h, pubkeys})
export const makeRoomRemoveMember = (h: string, pubkeys: string[]) =>
RoomMembershipOp.init({kind: ROOM_REMOVE_MEMBER, h, pubkeys})
// NIP-29 moderation op for adding (kind 9000) or removing (kind 9001) room // NIP-29 moderation op for adding (kind 9000) or removing (kind 9001) room
// members. These are regular (non-addressable) events carrying the group id in // members. Regular (non-addressable) events carrying the affected pubkeys in "p"
// the "h" tag and the affected pubkeys in "p" tags. The two kinds share an // tags; the target group id is the base `group` ("h") behavior tag. Add and
// identical shape, so they're merged into one kind-discriminated class. // remove share this shape; each is its own concrete class fixing the kind.
// //
// Because the base DomainObject treats `kind` as a fixed value and asserts // Flotilla's membership replay treats RoomAddMember => member, RoomRemoveMember
// event.kind === this.kind in parse(), `kind` is a mutable instance field here: // => not a member.
// it's seeded from values.kind in normalizeValues, and parse() is overridden to export abstract class RoomMembershipOp extends DomainObject<RoomMembershipValues> {
// adopt the event's kind before normalizing. values = makeRoomMembershipValues()
export class RoomMembershipOp extends DomainObject<RoomMembershipOpValues> {
kind = ROOM_ADD_MEMBER
values = makeRoomMembershipOpValues()
protected normalizeValues(values: Partial<RoomMembershipOpValues> = {}) { protected normalizeValues(values: Partial<RoomMembershipValues> = {}) {
const normalized = makeRoomMembershipOpValues(values) return makeRoomMembershipValues(values)
this.kind = normalized.kind
return normalized
} }
protected parseEvent(event: TrustedEvent): Partial<RoomMembershipOpValues> { protected parseEvent(event: TrustedEvent): Partial<RoomMembershipValues> {
return { return {pubkeys: uniq(getPubkeyTagValues(event.tags))}
kind: event.kind,
h: getTagValue("h", event.tags) || "",
pubkeys: uniq(getPubkeyTagValues(event.tags)),
}
}
async parse(event: TrustedEvent, signer?: ISigner) {
this.event = event
this.kind = event.kind
this.values = this.normalizeValues(await this.parseEvent(event, signer))
return this
}
h() {
return this.values.h
} }
pubkeys() { pubkeys() {
return this.values.pubkeys return this.values.pubkeys
} }
isAdd() {
return this.kind === ROOM_ADD_MEMBER
}
async toTemplate(): Promise<EventTemplate> { async toTemplate(): Promise<EventTemplate> {
const tags: string[][] = [ return {
["h", this.values.h], kind: this.kind,
...this.values.pubkeys.map(pk => ["p", pk]), tags: this.values.pubkeys.map(pk => ["p", pk]),
] content: "",
}
return {kind: this.kind, tags, content: ""}
} }
} }
export class RoomAddMember extends RoomMembershipOp {
readonly kind = ROOM_ADD_MEMBER
}
export class RoomRemoveMember extends RoomMembershipOp {
readonly kind = ROOM_REMOVE_MEMBER
}
+2 -25
View File
@@ -1,3 +1,4 @@
import {randomId} from "@welshman/lib"
import {ROOM_META, getIdentifier, getTag, getTagValue} from "@welshman/util" import {ROOM_META, getIdentifier, getTag, getTagValue} from "@welshman/util"
import type {EventTemplate, TrustedEvent} from "@welshman/util" import type {EventTemplate, TrustedEvent} from "@welshman/util"
import {DomainObject} from "./base.js" import {DomainObject} from "./base.js"
@@ -15,32 +16,8 @@ export type RoomMetaValues = {
livekit: boolean livekit: boolean
} }
const vowels = "a,e,i,o,u,ay,ey,oy,ou,ia,ea,ough,oo,ee,argh".split(",")
const consonants =
"p,b,t,d,k,g,ch,sh,th,f,v,s,z,l,r,m,n,pl,bl,cl,gl,pr,br,tr,dr,kr,gr,fl,sl,fr,thr,str,sk,sp,st".split(
",",
)
// Generate a random NIP-29 group id ("h" / "d" tag value).
export const generateH = () => {
const n = (6 + Math.random() * 2) | 0
const s = [consonants, vowels]
if (Math.random() < 0.5) {
s.reverse()
}
return (
Array.from({length: n}, (_, i) =>
s[i % 2].splice((Math.random() * s[i % 2].length) | 0, 1),
).join("") +
(1 + Math.floor(Math.random() * 9))
)
}
export const makeRoomMetaValues = (values: Partial<RoomMetaValues> = {}): RoomMetaValues => ({ export const makeRoomMetaValues = (values: Partial<RoomMetaValues> = {}): RoomMetaValues => ({
h: values.h || generateH(), h: values.h || randomId(),
isClosed: false, isClosed: false,
isHidden: false, isHidden: false,
isPrivate: false, isPrivate: false,
+1 -1
View File
@@ -21,7 +21,7 @@ export class SearchRelayList extends EncryptableList {
} }
setRelays(urls: string[]) { setRelays(urls: string[]) {
this.keepTagsWithKey("relay") this.clearTags()
return this.addPublicTags(...urls.map(url => ["relay", normalizeRelayUrl(url)])) return this.addPublicTags(...urls.map(url => ["relay", normalizeRelayUrl(url)]))
} }
-192
View File
@@ -1,192 +0,0 @@
import {append, parseJson, remove} from "@welshman/lib"
import {APP_DATA} from "@welshman/util"
import type {EventTemplate, TrustedEvent} from "@welshman/util"
import {decrypt} from "@welshman/signer"
import type {ISigner} from "@welshman/signer"
import {DomainObject} from "./base.js"
// Literal d-tag identifying flotilla's single addressable settings event.
export const SETTINGS_IDENTIFIER = "flotilla/settings"
export type SpaceNotificationSettings = {
url: string
notify: boolean
exceptions: string[]
}
export type SettingsValues = {
show_media: boolean
hide_sensitive: boolean
trusted_relays: string[]
report_usage: boolean
report_errors: boolean
relay_auth: "aggressive" | "conservative"
send_delay: number
font_size: number
alerts: SpaceNotificationSettings[]
zap_amounts: number[]
}
export const defaultSettings: SettingsValues = {
show_media: true,
hide_sensitive: true,
trusted_relays: [],
report_usage: true,
report_errors: true,
relay_auth: "conservative",
send_delay: 0,
font_size: 1.1,
alerts: [],
zap_amounts: [21, 210, 2100, 21000],
}
export const makeSettingsValues = (values: Partial<SettingsValues> = {}): SettingsValues => ({
...defaultSettings,
...values,
})
// FLOTILLA-SPECIFIC kind-30078 (NIP-78 app data) settings blob, addressable via the
// literal d-tag "flotilla/settings". The content is NIP-44 encrypted JSON, so `parse`
// needs the signer to decrypt and `toTemplate` needs it to encrypt: the values are
// stored to the author's own pubkey. Mutators stay synchronous over the decrypted
// `values`; encryption only happens on serialization.
export class Settings extends DomainObject<SettingsValues> {
readonly kind = APP_DATA
values = makeSettingsValues()
protected normalizeValues(values: Partial<SettingsValues> = {}) {
return makeSettingsValues(values)
}
protected async parseEvent(
event: TrustedEvent,
signer?: ISigner,
): Promise<Partial<SettingsValues>> {
if (!event.content) return defaultSettings
let plaintext = ""
if (signer) {
try {
plaintext = await decrypt(signer, event.pubkey, event.content)
} catch (e) {
return defaultSettings
}
}
return {...defaultSettings, ...(parseJson(plaintext) || {})}
}
showMedia() {
return this.values.show_media
}
hideSensitive() {
return this.values.hide_sensitive
}
trustedRelays() {
return this.values.trusted_relays
}
reportUsage() {
return this.values.report_usage
}
reportErrors() {
return this.values.report_errors
}
relayAuth() {
return this.values.relay_auth
}
sendDelay() {
return this.values.send_delay
}
fontSize() {
return this.values.font_size
}
alerts() {
return this.values.alerts
}
zapAmounts() {
return this.values.zap_amounts
}
// Port of flotilla's getShouldNotify branching: missing pref -> notify; space-level
// (no room) -> the pref's notify flag; otherwise rooms in `exceptions` invert the flag.
getShouldNotify(url: string, h?: string) {
const pref = this.values.alerts.find(s => s.url === url)
if (!pref) return true
if (!h) return pref.notify
if (pref.notify) return !pref.exceptions.includes(h)
return pref.exceptions.includes(h)
}
addTrustedRelay(url: string) {
this.values.trusted_relays = append(url, this.values.trusted_relays)
return this
}
removeTrustedRelay(url: string) {
this.values.trusted_relays = remove(url, this.values.trusted_relays)
return this
}
// Upsert a space-level notification preference, resetting its room exceptions.
setSpaceNotifications(url: string, notify: boolean) {
const {alerts} = this.values
const existing = alerts.find(s => s.url === url)
if (existing) {
this.values.alerts = alerts.map(s =>
s.url === url ? {...s, notify, exceptions: []} : s,
)
} else {
this.values.alerts = [...alerts, {url, notify, exceptions: []}]
}
return this
}
// Toggle a room (h) in a space's exception list, creating the pref if absent.
toggleRoomNotifications(url: string, h: string) {
const {alerts} = this.values
const existing = alerts.find(s => s.url === url)
if (!existing) {
this.values.alerts = [...alerts, {url, notify: true, exceptions: [h]}]
} else {
const exceptions = existing.exceptions.includes(h)
? remove(h, existing.exceptions)
: append(h, existing.exceptions)
this.values.alerts = alerts.map(s => (s.url === url ? {...s, exceptions} : s))
}
return this
}
async toTemplate(signer?: ISigner): Promise<EventTemplate> {
if (!signer) {
throw new Error("A signer is required to serialize Settings")
}
const pubkey = await signer.getPubkey()
const content = await signer.nip44.encrypt(pubkey, JSON.stringify(this.values))
return {
kind: this.kind,
content,
tags: [["d", SETTINGS_IDENTIFIER]],
}
}
}
+9 -22
View File
@@ -5,7 +5,6 @@ import {DomainObject} from "./base.js"
export type ThreadValues = { export type ThreadValues = {
title?: string title?: string
content: string content: string
h?: string
} }
export const makeThreadValues = (values: Partial<ThreadValues> = {}): ThreadValues => ({ export const makeThreadValues = (values: Partial<ThreadValues> = {}): ThreadValues => ({
@@ -14,12 +13,11 @@ export const makeThreadValues = (values: Partial<ThreadValues> = {}): ThreadValu
}) })
// NIP-7D kind-11 forum thread root. The body lives in `content` as plain text // NIP-7D kind-11 forum thread root. The body lives in `content` as plain text
// (not JSON), the title is carried in a "title" tag, and an optional "h" tag // (not JSON) and the title is carried in a "title" tag; room scoping is handled
// scopes the thread to a room. Non-addressable (referenced by event id); // by the base `group` behavior tag. Non-addressable (referenced by event id);
// replies are COMMENT (kind 1111) via "#E". Flotilla also appends editor/inline // replies are COMMENT (kind 1111) via "#E". Flotilla also appends editor/inline
// tags and an optional PROTECTED ("-") tag at call sites, so on serialize we // tags at call sites; those round-trip via the base `extraTags` (with "title"
// preserve any non-title/non-h tags from the parsed event to keep round-tripping // declared reserved so it isn't double-counted).
// lossless for those extra tags.
export class Thread extends DomainObject<ThreadValues> { export class Thread extends DomainObject<ThreadValues> {
readonly kind = THREAD readonly kind = THREAD
values = makeThreadValues() values = makeThreadValues()
@@ -28,11 +26,14 @@ export class Thread extends DomainObject<ThreadValues> {
return makeThreadValues(values) return makeThreadValues(values)
} }
protected reservedTagKeys() {
return ["title"]
}
protected parseEvent(event: TrustedEvent): Partial<ThreadValues> { protected parseEvent(event: TrustedEvent): Partial<ThreadValues> {
return { return {
title: getTagValue("title", event.tags), title: getTagValue("title", event.tags),
content: event.content || "", content: event.content || "",
h: getTagValue("h", event.tags),
} }
} }
@@ -44,27 +45,13 @@ export class Thread extends DomainObject<ThreadValues> {
return this.values.content return this.values.content
} }
h() {
return this.values.h
}
room() {
return this.values.h
}
async toTemplate(): Promise<EventTemplate> { async toTemplate(): Promise<EventTemplate> {
const tags: string[][] = (this.event?.tags || []).filter( const tags: string[][] = []
t => t[0] !== "title" && t[0] !== "h",
)
if (this.values.title) { if (this.values.title) {
tags.push(["title", this.values.title]) tags.push(["title", this.values.title])
} }
if (this.values.h) {
tags.push(["h", this.values.h])
}
return {kind: this.kind, content: this.values.content, tags} return {kind: this.kind, content: this.values.content, tags}
} }
} }
@@ -1,45 +1,47 @@
import {EVENT_TIME, getIdentifier, getTagValue, getTagValues, getAddress} from "@welshman/util" import {range, DAY} from "@welshman/lib"
import {EVENT_TIME, getIdentifier, getTagValue} from "@welshman/util"
import type {EventTemplate, TrustedEvent} from "@welshman/util" import type {EventTemplate, TrustedEvent} from "@welshman/util"
import {DomainObject} from "./base.js" import {DomainObject} from "./base.js"
export type CalendarEventValues = { export type TimeEventValues = {
identifier: string identifier: string
title?: string title?: string
location?: string location?: string
content: string content: string
start?: number start?: number
end?: number end?: number
days: string[]
h?: string
} }
export const makeCalendarEventValues = ( export const makeTimeEventValues = (
values: Partial<CalendarEventValues> = {}, values: Partial<TimeEventValues> = {},
): CalendarEventValues => ({ ): TimeEventValues => ({
identifier: "", identifier: "",
content: "", content: "",
days: [],
...values, ...values,
}) })
// NIP-52 kind-31923 time-based calendar event. Addressable via the "d" tag. // NIP-52 kind-31923 time-based calendar event. Addressable via the "d" tag.
// `start`/`end` are unix-second timestamps carried in "start"/"end" tags // `start`/`end` are unix-second timestamps carried in "start"/"end" tags
// (parsed with parseInt), `title` falls back to the legacy "name" tag, and the // (parsed with parseInt), `title` falls back to the legacy "name" tag, and the
// plain-text body lives in `content`. Flotilla additionally writes per-day // plain-text body lives in `content`. Room scoping is handled by the base
// ["D", "YYYY-MM-DD"] tags spanning start..end for day-bucket querying, and an // `group` behavior tag. Named
// optional "h" tag scoping the event to a room (commented via "#A"). This is the // TimeEvent (not CalendarEvent) to leave room for a future date-based event
// only calendar object flotilla uses (CALENDAR 31924 / EVENT_DATE 31922 / // (EVENT_DATE 31922); CALENDAR 31924 / EVENT_RSVP 31925 are not used. Tags +
// EVENT_RSVP 31925 are not used). Tags + plain content, so it extends // plain content, so it extends DomainObject directly.
// DomainObject directly. //
export class CalendarEvent extends DomainObject<CalendarEventValues> { // 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
// daysBetween: one tag per epoch-day floor(seconds / DAY) the event spans).
export class TimeEvent extends DomainObject<TimeEventValues> {
readonly kind = EVENT_TIME readonly kind = EVENT_TIME
values = makeCalendarEventValues() values = makeTimeEventValues()
protected normalizeValues(values: Partial<CalendarEventValues> = {}) { protected normalizeValues(values: Partial<TimeEventValues> = {}) {
return makeCalendarEventValues(values) return makeTimeEventValues(values)
} }
protected parseEvent(event: TrustedEvent): Partial<CalendarEventValues> { protected parseEvent(event: TrustedEvent): Partial<TimeEventValues> {
const start = parseInt(getTagValue("start", event.tags)!) const start = parseInt(getTagValue("start", event.tags)!)
const end = parseInt(getTagValue("end", event.tags)!) const end = parseInt(getTagValue("end", event.tags)!)
@@ -50,8 +52,6 @@ export class CalendarEvent extends DomainObject<CalendarEventValues> {
content: event.content || "", content: event.content || "",
start: isNaN(start) ? undefined : start, start: isNaN(start) ? undefined : start,
end: isNaN(end) ? undefined : end, end: isNaN(end) ? undefined : end,
days: getTagValues("D", event.tags),
h: getTagValue("h", event.tags),
} }
} }
@@ -79,24 +79,8 @@ export class CalendarEvent extends DomainObject<CalendarEventValues> {
return this.values.end return this.values.end
} }
days() {
return this.values.days
}
h() {
return this.values.h
}
room() {
return this.values.h
}
address() {
return getAddress(this.event!)
}
async toTemplate(): Promise<EventTemplate> { async toTemplate(): Promise<EventTemplate> {
const {identifier, title, location, content, start, end, days, h} = this.values const {identifier, title, location, content, start, end} = this.values
const tags: string[][] = [["d", identifier]] const tags: string[][] = [["d", identifier]]
@@ -105,12 +89,13 @@ export class CalendarEvent extends DomainObject<CalendarEventValues> {
if (start != null) tags.push(["start", String(start)]) if (start != null) tags.push(["start", String(start)])
if (end != null) tags.push(["end", String(end)]) if (end != null) tags.push(["end", String(end)])
for (const day of days) { // Derived day index for filtering: one "D" tag per epoch-day the event spans.
tags.push(["D", day]) if (start != null && end != null) {
for (const t of range(start, end, DAY)) {
tags.push(["D", String(Math.floor(t / DAY))])
}
} }
if (h) tags.push(["h", h])
return {kind: this.kind, content, tags} return {kind: this.kind, content, tags}
} }
} }
+2 -16
View File
@@ -7,7 +7,6 @@ export type ZapGoalValues = {
summary?: string summary?: string
amount: number amount: number
relays: string[] relays: string[]
h?: string
} }
export const makeZapGoalValues = (values: Partial<ZapGoalValues> = {}): ZapGoalValues => ({ export const makeZapGoalValues = (values: Partial<ZapGoalValues> = {}): ZapGoalValues => ({
@@ -20,8 +19,8 @@ export const makeZapGoalValues = (values: Partial<ZapGoalValues> = {}): ZapGoalV
// NIP-75 kind-9041 zap goal. A fundraising target that drives flotilla's goals // 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 // feature: the goal title lives in `content` as plain text (not JSON), the body
// in a "summary" tag, the target amount in an "amount" tag (millisats, parsed as // in a "summary" tag, the target amount in an "amount" tag (millisats, parsed as
// an int defaulting to 0), the relays to tally receipts from in repeated // an int defaulting to 0), and the relays to tally receipts from in repeated
// "relays" tags, and an optional "h" tag scopes the goal to a room. // "relays" tags; room scoping is handled by the base `group` behavior tag.
// Non-addressable (referenced by event id via "#E"); the funding tally is // Non-addressable (referenced by event id via "#E"); the funding tally is
// computed elsewhere from sibling zap receipts (ZAP_RESPONSE) and is not modeled // computed elsewhere from sibling zap receipts (ZAP_RESPONSE) and is not modeled
// here. Tags-only metadata, so it extends DomainObject directly. // here. Tags-only metadata, so it extends DomainObject directly.
@@ -39,7 +38,6 @@ export class ZapGoal extends DomainObject<ZapGoalValues> {
summary: getTagValue("summary", event.tags), summary: getTagValue("summary", event.tags),
amount: parseInt(getTagValue("amount", event.tags) || "0") || 0, amount: parseInt(getTagValue("amount", event.tags) || "0") || 0,
relays: getTagValues("relays", event.tags), relays: getTagValues("relays", event.tags),
h: getTagValue("h", event.tags),
} }
} }
@@ -59,14 +57,6 @@ export class ZapGoal extends DomainObject<ZapGoalValues> {
return this.values.relays return this.values.relays
} }
h() {
return this.values.h
}
room() {
return this.values.h
}
async toTemplate(): Promise<EventTemplate> { async toTemplate(): Promise<EventTemplate> {
const tags: string[][] = [] const tags: string[][] = []
@@ -80,10 +70,6 @@ export class ZapGoal extends DomainObject<ZapGoalValues> {
tags.push(["relays", relay]) tags.push(["relays", relay])
} }
if (this.values.h) {
tags.push(["h", this.values.h])
}
return {kind: this.kind, content: this.values.title, tags} return {kind: this.kind, content: this.values.title, tags}
} }
} }
+72 -3
View File
@@ -1,7 +1,10 @@
import {stamp, prep} from "@welshman/util" import {stamp, prep, getTagValue} from "@welshman/util"
import type {EventTemplate, SignedEvent, HashedEvent, TrustedEvent} from "@welshman/util" import type {EventTemplate, SignedEvent, HashedEvent, TrustedEvent} from "@welshman/util"
import type {ISigner} from "@welshman/signer" 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"]
/** /**
* The base class for domain objects. * The base class for domain objects.
* *
@@ -26,6 +29,19 @@ export abstract class DomainObject<V extends Record<string, unknown>> {
abstract values: V abstract values: V
event?: TrustedEvent event?: TrustedEvent
// 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]
// 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<T extends DomainObject<Record<string, unknown>>>( static init<T extends DomainObject<Record<string, unknown>>>(
this: new () => T, this: new () => T,
values?: Partial<T["values"]>, values?: Partial<T["values"]>,
@@ -53,6 +69,18 @@ export abstract class DomainObject<V extends Record<string, unknown>> {
} }
this.event = event this.event = event
this.group = getTagValue("h", event.tags)
this.protect = event.tags.some(t => t[0] === "-")
const expiration = parseInt(getTagValue("expiration", event.tags) ?? "")
this.expires = isNaN(expiration) ? undefined : expiration
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)) this.values = this.normalizeValues(await this.parseEvent(event, signer))
return this return this
@@ -60,6 +88,14 @@ export abstract class DomainObject<V extends Record<string, unknown>> {
protected abstract normalizeValues(values?: Partial<V>): V protected abstract normalizeValues(values?: Partial<V>): 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
}
protected abstract parseEvent( protected abstract parseEvent(
event: TrustedEvent, event: TrustedEvent,
signer?: ISigner, signer?: ISigner,
@@ -67,14 +103,47 @@ export abstract class DomainObject<V extends Record<string, unknown>> {
abstract toTemplate(signer?: ISigner): Promise<EventTemplate> abstract toTemplate(signer?: ISigner): Promise<EventTemplate>
// 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}
}
setGroup(group: string) {
this.group = group
return this
}
setProtect(protect = true) {
this.protect = protect
return this
}
setExpires(expires: number) {
this.expires = expires
return this
}
async toRumor(signer: ISigner): Promise<HashedEvent> { async toRumor(signer: ISigner): Promise<HashedEvent> {
const [template, pubkey] = await Promise.all([this.toTemplate(signer), signer.getPubkey()]) const [template, pubkey] = await Promise.all([this.toTemplate(signer), signer.getPubkey()])
return prep(template, pubkey) return prep(this.addBehaviorTags(template), pubkey)
} }
async toEvent(signer: ISigner): Promise<SignedEvent> { async toEvent(signer: ISigner): Promise<SignedEvent> {
const template = await this.toTemplate(signer) const template = this.addBehaviorTags(await this.toTemplate(signer))
return signer.sign(stamp(template)) return signer.sign(stamp(template))
} }
+2 -5
View File
@@ -2,16 +2,13 @@ export * from "./base.js"
export * from "./BlockedRelayList.js" export * from "./BlockedRelayList.js"
export * from "./BlossomServerList.js" export * from "./BlossomServerList.js"
export * from "./BookmarkList.js" export * from "./BookmarkList.js"
export * from "./CalendarEvent.js"
export * from "./ChannelList.js"
export * from "./Classified.js" export * from "./Classified.js"
export * from "./Comment.js" export * from "./Comment.js"
export * from "./CommunityList.js"
export * from "./EmojiList.js" export * from "./EmojiList.js"
export * from "./Feed.js" export * from "./Feed.js"
export * from "./FeedList.js" export * from "./FeedList.js"
export * from "./FileServerList.js"
export * from "./FollowList.js" export * from "./FollowList.js"
export * from "./GroupList.js"
export * from "./Handler.js" export * from "./Handler.js"
export * from "./HandlerRecommendation.js" export * from "./HandlerRecommendation.js"
export * from "./List.js" export * from "./List.js"
@@ -40,8 +37,8 @@ export * from "./RoomMembers.js"
export * from "./RoomMembershipOp.js" export * from "./RoomMembershipOp.js"
export * from "./RoomMeta.js" export * from "./RoomMeta.js"
export * from "./SearchRelayList.js" export * from "./SearchRelayList.js"
export * from "./Settings.js"
export * from "./Thread.js" export * from "./Thread.js"
export * from "./TimeEvent.js"
export * from "./TopicList.js" export * from "./TopicList.js"
export * from "./ZapGoal.js" export * from "./ZapGoal.js"
export * from "./ZapReceipt.js" export * from "./ZapReceipt.js"