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[]) {
this.keepTagsWithKey("relay")
this.clearTags()
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[]) {
this.keepTagsWithKey("server")
this.clearTags()
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 {
CLASSIFIED,
getAddress,
getIdentifier,
getTag,
getTagValue,
@@ -19,7 +18,6 @@ export type ClassifiedValues = {
status?: string
images: string[]
topics: string[]
h?: string
}
export const makeClassifiedValues = (
@@ -35,9 +33,9 @@ export const makeClassifiedValues = (
// 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, topics in "t" tags, and
// an optional "h" tag scopes the listing to a room. Tags-only metadata, so it
// extends DomainObject directly. Commented via "#A" (kind 1111 comments).
// 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<ClassifiedValues> {
readonly kind = CLASSIFIED
values = makeClassifiedValues()
@@ -60,7 +58,6 @@ export class Classified extends DomainObject<ClassifiedValues> {
status: getTagValue("status", event.tags),
images: getTagValues("image", 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
}
h() {
return this.values.h
}
room() {
return this.values.h
}
address() {
return getAddress(this.event!)
}
async toTemplate(): Promise<EventTemplate> {
const tags: string[][] = [["d", this.values.identifier]]
@@ -135,10 +120,6 @@ export class Classified extends DomainObject<ClassifiedValues> {
tags.push(["image", image])
}
if (this.values.h) {
tags.push(["h", this.values.h])
}
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 {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 = {
content: string
rootId?: string
rootAddress?: string
rootKind?: string
rootPubkey?: string
parentId?: string
parentAddress?: string
parentKind?: string
tags: string[][]
root: CommentRef
parent: CommentRef
}
export const makeCommentValues = (values: Partial<CommentValues> = {}): CommentValues => ({
content: "",
tags: [],
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) 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).
//
// Flotilla builds the reference tags at call sites via @welshman/app's
// `tagEventForComment(event, url)`, so this class round-trips the raw `tags`
// rather than reconstructing them.
// 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<CommentValues> {
readonly kind = COMMENT
values = makeCommentValues()
@@ -37,17 +55,25 @@ export class Comment extends DomainObject<CommentValues> {
return makeCommentValues(values)
}
protected reservedTagKeys() {
return REF_TAG_KEYS
}
protected parseEvent(event: TrustedEvent): Partial<CommentValues> {
return {
content: event.content || "",
rootId: getTagValue("E", event.tags),
rootAddress: getTagValue("A", event.tags),
rootKind: getTagValue("K", event.tags),
rootPubkey: getTagValue("P", event.tags),
parentId: getTagValue("e", event.tags),
parentAddress: getTagValue("a", event.tags),
parentKind: getTagValue("k", event.tags),
tags: event.tags,
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),
},
}
}
@@ -56,38 +82,97 @@ export class Comment extends DomainObject<CommentValues> {
}
rootId() {
return this.values.rootId
return this.values.root.id
}
rootAddress() {
return this.values.rootAddress
return this.values.root.address
}
rootKind() {
return this.values.rootKind
return this.values.root.kind
}
rootPubkey() {
return this.values.rootPubkey
return this.values.root.pubkey
}
parentId() {
return this.values.parentId
return this.values.parent.id
}
parentAddress() {
return this.values.parentAddress
return this.values.parent.address
}
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> {
return {
kind: this.kind,
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 {EncryptableList} from "./List.js"
@@ -12,18 +12,18 @@ export class EmojiList extends EncryptableList {
}
emojis() {
return this.tags().filter(t => t[0] === "emoji")
return this.tags().filter(spec(["emoji"]))
}
addEmoji(shortcode: string, url: string) {
return this.addPublicTags(["emoji", shortcode, url])
}
addSet(address: string) {
addEmojiSet(address: string) {
return this.addPublicTags(["a", address])
}
remove(value: string) {
removeEmoji(value: string) {
return this.removeTagsWithValue(value)
}
}
+11 -11
View File
@@ -1,5 +1,5 @@
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 {DomainObject} from "./base.js"
@@ -16,7 +16,9 @@ export const makeFeedValues = (values: Partial<FeedValues> = {}): FeedValues =>
identifier: "",
title: "",
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,
})
@@ -36,11 +38,17 @@ export class Feed extends DomainObject<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 {
identifier: getIdentifier(event) || "",
title: getTagValue("title", 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
}
display() {
return this.values.title || "[no name]"
}
getAddress() {
return getAddress(this.event!)
}
async toTemplate(): Promise<EventTemplate> {
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()))
}
add(address: string, relayHint?: string) {
addFeed(address: string, relayHint?: string) {
return this.addPublicTags(["a", address, relayHint || ""])
}
remove(address: string) {
removeFeed(address: string) {
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 {EncryptableList} from "./List.js"
// NIP-51 kind-10004 community membership list. Entries are `a` tags pointing at
// kind-34550 community definitions.
export class CommunityList extends EncryptableList {
// 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
addresses() {
return uniq(getAddressTagValues(this.tags()))
}
add(address: string, relayHint?: string) {
addGroup(address: string, relayHint?: string) {
return this.addPublicTags(["a", address, relayHint || ""])
}
remove(address: string) {
removeGroup(address: string) {
return this.removeTagsWithValue(address)
}
}
+9 -17
View File
@@ -1,5 +1,5 @@
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 {DomainObject} from "./base.js"
@@ -29,6 +29,10 @@ export class Handler extends DomainObject<HandlerValues> {
return makeHandlerValues(values)
}
protected reservedTagKeys() {
return ["k"]
}
protected parseEvent(event: TrustedEvent): Partial<HandlerValues> {
const meta = parseJson(event.content) || {}
@@ -71,20 +75,8 @@ export class Handler extends DomainObject<HandlerValues> {
return this.values.kinds
}
supportsKind(kind: number) {
return this.values.kinds.includes(kind)
}
identifier() {
return getIdentifier(this.event!)
}
display(fallback = "") {
return this.name() || fallback
}
getAddress() {
return getAddress(this.event!)
return getTagValue("d", this.extraTags)
}
async toTemplate(): Promise<EventTemplate> {
@@ -92,14 +84,14 @@ export class Handler extends DomainObject<HandlerValues> {
const content = JSON.stringify({name, about, image, website, lud16, nip05})
// Rebuild `k` tags from values.kinds, preserving existing `d`/`a` tags.
const preservedTags = (this.event?.tags || []).filter(t => t[0] === "d" || t[0] === "a")
// 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: [...preservedTags, ...kindTags],
tags: kindTags,
}
}
}
+31 -2
View File
@@ -79,10 +79,10 @@ export abstract class EncryptableList extends DomainObject<ListValues> {
}
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) {
this.values.privateTags = this.values.privateTags.filter(t => !pred(t))
this.values.privateTags = this.values.privateTags.filter(t => pred(t))
}
return this
@@ -114,6 +114,35 @@ export abstract class EncryptableList extends DomainObject<ListValues> {
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> {
const tags = this.values.publicTags
+1 -1
View File
@@ -23,7 +23,7 @@ export class MessagingRelayList extends EncryptableList {
}
setRelays(urls: string[]) {
this.keepTagsWithKey("relay")
this.clearTags()
return this.addPublicTags(...urls.map(url => ["relay", normalizeRelayUrl(url)]))
}
-10
View File
@@ -21,7 +21,6 @@ export type PollValues = {
pollType: PollType
endsAt?: number
relays: string[]
h?: string
}
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",
endsAt: Number.isNaN(endsAt) ? undefined : endsAt,
relays: getTagValues("relay", event.tags),
h: getTagValue("h", event.tags),
}
}
@@ -83,10 +81,6 @@ export class Poll extends DomainObject<PollValues> {
return this.values.relays
}
h() {
return this.values.h
}
// 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.
@@ -134,10 +128,6 @@ export class Poll extends DomainObject<PollValues> {
tags.push(["relay", relay])
}
if (this.values.h) {
tags.push(["h", this.values.h])
}
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
}
selections(pollType?: "singlechoice" | "multiplechoice") {
if (pollType === "singlechoice") {
return this.values.selections.slice(0, 1)
}
selections() {
return uniq(this.values.selections)
}
+23 -50
View File
@@ -1,76 +1,41 @@
import {uniq} from "@welshman/lib"
import {RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, getPubkeyTagValues} from "@welshman/util"
import type {EventTemplate, TrustedEvent} from "@welshman/util"
import type {ISigner} from "@welshman/signer"
import {DomainObject} from "./base.js"
export type RelayMembershipOpValues = {
kind: number
export type RelayMembershipValues = {
pubkeys: string[]
}
export const makeRelayMembershipOpValues = (
values: Partial<RelayMembershipOpValues> = {},
): RelayMembershipOpValues => ({
kind: RELAY_ADD_MEMBER,
export const makeRelayMembershipValues = (
values: Partial<RelayMembershipValues> = {},
): RelayMembershipValues => ({
pubkeys: [],
...values,
})
export const makeRelayAddMember = (pubkeys: string[]) =>
RelayMembershipOp.init({kind: RELAY_ADD_MEMBER, pubkeys})
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.
// 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.
//
// Flotilla's deriveUserSpaceMembershipStatus replays this history (RELAY_ADD_MEMBER
// => isMember true, RELAY_REMOVE_MEMBER => isMember false) when no RELAY_MEMBERS
// Flotilla's deriveUserSpaceMembershipStatus replays this history (RelayAddMember
// => isMember true, RelayRemoveMember => isMember false) when no RELAY_MEMBERS
// snapshot is available.
//
// Because the base DomainObject treats `kind` as a fixed value and asserts
// 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()
export abstract class RelayMembershipOp extends DomainObject<RelayMembershipValues> {
values = makeRelayMembershipValues()
protected normalizeValues(values: Partial<RelayMembershipOpValues> = {}) {
const normalized = makeRelayMembershipOpValues(values)
this.kind = normalized.kind
return normalized
protected normalizeValues(values: Partial<RelayMembershipValues> = {}) {
return makeRelayMembershipValues(values)
}
protected parseEvent(event: TrustedEvent): Partial<RelayMembershipOpValues> {
return {
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
protected parseEvent(event: TrustedEvent): Partial<RelayMembershipValues> {
return {pubkeys: uniq(getPubkeyTagValues(event.tags))}
}
pubkeys() {
return this.values.pubkeys
}
isAdd() {
return this.kind === RELAY_ADD_MEMBER
}
async toTemplate(): Promise<EventTemplate> {
return {
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[]) {
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)]))
}
+29 -60
View File
@@ -1,85 +1,54 @@
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 {ISigner} from "@welshman/signer"
import {DomainObject} from "./base.js"
export type RoomMembershipOpValues = {
kind: number
h: string
export type RoomMembershipValues = {
pubkeys: string[]
}
export const makeRoomMembershipOpValues = (
values: Partial<RoomMembershipOpValues> = {},
): RoomMembershipOpValues => ({
kind: ROOM_ADD_MEMBER,
h: "",
export const makeRoomMembershipValues = (
values: Partial<RoomMembershipValues> = {},
): RoomMembershipValues => ({
pubkeys: [],
...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
// members. These are regular (non-addressable) events carrying the group id in
// the "h" tag and the affected pubkeys in "p" tags. The two kinds share an
// identical shape, so they're merged into one kind-discriminated class.
// 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.
//
// Because the base DomainObject treats `kind` as a fixed value and asserts
// 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 RoomMembershipOp extends DomainObject<RoomMembershipOpValues> {
kind = ROOM_ADD_MEMBER
values = makeRoomMembershipOpValues()
// Flotilla's membership replay treats RoomAddMember => member, RoomRemoveMember
// => not a member.
export abstract class RoomMembershipOp extends DomainObject<RoomMembershipValues> {
values = makeRoomMembershipValues()
protected normalizeValues(values: Partial<RoomMembershipOpValues> = {}) {
const normalized = makeRoomMembershipOpValues(values)
this.kind = normalized.kind
return normalized
protected normalizeValues(values: Partial<RoomMembershipValues> = {}) {
return makeRoomMembershipValues(values)
}
protected parseEvent(event: TrustedEvent): Partial<RoomMembershipOpValues> {
return {
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
protected parseEvent(event: TrustedEvent): Partial<RoomMembershipValues> {
return {pubkeys: uniq(getPubkeyTagValues(event.tags))}
}
pubkeys() {
return this.values.pubkeys
}
isAdd() {
return this.kind === ROOM_ADD_MEMBER
}
async toTemplate(): Promise<EventTemplate> {
const tags: string[][] = [
["h", this.values.h],
...this.values.pubkeys.map(pk => ["p", pk]),
]
return {kind: this.kind, tags, content: ""}
return {
kind: this.kind,
tags: this.values.pubkeys.map(pk => ["p", pk]),
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 type {EventTemplate, TrustedEvent} from "@welshman/util"
import {DomainObject} from "./base.js"
@@ -15,32 +16,8 @@ export type RoomMetaValues = {
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 => ({
h: values.h || generateH(),
h: values.h || randomId(),
isClosed: false,
isHidden: false,
isPrivate: false,
+1 -1
View File
@@ -21,7 +21,7 @@ export class SearchRelayList extends EncryptableList {
}
setRelays(urls: string[]) {
this.keepTagsWithKey("relay")
this.clearTags()
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 = {
title?: string
content: string
h?: string
}
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
// (not JSON), the title is carried in a "title" tag, and an optional "h" tag
// scopes the thread to a room. Non-addressable (referenced by event id);
// (not JSON) and the title is carried in a "title" tag; room scoping is handled
// by the base `group` behavior tag. Non-addressable (referenced by event id);
// replies are COMMENT (kind 1111) via "#E". Flotilla also appends editor/inline
// tags and an optional PROTECTED ("-") tag at call sites, so on serialize we
// preserve any non-title/non-h tags from the parsed event to keep round-tripping
// lossless for those extra tags.
// 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<ThreadValues> {
readonly kind = THREAD
values = makeThreadValues()
@@ -28,11 +26,14 @@ export class Thread extends DomainObject<ThreadValues> {
return makeThreadValues(values)
}
protected reservedTagKeys() {
return ["title"]
}
protected parseEvent(event: TrustedEvent): Partial<ThreadValues> {
return {
title: getTagValue("title", event.tags),
content: event.content || "",
h: getTagValue("h", event.tags),
}
}
@@ -44,27 +45,13 @@ export class Thread extends DomainObject<ThreadValues> {
return this.values.content
}
h() {
return this.values.h
}
room() {
return this.values.h
}
async toTemplate(): Promise<EventTemplate> {
const tags: string[][] = (this.event?.tags || []).filter(
t => t[0] !== "title" && t[0] !== "h",
)
const tags: string[][] = []
if (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}
}
}
@@ -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 {DomainObject} from "./base.js"
export type CalendarEventValues = {
export type TimeEventValues = {
identifier: string
title?: string
location?: string
content: string
start?: number
end?: number
days: string[]
h?: string
}
export const makeCalendarEventValues = (
values: Partial<CalendarEventValues> = {},
): CalendarEventValues => ({
export const makeTimeEventValues = (
values: Partial<TimeEventValues> = {},
): TimeEventValues => ({
identifier: "",
content: "",
days: [],
...values,
})
// 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`. Flotilla additionally writes per-day
// ["D", "YYYY-MM-DD"] tags spanning start..end for day-bucket querying, and an
// optional "h" tag scoping the event to a room (commented via "#A"). This is the
// only calendar object flotilla uses (CALENDAR 31924 / EVENT_DATE 31922 /
// EVENT_RSVP 31925 are not used). Tags + plain content, so it extends
// DomainObject directly.
export class CalendarEvent extends DomainObject<CalendarEventValues> {
// 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.
//
// 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
values = makeCalendarEventValues()
values = makeTimeEventValues()
protected normalizeValues(values: Partial<CalendarEventValues> = {}) {
return makeCalendarEventValues(values)
protected normalizeValues(values: Partial<TimeEventValues> = {}) {
return makeTimeEventValues(values)
}
protected parseEvent(event: TrustedEvent): Partial<CalendarEventValues> {
protected parseEvent(event: TrustedEvent): Partial<TimeEventValues> {
const start = parseInt(getTagValue("start", event.tags)!)
const end = parseInt(getTagValue("end", event.tags)!)
@@ -50,8 +52,6 @@ export class CalendarEvent extends DomainObject<CalendarEventValues> {
content: event.content || "",
start: isNaN(start) ? undefined : start,
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
}
days() {
return this.values.days
}
h() {
return this.values.h
}
room() {
return this.values.h
}
address() {
return getAddress(this.event!)
}
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]]
@@ -105,12 +89,13 @@ export class CalendarEvent extends DomainObject<CalendarEventValues> {
if (start != null) tags.push(["start", String(start)])
if (end != null) tags.push(["end", String(end)])
for (const day of days) {
tags.push(["D", day])
// 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)) {
tags.push(["D", String(Math.floor(t / DAY))])
}
}
if (h) tags.push(["h", h])
return {kind: this.kind, content, tags}
}
}
+2 -16
View File
@@ -7,7 +7,6 @@ export type ZapGoalValues = {
summary?: string
amount: number
relays: string[]
h?: string
}
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
// 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
// an int defaulting to 0), the relays to tally receipts from in repeated
// "relays" tags, and an optional "h" tag scopes the goal to a room.
// an int defaulting to 0), and the relays to tally receipts from in repeated
// "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.
@@ -39,7 +38,6 @@ export class ZapGoal extends DomainObject<ZapGoalValues> {
summary: getTagValue("summary", event.tags),
amount: parseInt(getTagValue("amount", event.tags) || "0") || 0,
relays: getTagValues("relays", event.tags),
h: getTagValue("h", event.tags),
}
}
@@ -59,14 +57,6 @@ export class ZapGoal extends DomainObject<ZapGoalValues> {
return this.values.relays
}
h() {
return this.values.h
}
room() {
return this.values.h
}
async toTemplate(): Promise<EventTemplate> {
const tags: string[][] = []
@@ -80,10 +70,6 @@ export class ZapGoal extends DomainObject<ZapGoalValues> {
tags.push(["relays", relay])
}
if (this.values.h) {
tags.push(["h", this.values.h])
}
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 {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.
*
@@ -26,6 +29,19 @@ export abstract class DomainObject<V extends Record<string, unknown>> {
abstract values: V
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>>>(
this: new () => T,
values?: Partial<T["values"]>,
@@ -53,6 +69,18 @@ export abstract class DomainObject<V extends Record<string, unknown>> {
}
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))
return this
@@ -60,6 +88,14 @@ export abstract class DomainObject<V extends Record<string, unknown>> {
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(
event: TrustedEvent,
signer?: ISigner,
@@ -67,14 +103,47 @@ export abstract class DomainObject<V extends Record<string, unknown>> {
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> {
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> {
const template = await this.toTemplate(signer)
const template = this.addBehaviorTags(await this.toTemplate(signer))
return signer.sign(stamp(template))
}
+2 -5
View File
@@ -2,16 +2,13 @@ export * from "./base.js"
export * from "./BlockedRelayList.js"
export * from "./BlossomServerList.js"
export * from "./BookmarkList.js"
export * from "./CalendarEvent.js"
export * from "./ChannelList.js"
export * from "./Classified.js"
export * from "./Comment.js"
export * from "./CommunityList.js"
export * from "./EmojiList.js"
export * from "./Feed.js"
export * from "./FeedList.js"
export * from "./FileServerList.js"
export * from "./FollowList.js"
export * from "./GroupList.js"
export * from "./Handler.js"
export * from "./HandlerRecommendation.js"
export * from "./List.js"
@@ -40,8 +37,8 @@ export * from "./RoomMembers.js"
export * from "./RoomMembershipOp.js"
export * from "./RoomMeta.js"
export * from "./SearchRelayList.js"
export * from "./Settings.js"
export * from "./Thread.js"
export * from "./TimeEvent.js"
export * from "./TopicList.js"
export * from "./ZapGoal.js"
export * from "./ZapReceipt.js"