Refine domain classes: behavior tags, extra-tag passthrough, cleanups
tests / tests (push) Failing after 5m10s
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:
@@ -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)]))
|
||||
}
|
||||
|
||||
@@ -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)]))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"]),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)]))
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)]))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]))
|
||||
}
|
||||
|
||||
@@ -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]],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
Reference in New Issue
Block a user