Replace the single DomainObject/EncryptableList classes with a read/write split that removes the optional-event ambiguity: - base.ts: EventReader<P> (static kind; fromEvent(event, signer?) eagerly computes a generic `plain`, validates leniently, throws-or-passes; lazy method accessors; group/protect/expires + extraTags carry-over; builder()) and EventBuilder<P> (chainable setters, buildTags/buildContent, validate-on-emit). - List.ts: ListReader/ListBuilder for NIP-51 lists (decrypt-on-read into `plain`, re-encrypt-on-emit, tag mutators). - Every kind converted to a <Noun> reader + <Noun>Builder pair; membership ops split into per-kind reader/builder pairs over a shared abstract base. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V67tPYdvh1qCkjEBhJGZUR
This commit is contained in:
@@ -1,23 +1,35 @@
|
|||||||
import {uniqBy} from "@welshman/lib"
|
import {uniqBy} from "@welshman/lib"
|
||||||
import {BLOCKED_RELAYS, getTagValues, normalizeRelayUrl} from "@welshman/util"
|
import {BLOCKED_RELAYS, getTagValues, normalizeRelayUrl} from "@welshman/util"
|
||||||
import {EncryptableList} from "./List.js"
|
import {ListReader, ListBuilder} from "./List.js"
|
||||||
|
|
||||||
// NIP-51 kind-10006 blocked relays. Entries are marker-less ['relay', url] tags
|
// NIP-51 kind-10006 blocked relays. Entries are marker-less ['relay', url] tags
|
||||||
// (NOT NIP-65 'r' tags with read/write markers). `urls()` gates AUTH (never auth
|
// (NOT NIP-65 'r' tags with read/write markers). `urls()` gates AUTH (never auth
|
||||||
// to a blocked relay) and relay selection, so it stays a flat, normalized set.
|
// to a blocked relay) and relay selection, so it stays a flat, normalized set.
|
||||||
export class BlockedRelayList extends EncryptableList {
|
export class BlockedRelayList extends ListReader {
|
||||||
readonly kind = BLOCKED_RELAYS
|
static kind = BLOCKED_RELAYS
|
||||||
|
|
||||||
urls() {
|
urls() {
|
||||||
return uniqBy(normalizeRelayUrl, getTagValues("relay", this.tags()))
|
return uniqBy(normalizeRelayUrl, getTagValues("relay", this.tags()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
includes(url: string) {
|
||||||
|
return this.urls().includes(normalizeRelayUrl(url))
|
||||||
|
}
|
||||||
|
|
||||||
|
builder() {
|
||||||
|
return this.seedList(new BlockedRelayListBuilder())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BlockedRelayListBuilder extends ListBuilder {
|
||||||
|
static kind = BLOCKED_RELAYS
|
||||||
|
|
||||||
addRelay(url: string) {
|
addRelay(url: string) {
|
||||||
return this.addPublicTags(["relay", normalizeRelayUrl(url)])
|
return this.addPublicTags(["relay", normalizeRelayUrl(url)])
|
||||||
}
|
}
|
||||||
|
|
||||||
removeRelay(url: string) {
|
removeRelay(url: string) {
|
||||||
return this.removeTagsWithValue(url)
|
return this.removeTagsWithValue(normalizeRelayUrl(url))
|
||||||
}
|
}
|
||||||
|
|
||||||
setRelays(urls: string[]) {
|
setRelays(urls: string[]) {
|
||||||
|
|||||||
@@ -1,27 +1,35 @@
|
|||||||
import {uniq} from "@welshman/lib"
|
import {uniq} from "@welshman/lib"
|
||||||
import {BLOSSOM_SERVERS, getTagValues, normalizeRelayUrl} from "@welshman/util"
|
import {BLOSSOM_SERVERS, getTagValues, normalizeRelayUrl} from "@welshman/util"
|
||||||
import {EncryptableList} from "./List.js"
|
import {ListReader, ListBuilder} from "./List.js"
|
||||||
|
|
||||||
// Blossom BUD-03 user server list (kind 10063). Server endpoints are stored as
|
// Blossom BUD-03 user server list (kind 10063). Server endpoints are stored as
|
||||||
// `["server", url]` tags (NOT the `r`/`relay` tags used by relay lists), so the
|
// `["server", url]` tags (NOT the `r`/`relay` tags used by relay lists), so the
|
||||||
// generic relay-tag helpers would miss them. Effectively public-only.
|
// generic relay-tag helpers would miss them. Effectively public-only.
|
||||||
export class BlossomServerList extends EncryptableList {
|
export class BlossomServerList extends ListReader {
|
||||||
readonly kind = BLOSSOM_SERVERS
|
static kind = BLOSSOM_SERVERS
|
||||||
|
|
||||||
servers() {
|
servers() {
|
||||||
return uniq(getTagValues("server", this.tags()).map(normalizeRelayUrl))
|
return uniq(getTagValues("server", this.tags()).map(normalizeRelayUrl))
|
||||||
}
|
}
|
||||||
|
|
||||||
includes(url: string) {
|
includes(url: string) {
|
||||||
return this.servers().includes(url)
|
return this.servers().includes(normalizeRelayUrl(url))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
builder() {
|
||||||
|
return this.seedList(new BlossomServerListBuilder())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BlossomServerListBuilder extends ListBuilder {
|
||||||
|
static kind = BLOSSOM_SERVERS
|
||||||
|
|
||||||
addServer(url: string) {
|
addServer(url: string) {
|
||||||
return this.addPublicTags(["server", normalizeRelayUrl(url)])
|
return this.addPublicTags(["server", normalizeRelayUrl(url)])
|
||||||
}
|
}
|
||||||
|
|
||||||
removeServer(url: string) {
|
removeServer(url: string) {
|
||||||
return this.removeTagsWithValue(url)
|
return this.removeTagsWithValue(normalizeRelayUrl(url))
|
||||||
}
|
}
|
||||||
|
|
||||||
setServers(urls: string[]) {
|
setServers(urls: string[]) {
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import {
|
|||||||
getTopicTagValues,
|
getTopicTagValues,
|
||||||
getTagValues,
|
getTagValues,
|
||||||
} from "@welshman/util"
|
} from "@welshman/util"
|
||||||
import {EncryptableList} from "./List.js"
|
import {ListReader, ListBuilder} from "./List.js"
|
||||||
|
|
||||||
// NIP-51 kind-10003 bookmark list. Mixed entries (notes via 'e', articles via
|
// NIP-51 kind-10003 bookmark list. Mixed entries (notes via 'e', articles via
|
||||||
// 'a', hashtags via 't', urls via 'r') can be bookmarked publicly (tags) or
|
// 'a', hashtags via 't', urls via 'r') can be bookmarked publicly (tags) or
|
||||||
// privately (encrypted content); accessors treat both as one merged set.
|
// privately (encrypted content); accessors treat both as one merged set.
|
||||||
export class BookmarkList extends EncryptableList {
|
export class BookmarkList extends ListReader {
|
||||||
readonly kind = BOOKMARKS
|
static kind = BOOKMARKS
|
||||||
|
|
||||||
ids() {
|
ids() {
|
||||||
return uniq(getEventTagValues(this.tags()))
|
return uniq(getEventTagValues(this.tags()))
|
||||||
@@ -30,6 +30,14 @@ export class BookmarkList extends EncryptableList {
|
|||||||
return uniq(getTagValues("r", this.tags()))
|
return uniq(getTagValues("r", this.tags()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
builder() {
|
||||||
|
return this.seedList(new BookmarkListBuilder())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BookmarkListBuilder extends ListBuilder {
|
||||||
|
static kind = BOOKMARKS
|
||||||
|
|
||||||
bookmarkPublicly(tag: string[]) {
|
bookmarkPublicly(tag: string[]) {
|
||||||
return this.addPublicTags(tag)
|
return this.addPublicTags(tag)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,125 +1,158 @@
|
|||||||
import {
|
import {randomId} from "@welshman/lib"
|
||||||
CLASSIFIED,
|
import {CLASSIFIED, getTag, getTagValue, getTagValues, getTopicTagValues} from "@welshman/util"
|
||||||
getIdentifier,
|
import type {ISigner} from "@welshman/signer"
|
||||||
getTag,
|
import {EventReader, EventBuilder} from "./base.js"
|
||||||
getTagValue,
|
|
||||||
getTagValues,
|
|
||||||
getTopicTagValues,
|
|
||||||
} from "@welshman/util"
|
|
||||||
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
|
||||||
import {DomainObject} from "./base.js"
|
|
||||||
|
|
||||||
export type ClassifiedValues = {
|
export type ClassifiedPrice = {
|
||||||
identifier: string
|
amount: number
|
||||||
title?: string
|
currency: string
|
||||||
summary?: string
|
|
||||||
content: string
|
|
||||||
price?: {amount: number; currency: string}
|
|
||||||
status?: string
|
|
||||||
images: string[]
|
|
||||||
topics: string[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const makeClassifiedValues = (
|
// NIP-99 kind-30402 addressable classified listing. Addressable via the "d" tag;
|
||||||
values: Partial<ClassifiedValues> = {},
|
// the listing description lives in `content` as plain text (not JSON). The price
|
||||||
): ClassifiedValues => ({
|
// is carried in a ["price", amount, currency] tag with the currency defaulting to
|
||||||
identifier: "",
|
// "SAT", images in repeated "image" tags, and topics in "t" tags; room scoping is
|
||||||
content: "",
|
// handled by the base `group` behavior tag. Plain-text content, so it extends
|
||||||
images: [],
|
// EventReader/EventBuilder directly.
|
||||||
topics: [],
|
export class Classified extends EventReader {
|
||||||
...values,
|
static kind = CLASSIFIED
|
||||||
})
|
|
||||||
|
|
||||||
// NIP-99 kind-30402 addressable classified listing. Addressable via the "d"
|
protected validate() {
|
||||||
// tag; the listing description lives in `content` as plain text (not JSON). The
|
if (!this.identifier()) {
|
||||||
// price is carried in a ["price", amount, currency] tag with the currency
|
throw new Error("Classified requires a d tag")
|
||||||
// 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()
|
|
||||||
|
|
||||||
protected normalizeValues(values: Partial<ClassifiedValues> = {}) {
|
|
||||||
return makeClassifiedValues(values)
|
|
||||||
}
|
|
||||||
|
|
||||||
protected parseEvent(event: TrustedEvent): Partial<ClassifiedValues> {
|
|
||||||
const priceTag = getTag("price", event.tags)
|
|
||||||
|
|
||||||
return {
|
|
||||||
identifier: getIdentifier(event) || "",
|
|
||||||
title: getTagValue("title", event.tags),
|
|
||||||
summary: getTagValue("summary", event.tags),
|
|
||||||
content: event.content || "",
|
|
||||||
price: priceTag
|
|
||||||
? {amount: parseFloat(priceTag[1]) || 0, currency: priceTag[2] || "SAT"}
|
|
||||||
: undefined,
|
|
||||||
status: getTagValue("status", event.tags),
|
|
||||||
images: getTagValues("image", event.tags),
|
|
||||||
topics: getTopicTagValues(event.tags),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
identifier() {
|
protected reservedTagKeys() {
|
||||||
return this.values.identifier
|
return ["d", "title", "summary", "price", "status", "image", "t"]
|
||||||
}
|
}
|
||||||
|
|
||||||
title() {
|
title() {
|
||||||
return this.values.title
|
return getTagValue("title", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
summary() {
|
summary() {
|
||||||
return this.values.summary
|
return getTagValue("summary", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
content() {
|
content() {
|
||||||
return this.values.content
|
return this.event.content
|
||||||
}
|
}
|
||||||
|
|
||||||
price() {
|
price(): ClassifiedPrice | undefined {
|
||||||
return this.values.price
|
const tag = getTag("price", this.event.tags)
|
||||||
|
|
||||||
|
return tag ? {amount: parseFloat(tag[1]) || 0, currency: tag[2] || "SAT"} : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
status() {
|
status() {
|
||||||
return this.values.status
|
return getTagValue("status", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
images() {
|
images() {
|
||||||
return this.values.images
|
return getTagValues("image", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
topics() {
|
topics() {
|
||||||
return this.values.topics
|
return getTopicTagValues(this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
async toTemplate(): Promise<EventTemplate> {
|
builder() {
|
||||||
const tags: string[][] = [["d", this.values.identifier]]
|
const builder = new ClassifiedBuilder()
|
||||||
|
|
||||||
if (this.values.title) {
|
builder.identifier = this.identifier() || ""
|
||||||
tags.push(["title", this.values.title])
|
builder.title = this.title()
|
||||||
|
builder.summary = this.summary()
|
||||||
|
builder.content = this.content()
|
||||||
|
builder.price = this.price()
|
||||||
|
builder.status = this.status()
|
||||||
|
builder.images = this.images()
|
||||||
|
builder.topics = this.topics()
|
||||||
|
|
||||||
|
return this.seedBuilder(builder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClassifiedBuilder extends EventBuilder {
|
||||||
|
static kind = CLASSIFIED
|
||||||
|
|
||||||
|
identifier = randomId()
|
||||||
|
title?: string
|
||||||
|
summary?: string
|
||||||
|
content = ""
|
||||||
|
price?: ClassifiedPrice
|
||||||
|
status?: string
|
||||||
|
images: string[] = []
|
||||||
|
topics: string[] = []
|
||||||
|
|
||||||
|
setTitle(title: string) {
|
||||||
|
this.title = title
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setSummary(summary: string) {
|
||||||
|
this.summary = summary
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent(content: string) {
|
||||||
|
this.content = content
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setPrice(amount: number, currency = "SAT") {
|
||||||
|
this.price = {amount, currency}
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(status: string) {
|
||||||
|
this.status = status
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setImages(images: string[]) {
|
||||||
|
this.images = images
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setTopics(topics: string[]) {
|
||||||
|
this.topics = topics
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
protected validate() {
|
||||||
|
if (!this.identifier) {
|
||||||
|
throw new Error("Classified requires a d identifier")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (this.values.summary) {
|
protected buildContent(_signer?: ISigner) {
|
||||||
tags.push(["summary", this.values.summary])
|
return this.content
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.values.price) {
|
protected buildTags() {
|
||||||
tags.push(["price", String(this.values.price.amount), this.values.price.currency])
|
const tags: string[][] = [["d", this.identifier]]
|
||||||
}
|
|
||||||
|
|
||||||
if (this.values.status) {
|
if (this.title) tags.push(["title", this.title])
|
||||||
tags.push(["status", this.values.status])
|
if (this.summary) tags.push(["summary", this.summary])
|
||||||
}
|
if (this.price) tags.push(["price", String(this.price.amount), this.price.currency])
|
||||||
|
if (this.status) tags.push(["status", this.status])
|
||||||
|
|
||||||
for (const topic of this.values.topics) {
|
for (const topic of this.topics) {
|
||||||
tags.push(["t", topic])
|
tags.push(["t", topic])
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const image of this.values.images) {
|
for (const image of this.images) {
|
||||||
tags.push(["image", image])
|
tags.push(["image", image])
|
||||||
}
|
}
|
||||||
|
|
||||||
return {kind: this.kind, content: this.values.content, tags}
|
return tags
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {COMMENT, Address, getAddress, getTagValue, isReplaceableKind} from "@welshman/util"
|
import {COMMENT, Address, getAddress, getTagValue, isReplaceableKind} from "@welshman/util"
|
||||||
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {DomainObject} from "./base.js"
|
import type {ISigner} from "@welshman/signer"
|
||||||
|
import {EventReader, EventBuilder} from "./base.js"
|
||||||
|
|
||||||
// A NIP-22 reference to another event: its id, address (for addressable roots),
|
// 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.
|
// kind, and pubkey. All optional since a comment may reference any subset.
|
||||||
@@ -12,7 +13,7 @@ export type CommentRef = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The tag keys NIP-22 uses for the root (uppercase) and parent (lowercase)
|
// The tag keys NIP-22 uses for the root (uppercase) and parent (lowercase)
|
||||||
// references; stripped on parse and rebuilt from the structs on serialize.
|
// references; read into the structs and rebuilt from them on emit.
|
||||||
const REF_TAG_KEYS = ["E", "A", "K", "P", "e", "a", "k", "p"]
|
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
|
// Build a reference from a full event, deriving the address only when the event
|
||||||
@@ -24,99 +25,120 @@ const refFromEvent = (event: TrustedEvent): CommentRef => ({
|
|||||||
address: isReplaceableKind(event.kind) ? getAddress(event) : undefined,
|
address: isReplaceableKind(event.kind) ? getAddress(event) : undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
export type CommentValues = {
|
// Build the NIP-22 reference tags for one struct: pass uppercase keys for the
|
||||||
content: string
|
// root, lowercase for the parent.
|
||||||
root: CommentRef
|
const refTags = (ref: CommentRef, [idKey, addressKey, kindKey, pubkeyKey]: string[]) => {
|
||||||
parent: CommentRef
|
const tags: string[][] = []
|
||||||
|
|
||||||
|
if (ref.id) tags.push([idKey, ref.id])
|
||||||
|
if (ref.address) tags.push([addressKey, ref.address])
|
||||||
|
if (ref.kind) tags.push([kindKey, ref.kind])
|
||||||
|
if (ref.pubkey) tags.push([pubkeyKey, ref.pubkey])
|
||||||
|
|
||||||
|
return tags
|
||||||
}
|
}
|
||||||
|
|
||||||
export const makeCommentValues = (values: Partial<CommentValues> = {}): CommentValues => ({
|
// Read side of NIP-22 kind-1111 generic comment, flotilla's universal reply
|
||||||
content: "",
|
// primitive: threads, goals, and polls reference their root event via uppercase
|
||||||
root: {},
|
// E/A/K/P tags, while classifieds and calendar events reference addressable
|
||||||
parent: {},
|
// roots via #A. Uppercase tags (E/A/K/P) name the root of the thread; lowercase
|
||||||
...values,
|
// tags (e/a/k/p) name the immediate parent. The comment body is plain text in
|
||||||
})
|
// the event content (not JSON).
|
||||||
|
|
||||||
// NIP-22 kind-1111 generic comment, flotilla's universal reply primitive: threads,
|
|
||||||
// goals, and polls reference their root event via uppercase E/A/K/P tags, while
|
|
||||||
// classifieds and calendar events reference addressable roots via #A. Uppercase
|
|
||||||
// tags (E/A/K/P) name the root of the thread; lowercase tags (e/a/k/p) name the
|
|
||||||
// immediate parent. The comment body lives in `content` as plain text (not JSON).
|
|
||||||
//
|
//
|
||||||
// The reference tags are parsed into the `root`/`parent` structs and rebuilt
|
// The reference tags are read lazily into the root/parent accessors; REF_TAG_KEYS
|
||||||
// from them in toTemplate; any other tags round-trip via the base `extraTags`
|
// is declared reserved so any other tags round-trip via the base extraTags.
|
||||||
// (REF_TAG_KEYS is declared as reserved so they aren't double-counted). Use
|
export class Comment extends EventReader {
|
||||||
// setRoot/setParent (or the *FromEvent variants) to populate them programmatically.
|
static kind = COMMENT
|
||||||
export class Comment extends DomainObject<CommentValues> {
|
|
||||||
readonly kind = COMMENT
|
|
||||||
values = makeCommentValues()
|
|
||||||
|
|
||||||
protected normalizeValues(values: Partial<CommentValues> = {}) {
|
|
||||||
return makeCommentValues(values)
|
|
||||||
}
|
|
||||||
|
|
||||||
protected reservedTagKeys() {
|
protected reservedTagKeys() {
|
||||||
return REF_TAG_KEYS
|
return REF_TAG_KEYS
|
||||||
}
|
}
|
||||||
|
|
||||||
protected parseEvent(event: TrustedEvent): Partial<CommentValues> {
|
|
||||||
return {
|
|
||||||
content: event.content || "",
|
|
||||||
root: {
|
|
||||||
id: getTagValue("E", event.tags),
|
|
||||||
address: getTagValue("A", event.tags),
|
|
||||||
kind: getTagValue("K", event.tags),
|
|
||||||
pubkey: getTagValue("P", event.tags),
|
|
||||||
},
|
|
||||||
parent: {
|
|
||||||
id: getTagValue("e", event.tags),
|
|
||||||
address: getTagValue("a", event.tags),
|
|
||||||
kind: getTagValue("k", event.tags),
|
|
||||||
pubkey: getTagValue("p", event.tags),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
content() {
|
content() {
|
||||||
return this.values.content
|
return this.event.content || ""
|
||||||
}
|
}
|
||||||
|
|
||||||
rootId() {
|
rootId() {
|
||||||
return this.values.root.id
|
return getTagValue("E", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
rootAddress() {
|
rootAddress() {
|
||||||
return this.values.root.address
|
return getTagValue("A", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
rootKind() {
|
rootKind() {
|
||||||
return this.values.root.kind
|
return getTagValue("K", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
rootPubkey() {
|
rootPubkey() {
|
||||||
return this.values.root.pubkey
|
return getTagValue("P", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
parentId() {
|
parentId() {
|
||||||
return this.values.parent.id
|
return getTagValue("e", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
parentAddress() {
|
parentAddress() {
|
||||||
return this.values.parent.address
|
return getTagValue("a", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
parentKind() {
|
parentKind() {
|
||||||
return this.values.parent.kind
|
return getTagValue("k", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
parentPubkey() {
|
parentPubkey() {
|
||||||
return this.values.parent.pubkey
|
return getTagValue("p", this.event.tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
root(): CommentRef {
|
||||||
|
return {
|
||||||
|
id: this.rootId(),
|
||||||
|
address: this.rootAddress(),
|
||||||
|
kind: this.rootKind(),
|
||||||
|
pubkey: this.rootPubkey(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parent(): CommentRef {
|
||||||
|
return {
|
||||||
|
id: this.parentId(),
|
||||||
|
address: this.parentAddress(),
|
||||||
|
kind: this.parentKind(),
|
||||||
|
pubkey: this.parentPubkey(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
builder() {
|
||||||
|
const builder = new CommentBuilder()
|
||||||
|
|
||||||
|
builder.content = this.content()
|
||||||
|
builder.root = this.root()
|
||||||
|
builder.parent = this.parent()
|
||||||
|
|
||||||
|
return this.seedBuilder(builder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write side of NIP-22 kind-1111 generic comment. Set the body via setContent and
|
||||||
|
// the root/parent references via setRoot/setParent (or the *FromEvent variants);
|
||||||
|
// buildTags rebuilds the uppercase/lowercase reference tags from those structs.
|
||||||
|
export class CommentBuilder extends EventBuilder {
|
||||||
|
static kind = COMMENT
|
||||||
|
|
||||||
|
content = ""
|
||||||
|
root: CommentRef = {}
|
||||||
|
parent: CommentRef = {}
|
||||||
|
|
||||||
|
setContent(content: string) {
|
||||||
|
this.content = content
|
||||||
|
|
||||||
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the thread root reference, deriving the address from kind/pubkey/identifier
|
// Set the thread root reference, deriving the address from kind/pubkey/identifier
|
||||||
// when the referenced event is addressable.
|
// when the referenced event is addressable.
|
||||||
setRoot(kind: number, id: string, pubkey: string, identifier?: string) {
|
setRoot(kind: number, id: string, pubkey: string, identifier?: string) {
|
||||||
this.values.root = {
|
this.root = {
|
||||||
id,
|
id,
|
||||||
pubkey,
|
pubkey,
|
||||||
kind: String(kind),
|
kind: String(kind),
|
||||||
@@ -128,7 +150,7 @@ export class Comment extends DomainObject<CommentValues> {
|
|||||||
|
|
||||||
// Set the immediate parent reference, deriving the address as above.
|
// Set the immediate parent reference, deriving the address as above.
|
||||||
setParent(kind: number, id: string, pubkey: string, identifier?: string) {
|
setParent(kind: number, id: string, pubkey: string, identifier?: string) {
|
||||||
this.values.parent = {
|
this.parent = {
|
||||||
id,
|
id,
|
||||||
pubkey,
|
pubkey,
|
||||||
kind: String(kind),
|
kind: String(kind),
|
||||||
@@ -140,39 +162,26 @@ export class Comment extends DomainObject<CommentValues> {
|
|||||||
|
|
||||||
// Set the thread root reference from a full event.
|
// Set the thread root reference from a full event.
|
||||||
setRootFromEvent(event: TrustedEvent) {
|
setRootFromEvent(event: TrustedEvent) {
|
||||||
this.values.root = refFromEvent(event)
|
this.root = refFromEvent(event)
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the immediate parent reference from a full event.
|
// Set the immediate parent reference from a full event.
|
||||||
setParentFromEvent(event: TrustedEvent) {
|
setParentFromEvent(event: TrustedEvent) {
|
||||||
this.values.parent = refFromEvent(event)
|
this.parent = refFromEvent(event)
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the NIP-22 reference tags for one struct: uppercase keys for the root,
|
protected buildContent(_signer?: ISigner) {
|
||||||
// lowercase for the parent.
|
return this.content
|
||||||
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> {
|
protected buildTags() {
|
||||||
return {
|
return [
|
||||||
kind: this.kind,
|
...refTags(this.root, ["E", "A", "K", "P"]),
|
||||||
content: this.values.content,
|
...refTags(this.parent, ["e", "a", "k", "p"]),
|
||||||
tags: [
|
]
|
||||||
...this.refTags(this.values.root, ["E", "A", "K", "P"]),
|
|
||||||
...this.refTags(this.values.parent, ["e", "a", "k", "p"]),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,30 @@
|
|||||||
import {uniq, spec} from "@welshman/lib"
|
import {uniq, spec} from "@welshman/lib"
|
||||||
import {EMOJIS, getAddressTagValues} from "@welshman/util"
|
import {EMOJIS, getAddressTagValues} from "@welshman/util"
|
||||||
import {EncryptableList} from "./List.js"
|
import {ListReader, ListBuilder} from "./List.js"
|
||||||
|
|
||||||
// NIP-51 / NIP-30 kind-10030 user emoji list. Holds references to kind 30030
|
// NIP-51 / NIP-30 kind-10030 user emoji list. Holds references to kind 30030
|
||||||
// emoji sets via `a` tags, plus inline `["emoji", shortcode, url]` tags.
|
// emoji sets via `a` tags, plus inline `["emoji", shortcode, url]` tags.
|
||||||
export class EmojiList extends EncryptableList {
|
export class EmojiList extends ListReader {
|
||||||
readonly kind = EMOJIS
|
static kind = EMOJIS
|
||||||
|
|
||||||
|
// Addresses of referenced emoji sets (kind 30030).
|
||||||
addresses() {
|
addresses() {
|
||||||
return uniq(getAddressTagValues(this.tags()))
|
return uniq(getAddressTagValues(this.tags()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inline emoji tags: ["emoji", shortcode, url].
|
||||||
emojis() {
|
emojis() {
|
||||||
return this.tags().filter(spec(["emoji"]))
|
return this.tags().filter(spec(["emoji"]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
builder() {
|
||||||
|
return this.seedList(new EmojiListBuilder())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EmojiListBuilder extends ListBuilder {
|
||||||
|
static kind = EMOJIS
|
||||||
|
|
||||||
addEmoji(shortcode: string, url: string) {
|
addEmoji(shortcode: string, url: string) {
|
||||||
return this.addPublicTags(["emoji", shortcode, url])
|
return this.addPublicTags(["emoji", shortcode, url])
|
||||||
}
|
}
|
||||||
|
|||||||
+78
-59
@@ -1,26 +1,7 @@
|
|||||||
import {parseJson} from "@welshman/lib"
|
import {randomId, parseJson} from "@welshman/lib"
|
||||||
import {FEED, getIdentifier, getTagValue} from "@welshman/util"
|
import {FEED, getTagValue} from "@welshman/util"
|
||||||
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
|
||||||
import {DomainObject} from "./base.js"
|
|
||||||
|
|
||||||
export type FeedValues = {
|
import {EventReader, EventBuilder} from "./base.js"
|
||||||
identifier: string
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
// The feed definition is a @welshman/feeds `IFeed` AST. That package is not a
|
|
||||||
// dependency of @welshman/domain, so it is typed as `unknown` here.
|
|
||||||
definition: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeFeedValues = (values: Partial<FeedValues> = {}): FeedValues => ({
|
|
||||||
identifier: "",
|
|
||||||
title: "",
|
|
||||||
description: "",
|
|
||||||
// Default to an empty @welshman/feeds feed (a union of nothing). That package
|
|
||||||
// isn't a dependency here, so the AST is written structurally.
|
|
||||||
definition: ["union"],
|
|
||||||
...values,
|
|
||||||
})
|
|
||||||
|
|
||||||
// NIP-51 kind-31890 saved-feed DEFINITION event. Addressable via the "d" tag.
|
// NIP-51 kind-31890 saved-feed DEFINITION event. Addressable via the "d" tag.
|
||||||
// The feed definition is a @welshman/feeds `IFeed` AST, JSON-encoded in a "feed"
|
// The feed definition is a @welshman/feeds `IFeed` AST, JSON-encoded in a "feed"
|
||||||
@@ -28,59 +9,97 @@ export const makeFeedValues = (values: Partial<FeedValues> = {}): FeedValues =>
|
|||||||
// kind-10014 FEEDS favorites list (FeedList.ts) which references these by
|
// kind-10014 FEEDS favorites list (FeedList.ts) which references these by
|
||||||
// address. Flotilla's isTopicFeed/isMentionFeed/isAddressFeed/isContextFeed/
|
// address. Flotilla's isTopicFeed/isMentionFeed/isAddressFeed/isContextFeed/
|
||||||
// isPeopleFeed are pure functions over the IFeed AST and stay in flotilla's lib,
|
// isPeopleFeed are pure functions over the IFeed AST and stay in flotilla's lib,
|
||||||
// not on this class. Tags-only, so it extends DomainObject directly.
|
// not on this class. Tags-only, so it extends EventReader directly.
|
||||||
export class Feed extends DomainObject<FeedValues> {
|
export class Feed extends EventReader {
|
||||||
readonly kind = FEED
|
static kind = FEED
|
||||||
values = makeFeedValues()
|
|
||||||
|
|
||||||
protected normalizeValues(values: Partial<FeedValues> = {}) {
|
protected validate() {
|
||||||
return makeFeedValues(values)
|
if (!this.identifier()) {
|
||||||
}
|
throw new Error("Feed requires a d tag")
|
||||||
|
|
||||||
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 {
|
if (getTagValue("feed", this.event.tags) == null) {
|
||||||
identifier: getIdentifier(event) || "",
|
throw new Error("Feed requires a feed tag")
|
||||||
title: getTagValue("title", event.tags) || "",
|
|
||||||
description: getTagValue("description", event.tags) || "",
|
|
||||||
definition: parseJson(feed),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
identifier() {
|
protected reservedTagKeys() {
|
||||||
return this.values.identifier
|
return ["d", "alt", "title", "description", "feed"]
|
||||||
}
|
}
|
||||||
|
|
||||||
title() {
|
title() {
|
||||||
return this.values.title
|
return getTagValue("title", this.event.tags) || ""
|
||||||
}
|
}
|
||||||
|
|
||||||
description() {
|
description() {
|
||||||
return this.values.description
|
return getTagValue("description", this.event.tags) || ""
|
||||||
}
|
}
|
||||||
|
|
||||||
definition() {
|
// The feed definition is a @welshman/feeds `IFeed` AST. That package is not a
|
||||||
return this.values.definition
|
// dependency of @welshman/domain, so it is typed as `unknown` here.
|
||||||
|
definition(): unknown {
|
||||||
|
return parseJson(getTagValue("feed", this.event.tags))
|
||||||
}
|
}
|
||||||
|
|
||||||
async toTemplate(): Promise<EventTemplate> {
|
builder() {
|
||||||
const {identifier, title, description, definition} = this.values
|
const builder = new FeedBuilder()
|
||||||
|
|
||||||
return {
|
builder.identifier = this.identifier() || ""
|
||||||
kind: this.kind,
|
builder.title = this.title()
|
||||||
content: "",
|
builder.description = this.description()
|
||||||
tags: [
|
builder.definition = this.definition()
|
||||||
["d", identifier],
|
|
||||||
["alt", title],
|
return this.seedBuilder(builder)
|
||||||
["title", title],
|
}
|
||||||
["description", description],
|
}
|
||||||
["feed", JSON.stringify(definition)],
|
|
||||||
],
|
export class FeedBuilder extends EventBuilder {
|
||||||
}
|
static kind = FEED
|
||||||
|
|
||||||
|
identifier = randomId()
|
||||||
|
title = ""
|
||||||
|
description = ""
|
||||||
|
// Default to an empty @welshman/feeds feed (a union of nothing). That package
|
||||||
|
// isn't a dependency here, so the AST is written structurally.
|
||||||
|
definition: unknown = ["union"]
|
||||||
|
|
||||||
|
setIdentifier(identifier: string) {
|
||||||
|
this.identifier = identifier
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setTitle(title: string) {
|
||||||
|
this.title = title
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setDescription(description: string) {
|
||||||
|
this.description = description
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setDefinition(definition: unknown) {
|
||||||
|
this.definition = definition
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
protected validate() {
|
||||||
|
if (!this.identifier) {
|
||||||
|
throw new Error("Feed requires a d identifier")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildTags() {
|
||||||
|
return [
|
||||||
|
["d", this.identifier],
|
||||||
|
["alt", this.title],
|
||||||
|
["title", this.title],
|
||||||
|
["description", this.description],
|
||||||
|
["feed", JSON.stringify(this.definition)],
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,37 @@
|
|||||||
import {uniq} from "@welshman/lib"
|
import {uniq} from "@welshman/lib"
|
||||||
import {FEEDS, getAddressTagValues} from "@welshman/util"
|
import {FEEDS, getAddressTagValues} from "@welshman/util"
|
||||||
import {EncryptableList} from "./List.js"
|
import {ListReader, ListBuilder} from "./List.js"
|
||||||
|
|
||||||
// NIP-51 kind-10014 saved feeds list. Entries are `a` tags pointing at kind 31890
|
// NIP-51 kind-10014 saved feeds list. Entries are `a` tags pointing at kind 31890
|
||||||
// FEED definitions. Extends EncryptableList; exposes the addresses as a merged set.
|
// FEED definitions, stored publicly (tags) or privately (encrypted content); the
|
||||||
export class FeedList extends EncryptableList {
|
// reader treats both as one merged set.
|
||||||
readonly kind = FEEDS
|
export class FeedList extends ListReader {
|
||||||
|
static kind = FEEDS
|
||||||
|
|
||||||
addresses() {
|
addresses() {
|
||||||
return uniq(getAddressTagValues(this.tags()))
|
return uniq(getAddressTagValues(this.tags()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
includes(address: string) {
|
||||||
|
return this.addresses().includes(address)
|
||||||
|
}
|
||||||
|
|
||||||
|
builder() {
|
||||||
|
return this.seedList(new FeedListBuilder())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FeedListBuilder extends ListBuilder {
|
||||||
|
static kind = FEEDS
|
||||||
|
|
||||||
addFeed(address: string, relayHint?: string) {
|
addFeed(address: string, relayHint?: string) {
|
||||||
return this.addPublicTags(["a", address, relayHint || ""])
|
return this.addPublicTags(["a", address, relayHint || ""])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addFeedPrivately(address: string, relayHint?: string) {
|
||||||
|
return this.addPrivateTags(["a", address, relayHint || ""])
|
||||||
|
}
|
||||||
|
|
||||||
removeFeed(address: string) {
|
removeFeed(address: string) {
|
||||||
return this.removeTagsWithValue(address)
|
return this.removeTagsWithValue(address)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import {uniq} from "@welshman/lib"
|
import {uniq} from "@welshman/lib"
|
||||||
import {FOLLOWS, getPubkeyTagValues} from "@welshman/util"
|
import {FOLLOWS, getPubkeyTagValues} from "@welshman/util"
|
||||||
import {EncryptableList} from "./List.js"
|
import {ListReader, ListBuilder} from "./List.js"
|
||||||
|
|
||||||
// NIP-02 kind-3 follow list. Structurally a 'p'-tag list; follows are public in
|
// NIP-02 kind-3 follow list. Structurally a 'p'-tag list; follows are public in
|
||||||
// practice, but the encryptable-list machinery is inherited unchanged (private
|
// practice, but the encryptable-list machinery is inherited unchanged (private
|
||||||
// tags simply go unused). Follow targets may also be non-pubkey tags (e.g. 't'
|
// tags simply go unused). Follow targets may also be non-pubkey tags (e.g. 't'
|
||||||
// hashtags), so `follow` accepts a full tag and `unfollow` removes by value.
|
// hashtags), so `addFollow` accepts a full tag and `removeFollow` removes by value.
|
||||||
export class FollowList extends EncryptableList {
|
export class FollowList extends ListReader {
|
||||||
readonly kind = FOLLOWS
|
static kind = FOLLOWS
|
||||||
|
|
||||||
pubkeys() {
|
pubkeys() {
|
||||||
return uniq(getPubkeyTagValues(this.tags()))
|
return uniq(getPubkeyTagValues(this.tags()))
|
||||||
@@ -17,11 +17,19 @@ export class FollowList extends EncryptableList {
|
|||||||
return this.pubkeys().includes(pubkey)
|
return this.pubkeys().includes(pubkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
follow(tag: string[]) {
|
builder() {
|
||||||
|
return this.seedList(new FollowListBuilder())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FollowListBuilder extends ListBuilder {
|
||||||
|
static kind = FOLLOWS
|
||||||
|
|
||||||
|
addFollow(tag: string[]) {
|
||||||
return this.addPublicTags(tag)
|
return this.addPublicTags(tag)
|
||||||
}
|
}
|
||||||
|
|
||||||
unfollow(value: string) {
|
removeFollow(value: string) {
|
||||||
return this.removeTagsWithValue(value)
|
return this.removeTagsWithValue(value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
import {uniq} from "@welshman/lib"
|
import {uniq} from "@welshman/lib"
|
||||||
import {COMMUNITIES, getAddressTagValues} from "@welshman/util"
|
import {COMMUNITIES, getAddressTagValues} from "@welshman/util"
|
||||||
import {EncryptableList} from "./List.js"
|
import {ListReader, ListBuilder} from "./List.js"
|
||||||
|
|
||||||
// NIP-51 kind-10004 group (community) membership list. Entries are `a` tags
|
// NIP-51 kind-10004 group (community) membership list. Entries are `a` tags
|
||||||
// pointing at kind-34550 community definitions.
|
// pointing at kind-34550 community definitions, merged across public tags and
|
||||||
export class GroupList extends EncryptableList {
|
// decrypted private content.
|
||||||
readonly kind = COMMUNITIES
|
export class GroupList extends ListReader {
|
||||||
|
static kind = COMMUNITIES
|
||||||
|
|
||||||
addresses() {
|
addresses() {
|
||||||
return uniq(getAddressTagValues(this.tags()))
|
return uniq(getAddressTagValues(this.tags()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
builder() {
|
||||||
|
return this.seedList(new GroupListBuilder())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GroupListBuilder extends ListBuilder {
|
||||||
|
static kind = COMMUNITIES
|
||||||
|
|
||||||
addGroup(address: string, relayHint?: string) {
|
addGroup(address: string, relayHint?: string) {
|
||||||
return this.addPublicTags(["a", address, relayHint || ""])
|
return this.addPublicTags(["a", address, relayHint || ""])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,97 +1,136 @@
|
|||||||
import {parseJson} from "@welshman/lib"
|
import {parseJson} from "@welshman/lib"
|
||||||
import {HANDLER_INFORMATION, getKindTagValues, getTagValue} from "@welshman/util"
|
import {HANDLER_INFORMATION, getKindTagValues} from "@welshman/util"
|
||||||
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
import {EventReader, EventBuilder} from "./base.js"
|
||||||
import {DomainObject} from "./base.js"
|
|
||||||
|
|
||||||
export type HandlerValues = {
|
// The parsed JSON metadata blob stored in a handler's content. Shaped like a
|
||||||
|
// profile; readers map the various aliases (display_name/picture) down to a
|
||||||
|
// single canonical accessor.
|
||||||
|
export type HandlerMeta = {
|
||||||
name?: string
|
name?: string
|
||||||
about?: string
|
about?: string
|
||||||
image?: string
|
image?: string
|
||||||
website?: string
|
website?: string
|
||||||
lud16?: string
|
lud16?: string
|
||||||
nip05?: string
|
nip05?: string
|
||||||
kinds: number[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const makeHandlerValues = (values: Partial<HandlerValues> = {}): HandlerValues => ({
|
|
||||||
kinds: [],
|
|
||||||
...values,
|
|
||||||
})
|
|
||||||
|
|
||||||
// NIP-89 kind-31990 handler information. Addressable (has a `d` tag); content is a
|
// NIP-89 kind-31990 handler information. Addressable (has a `d` tag); content is a
|
||||||
// JSON metadata blob like a profile. Holds one object with the full set of handled
|
// JSON metadata blob like a profile, and the handled `kinds` are stored as `k`
|
||||||
// `kinds`, rather than the legacy per-kind fan-out.
|
// tags. `plain` is the parsed metadata object.
|
||||||
export class Handler extends DomainObject<HandlerValues> {
|
export class Handler extends EventReader<HandlerMeta> {
|
||||||
readonly kind = HANDLER_INFORMATION
|
static kind = HANDLER_INFORMATION
|
||||||
values = makeHandlerValues()
|
|
||||||
|
|
||||||
protected normalizeValues(values: Partial<HandlerValues> = {}) {
|
protected async parsePlain(): Promise<HandlerMeta> {
|
||||||
return makeHandlerValues(values)
|
return parseJson(this.event.content) || {}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected reservedTagKeys() {
|
protected reservedTagKeys() {
|
||||||
return ["k"]
|
return ["k"]
|
||||||
}
|
}
|
||||||
|
|
||||||
protected parseEvent(event: TrustedEvent): Partial<HandlerValues> {
|
|
||||||
const meta = parseJson(event.content) || {}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: meta.name || meta.display_name,
|
|
||||||
about: meta.about,
|
|
||||||
image: meta.image || meta.picture,
|
|
||||||
website: meta.website,
|
|
||||||
lud16: meta.lud16,
|
|
||||||
nip05: meta.nip05,
|
|
||||||
kinds: getKindTagValues(event.tags),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
name() {
|
name() {
|
||||||
return this.values.name
|
return this.plain.name || (this.plain as {display_name?: string}).display_name
|
||||||
}
|
}
|
||||||
|
|
||||||
about() {
|
about() {
|
||||||
return this.values.about
|
return this.plain.about
|
||||||
}
|
}
|
||||||
|
|
||||||
image() {
|
image() {
|
||||||
return this.values.image
|
return this.plain.image || (this.plain as {picture?: string}).picture
|
||||||
}
|
}
|
||||||
|
|
||||||
website() {
|
website() {
|
||||||
return this.values.website
|
return this.plain.website
|
||||||
}
|
}
|
||||||
|
|
||||||
lud16() {
|
lud16() {
|
||||||
return this.values.lud16
|
return this.plain.lud16
|
||||||
}
|
}
|
||||||
|
|
||||||
nip05() {
|
nip05() {
|
||||||
return this.values.nip05
|
return this.plain.nip05
|
||||||
}
|
}
|
||||||
|
|
||||||
kinds() {
|
kinds() {
|
||||||
return this.values.kinds
|
return getKindTagValues(this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
identifier() {
|
builder() {
|
||||||
return getTagValue("d", this.extraTags)
|
const builder = new HandlerBuilder()
|
||||||
}
|
|
||||||
|
|
||||||
async toTemplate(): Promise<EventTemplate> {
|
builder.name = this.name()
|
||||||
const {name, about, image, website, lud16, nip05} = this.values
|
builder.about = this.about()
|
||||||
|
builder.image = this.image()
|
||||||
|
builder.website = this.website()
|
||||||
|
builder.lud16 = this.lud16()
|
||||||
|
builder.nip05 = this.nip05()
|
||||||
|
builder.kinds = this.kinds()
|
||||||
|
|
||||||
const content = JSON.stringify({name, about, image, website, lud16, nip05})
|
return this.seedBuilder(builder)
|
||||||
|
}
|
||||||
// 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)])
|
export class HandlerBuilder extends EventBuilder<HandlerMeta> {
|
||||||
|
static kind = HANDLER_INFORMATION
|
||||||
return {
|
|
||||||
kind: this.kind,
|
name?: string
|
||||||
content,
|
about?: string
|
||||||
tags: kindTags,
|
image?: string
|
||||||
}
|
website?: string
|
||||||
|
lud16?: string
|
||||||
|
nip05?: string
|
||||||
|
kinds: number[] = []
|
||||||
|
|
||||||
|
setName(name: string) {
|
||||||
|
this.name = name
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setAbout(about: string) {
|
||||||
|
this.about = about
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setImage(image: string) {
|
||||||
|
this.image = image
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setWebsite(website: string) {
|
||||||
|
this.website = website
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setLud16(lud16: string) {
|
||||||
|
this.lud16 = lud16
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setNip05(nip05: string) {
|
||||||
|
this.nip05 = nip05
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setKinds(kinds: number[]) {
|
||||||
|
this.kinds = kinds
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildContent() {
|
||||||
|
const {name, about, image, website, lud16, nip05} = this
|
||||||
|
|
||||||
|
return JSON.stringify({name, about, image, website, lud16, nip05})
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildTags() {
|
||||||
|
return this.kinds.map(kind => ["k", String(kind)])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,80 +1,82 @@
|
|||||||
import {last} from "@welshman/lib"
|
import {last} from "@welshman/lib"
|
||||||
import {HANDLER_RECOMMENDATION, getIdentifier, getAddressTags, getAddressTagValues} from "@welshman/util"
|
import {HANDLER_RECOMMENDATION, getAddressTags, getAddressTagValues} from "@welshman/util"
|
||||||
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
import {EventReader, EventBuilder} from "./base.js"
|
||||||
import {DomainObject} from "./base.js"
|
|
||||||
|
|
||||||
export type HandlerRecommendationValues = {
|
|
||||||
// The recommended kind, stored in the `d` tag.
|
|
||||||
identifier: string
|
|
||||||
// Raw `a` tags: ["a", address, relay?, platform?].
|
|
||||||
addresses: string[][]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeHandlerRecommendationValues = (
|
|
||||||
values: Partial<HandlerRecommendationValues> = {},
|
|
||||||
): HandlerRecommendationValues => ({
|
|
||||||
identifier: "",
|
|
||||||
addresses: [],
|
|
||||||
...values,
|
|
||||||
})
|
|
||||||
|
|
||||||
// NIP-89 kind-31989 handler recommendation. Addressable (the `d` tag holds the
|
// NIP-89 kind-31989 handler recommendation. Addressable (the `d` tag holds the
|
||||||
// recommended kind), tags-only with empty content. Each entry is a raw `a` tag
|
// recommended kind), tags-only with empty content. Each entry is a raw `a` tag
|
||||||
// pointing at a kind-31990 handler, optionally carrying a relay hint and a
|
// pointing at a kind-31990 handler, optionally carrying a relay hint and a
|
||||||
// trailing platform marker (e.g. "web").
|
// trailing platform marker (e.g. "web").
|
||||||
export class HandlerRecommendation extends DomainObject<HandlerRecommendationValues> {
|
export class HandlerRecommendation extends EventReader {
|
||||||
readonly kind = HANDLER_RECOMMENDATION
|
static kind = HANDLER_RECOMMENDATION
|
||||||
values = makeHandlerRecommendationValues()
|
|
||||||
|
|
||||||
protected normalizeValues(values: Partial<HandlerRecommendationValues> = {}) {
|
protected validate() {
|
||||||
return makeHandlerRecommendationValues(values)
|
if (!this.identifier()) {
|
||||||
}
|
throw new Error("HandlerRecommendation requires a d tag")
|
||||||
|
|
||||||
protected parseEvent(event: TrustedEvent): Partial<HandlerRecommendationValues> {
|
|
||||||
return {
|
|
||||||
identifier: getIdentifier(event) || "",
|
|
||||||
addresses: getAddressTags(event.tags),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
identifier() {
|
protected reservedTagKeys() {
|
||||||
return this.values.identifier
|
return ["d", "a"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw `a` tags: ["a", address, relay?, platform?].
|
||||||
|
addressTags() {
|
||||||
|
return getAddressTags(this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
addresses() {
|
addresses() {
|
||||||
return getAddressTagValues(this.values.addresses)
|
return getAddressTagValues(this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer the recommendation marked as a "web" handler, otherwise fall back to
|
// Prefer the recommendation marked as a "web" handler, otherwise fall back to
|
||||||
// the first recommendation.
|
// the first recommendation.
|
||||||
handlerAddress() {
|
handlerAddress() {
|
||||||
const tag = this.values.addresses.find(t => last(t) === "web") || this.values.addresses[0]
|
const tags = this.addressTags()
|
||||||
|
const tag = tags.find(t => last(t) === "web") || tags[0]
|
||||||
|
|
||||||
return tag?.[1]
|
return tag?.[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
builder() {
|
||||||
|
const builder = new HandlerRecommendationBuilder(this.identifier() || "")
|
||||||
|
|
||||||
|
builder.addressTags = this.addressTags()
|
||||||
|
|
||||||
|
return this.seedBuilder(builder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HandlerRecommendationBuilder extends EventBuilder {
|
||||||
|
static kind = HANDLER_RECOMMENDATION
|
||||||
|
|
||||||
|
// Raw `a` tags: ["a", address, relay?, platform?].
|
||||||
|
addressTags: string[][] = []
|
||||||
|
|
||||||
|
constructor(public identifier: string) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
addRecommendation(address: string, relay?: string, platform?: string) {
|
addRecommendation(address: string, relay?: string, platform?: string) {
|
||||||
if (!this.values.addresses.some(t => t[1] === address)) {
|
if (!this.addressTags.some(t => t[1] === address)) {
|
||||||
this.values.addresses = [
|
this.addressTags = [...this.addressTags, ["a", address, relay || "", platform || ""]]
|
||||||
...this.values.addresses,
|
|
||||||
["a", address, relay || "", platform || ""],
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
removeRecommendation(address: string) {
|
removeRecommendation(address: string) {
|
||||||
this.values.addresses = this.values.addresses.filter(t => t[1] !== address)
|
this.addressTags = this.addressTags.filter(t => t[1] !== address)
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
async toTemplate(): Promise<EventTemplate> {
|
protected validate() {
|
||||||
return {
|
if (!this.identifier) {
|
||||||
kind: this.kind,
|
throw new Error("HandlerRecommendation requires a d identifier")
|
||||||
tags: [["d", this.values.identifier], ...this.values.addresses],
|
|
||||||
content: "",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected buildTags() {
|
||||||
|
return [["d", this.identifier], ...this.addressTags]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+77
-58
@@ -1,34 +1,27 @@
|
|||||||
import {nthEq, parseJson} from "@welshman/lib"
|
import {nthEq, parseJson} from "@welshman/lib"
|
||||||
import {uniqTags} from "@welshman/util"
|
import {uniqTags} from "@welshman/util"
|
||||||
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {decrypt} from "@welshman/signer"
|
import {decrypt} from "@welshman/signer"
|
||||||
import type {ISigner} from "@welshman/signer"
|
import type {ISigner} from "@welshman/signer"
|
||||||
import {DomainObject} from "./base.js"
|
import {EventReader, EventBuilder} from "./base.js"
|
||||||
|
|
||||||
const isValidTag = (tag: unknown): tag is string[] =>
|
const isValidTag = (tag: unknown): tag is string[] =>
|
||||||
Array.isArray(tag) && tag.length > 0 && tag.every(v => typeof v === "string")
|
Array.isArray(tag) && tag.length > 0 && tag.every(v => typeof v === "string")
|
||||||
|
|
||||||
export type ListValues = {
|
// The decrypted `plain` payload shared by every NIP-51 list. `decrypted` is false
|
||||||
publicTags: string[][]
|
// when there was ciphertext we couldn't read (no signer / decryption failed), so
|
||||||
|
// the private entries are unknown and must be left untouched.
|
||||||
|
export type ListPlain = {
|
||||||
privateTags: string[][]
|
privateTags: string[][]
|
||||||
// True when `privateTags` reflects decrypted content; false when we hold
|
|
||||||
// ciphertext we couldn't read (so private entries are unknown).
|
|
||||||
decrypted: boolean
|
decrypted: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const makeListValues = (values: Partial<ListValues> = {}): ListValues => ({
|
|
||||||
publicTags: [],
|
|
||||||
privateTags: [],
|
|
||||||
decrypted: true,
|
|
||||||
...values,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Decrypt the private tags in an event's content. Returns decrypted: false when
|
// Decrypt the private tags in an event's content. Returns decrypted: false when
|
||||||
// there's content but no signer, or decryption fails.
|
// there's content but no signer, or decryption fails.
|
||||||
export const decryptListContent = async (
|
export const decryptListContent = async (
|
||||||
event: TrustedEvent,
|
event: TrustedEvent,
|
||||||
signer?: ISigner,
|
signer?: ISigner,
|
||||||
): Promise<Pick<ListValues, "privateTags" | "decrypted">> => {
|
): Promise<ListPlain> => {
|
||||||
if (!event.content) return {privateTags: [], decrypted: true}
|
if (!event.content) return {privateTags: [], decrypted: true}
|
||||||
|
|
||||||
if (!signer) return {privateTags: [], decrypted: false}
|
if (!signer) return {privateTags: [], decrypted: false}
|
||||||
@@ -43,46 +36,79 @@ export const decryptListContent = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Base for NIP-51 lists: public entries in tags, private entries as an encrypted
|
// Read side for NIP-51 lists: public entries in tags, private entries decrypted
|
||||||
// JSON array in content. Subclasses fix the kind and add domain accessors.
|
// from content into `plain`. Subclasses declare the kind and add domain accessors
|
||||||
export abstract class EncryptableList extends DomainObject<ListValues> {
|
// over `tags()`.
|
||||||
values = makeListValues()
|
export abstract class ListReader extends EventReader<ListPlain> {
|
||||||
|
protected parsePlain(signer?: ISigner) {
|
||||||
protected normalizeValues(values: Partial<ListValues> = {}) {
|
return decryptListContent(this.event, signer)
|
||||||
return makeListValues(values)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async parseEvent(event: TrustedEvent, signer?: ISigner): Promise<Partial<ListValues>> {
|
publicTags() {
|
||||||
const {privateTags, decrypted} = await decryptListContent(event, signer)
|
return this.event.tags
|
||||||
|
}
|
||||||
|
|
||||||
return {publicTags: event.tags, privateTags, decrypted}
|
privateTags() {
|
||||||
|
return this.plain.privateTags
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted() {
|
||||||
|
return this.plain.decrypted
|
||||||
}
|
}
|
||||||
|
|
||||||
tags() {
|
tags() {
|
||||||
return [...this.values.publicTags, ...this.values.privateTags]
|
return [...this.event.tags, ...this.plain.privateTags]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed a list builder from this reader: public tags, decrypted private tags,
|
||||||
|
// the original ciphertext (for the undecrypted case) and the behavior tags.
|
||||||
|
protected seedList<B extends ListBuilder>(builder: B): B {
|
||||||
|
builder.publicTags = [...this.event.tags]
|
||||||
|
builder.plain = {privateTags: [...this.plain.privateTags], decrypted: this.plain.decrypted}
|
||||||
|
builder.originalContent = this.event.content
|
||||||
|
builder.group = this.group()
|
||||||
|
builder.protect = this.protect()
|
||||||
|
builder.expires = this.expires()
|
||||||
|
|
||||||
|
return builder
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract builder(): ListBuilder
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write side for NIP-51 lists: mutate public/private tag sets, re-encrypt the
|
||||||
|
// private set into content on emit.
|
||||||
|
export abstract class ListBuilder extends EventBuilder<ListPlain> {
|
||||||
|
publicTags: string[][] = []
|
||||||
|
plain: ListPlain = {privateTags: [], decrypted: true}
|
||||||
|
// Preserved ciphertext when the source list was never decrypted.
|
||||||
|
originalContent?: string
|
||||||
|
|
||||||
|
tags() {
|
||||||
|
return [...this.publicTags, ...this.plain.privateTags]
|
||||||
}
|
}
|
||||||
|
|
||||||
addPublicTags(...tags: string[][]) {
|
addPublicTags(...tags: string[][]) {
|
||||||
this.values.publicTags = uniqTags([...this.values.publicTags, ...tags])
|
this.publicTags = uniqTags([...this.publicTags, ...tags])
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
addPrivateTags(...tags: string[][]) {
|
addPrivateTags(...tags: string[][]) {
|
||||||
if (!this.values.decrypted) {
|
if (!this.plain.decrypted) {
|
||||||
throw new Error("Cannot modify the private entries of a list that has not been decrypted")
|
throw new Error("Cannot modify the private entries of a list that has not been decrypted")
|
||||||
}
|
}
|
||||||
|
|
||||||
this.values.privateTags = uniqTags([...this.values.privateTags, ...tags])
|
this.plain.privateTags = uniqTags([...this.plain.privateTags, ...tags])
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
keepTags(pred: (tag: string[]) => boolean) {
|
keepTags(pred: (tag: string[]) => boolean) {
|
||||||
this.values.publicTags = this.values.publicTags.filter(t => pred(t))
|
this.publicTags = this.publicTags.filter(t => pred(t))
|
||||||
|
|
||||||
if (this.values.decrypted) {
|
if (this.plain.decrypted) {
|
||||||
this.values.privateTags = this.values.privateTags.filter(t => pred(t))
|
this.plain.privateTags = this.plain.privateTags.filter(t => pred(t))
|
||||||
}
|
}
|
||||||
|
|
||||||
return this
|
return this
|
||||||
@@ -97,10 +123,10 @@ export abstract class EncryptableList extends DomainObject<ListValues> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
removeTags(pred: (tag: string[]) => boolean) {
|
removeTags(pred: (tag: string[]) => boolean) {
|
||||||
this.values.publicTags = this.values.publicTags.filter(t => !pred(t))
|
this.publicTags = this.publicTags.filter(t => !pred(t))
|
||||||
|
|
||||||
if (this.values.decrypted) {
|
if (this.plain.decrypted) {
|
||||||
this.values.privateTags = this.values.privateTags.filter(t => !pred(t))
|
this.plain.privateTags = this.plain.privateTags.filter(t => !pred(t))
|
||||||
}
|
}
|
||||||
|
|
||||||
return this
|
return this
|
||||||
@@ -115,54 +141,47 @@ export abstract class EncryptableList extends DomainObject<ListValues> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearPublicTags() {
|
clearPublicTags() {
|
||||||
this.values.publicTags = []
|
this.publicTags = []
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
clearPrivateTags() {
|
clearPrivateTags() {
|
||||||
if (!this.values.decrypted) {
|
if (!this.plain.decrypted) {
|
||||||
throw new Error("Cannot modify the private entries of a list that has not been decrypted")
|
throw new Error("Cannot modify the private entries of a list that has not been decrypted")
|
||||||
}
|
}
|
||||||
|
|
||||||
this.values.privateTags = []
|
this.plain.privateTags = []
|
||||||
|
|
||||||
return this
|
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() {
|
clearTags() {
|
||||||
this.values.publicTags = []
|
this.publicTags = []
|
||||||
|
|
||||||
if (this.values.decrypted) {
|
if (this.plain.decrypted) {
|
||||||
this.values.privateTags = []
|
this.plain.privateTags = []
|
||||||
}
|
}
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
async toTemplate(signer?: ISigner): Promise<EventTemplate> {
|
protected buildTags() {
|
||||||
const tags = this.values.publicTags
|
return this.publicTags
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async buildContent(signer?: ISigner): Promise<string> {
|
||||||
// Preserve the original ciphertext when we never decrypted it.
|
// Preserve the original ciphertext when we never decrypted it.
|
||||||
let content = this.event?.content || ""
|
if (!this.plain.decrypted) return this.originalContent || ""
|
||||||
|
|
||||||
if (this.values.decrypted) {
|
if (this.plain.privateTags.length === 0) return ""
|
||||||
if (this.values.privateTags.length === 0) {
|
|
||||||
content = ""
|
|
||||||
} else {
|
|
||||||
if (!signer) {
|
|
||||||
throw new Error("A signer is required to encrypt the private entries of a list")
|
|
||||||
}
|
|
||||||
|
|
||||||
const pubkey = await signer.getPubkey()
|
if (!signer) {
|
||||||
|
throw new Error("A signer is required to encrypt the private entries of a list")
|
||||||
content = await signer.nip44.encrypt(pubkey, JSON.stringify(this.values.privateTags))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {kind: this.kind, tags, content}
|
const pubkey = await signer.getPubkey()
|
||||||
|
|
||||||
|
return signer.nip44.encrypt(pubkey, JSON.stringify(this.plain.privateTags))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,33 @@
|
|||||||
import {uniqBy} from "@welshman/lib"
|
import {uniqBy} from "@welshman/lib"
|
||||||
import {MESSAGING_RELAYS, getTagValues, normalizeRelayUrl} from "@welshman/util"
|
import {MESSAGING_RELAYS, getTagValues, normalizeRelayUrl} from "@welshman/util"
|
||||||
import {EncryptableList} from "./List.js"
|
import {ListReader, ListBuilder} from "./List.js"
|
||||||
|
|
||||||
// NIP-17 kind-10050 messaging/inbox relays. Entries are marker-less
|
// NIP-17 kind-10050 messaging/inbox relays. Entries are marker-less
|
||||||
// ['relay', url] tags (NOT NIP-65 'r' tags with read/write markers, and the
|
// ['relay', url] tags (NOT NIP-65 'r' tags with read/write markers, and the
|
||||||
// RelayMode.Messaging marker is not used per-tag here). `urls()` drives where
|
// RelayMode.Messaging marker is not used per-tag here). `urls()` drives where
|
||||||
// encrypted DM gift-wraps are sent and fetched, so it stays a flat, normalized
|
// encrypted DM gift-wraps are sent and fetched, so it stays a flat, normalized
|
||||||
// set. Identical structure to BlockedRelayList/SearchRelayList.
|
// set. Identical structure to BlockedRelayList/SearchRelayList.
|
||||||
export class MessagingRelayList extends EncryptableList {
|
export class MessagingRelayList extends ListReader {
|
||||||
readonly kind = MESSAGING_RELAYS
|
static kind = MESSAGING_RELAYS
|
||||||
|
|
||||||
urls() {
|
urls() {
|
||||||
return uniqBy(normalizeRelayUrl, getTagValues("relay", this.tags()))
|
return uniqBy(normalizeRelayUrl, getTagValues("relay", this.tags()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
builder() {
|
||||||
|
return this.seedList(new MessagingRelayListBuilder())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MessagingRelayListBuilder extends ListBuilder {
|
||||||
|
static kind = MESSAGING_RELAYS
|
||||||
|
|
||||||
addRelay(url: string) {
|
addRelay(url: string) {
|
||||||
return this.addPublicTags(["relay", normalizeRelayUrl(url)])
|
return this.addPublicTags(["relay", normalizeRelayUrl(url)])
|
||||||
}
|
}
|
||||||
|
|
||||||
removeRelay(url: string) {
|
removeRelay(url: string) {
|
||||||
return this.removeTagsWithValue(url)
|
return this.removeTagsWithValue(normalizeRelayUrl(url))
|
||||||
}
|
}
|
||||||
|
|
||||||
setRelays(urls: string[]) {
|
setRelays(urls: string[]) {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import {uniq} from "@welshman/lib"
|
import {uniq} from "@welshman/lib"
|
||||||
import {MUTES, getPubkeyTagValues} from "@welshman/util"
|
import {MUTES, getPubkeyTagValues} from "@welshman/util"
|
||||||
import {EncryptableList} from "./List.js"
|
import {ListReader, ListBuilder} from "./List.js"
|
||||||
|
|
||||||
// NIP-51 kind-10000 mute list. Pubkeys can be muted publicly (tags) or privately
|
// NIP-51 kind-10000 mute list. Pubkeys can be muted publicly (tags) or privately
|
||||||
// (encrypted content); the accessors treat both as one merged set.
|
// (encrypted content); the reader treats both as one merged set.
|
||||||
export class MuteList extends EncryptableList {
|
export class MuteList extends ListReader {
|
||||||
readonly kind = MUTES
|
static kind = MUTES
|
||||||
|
|
||||||
pubkeys() {
|
pubkeys() {
|
||||||
return uniq(getPubkeyTagValues(this.tags()))
|
return uniq(getPubkeyTagValues(this.tags()))
|
||||||
@@ -15,6 +15,14 @@ export class MuteList extends EncryptableList {
|
|||||||
return this.pubkeys().includes(pubkey)
|
return this.pubkeys().includes(pubkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
builder() {
|
||||||
|
return this.seedList(new MuteListBuilder())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MuteListBuilder extends ListBuilder {
|
||||||
|
static kind = MUTES
|
||||||
|
|
||||||
mutePublicly(pubkey: string) {
|
mutePublicly(pubkey: string) {
|
||||||
return this.addPublicTags(["p", pubkey])
|
return this.addPublicTags(["p", pubkey])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import {uniq} from "@welshman/lib"
|
import {uniq} from "@welshman/lib"
|
||||||
import {PINS, getEventTagValues, getAddressTagValues} from "@welshman/util"
|
import {PINS, getEventTagValues, getAddressTagValues} from "@welshman/util"
|
||||||
import {EncryptableList} from "./List.js"
|
import {ListReader, ListBuilder} from "./List.js"
|
||||||
|
|
||||||
// NIP-51 kind-10001 pin list. Pinned items are heterogeneous tags (typically
|
// NIP-51 kind-10001 pin list. Pinned items are heterogeneous tags (typically
|
||||||
// 'e' events and optionally 'a' addresses), so they are exposed through
|
// 'e' events and optionally 'a' addresses), so they are exposed through
|
||||||
// type-specific accessors rather than a single id-only set.
|
// type-specific accessors rather than a single id-only set. Items can be pinned
|
||||||
export class PinList extends EncryptableList {
|
// publicly (tags) or privately (encrypted content); the reader merges both.
|
||||||
readonly kind = PINS
|
export class PinList extends ListReader {
|
||||||
|
static kind = PINS
|
||||||
|
|
||||||
ids() {
|
ids() {
|
||||||
return uniq(getEventTagValues(this.tags()))
|
return uniq(getEventTagValues(this.tags()))
|
||||||
@@ -16,11 +17,24 @@ export class PinList extends EncryptableList {
|
|||||||
return uniq(getAddressTagValues(this.tags()))
|
return uniq(getAddressTagValues(this.tags()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
builder() {
|
||||||
|
return this.seedList(new PinListBuilder())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PinListBuilder extends ListBuilder {
|
||||||
|
static kind = PINS
|
||||||
|
|
||||||
// Pin a full tag (e.g. ["e", id, ...] or ["a", address, ...]) publicly.
|
// Pin a full tag (e.g. ["e", id, ...] or ["a", address, ...]) publicly.
|
||||||
pin(tag: string[]) {
|
pinPublicly(tag: string[]) {
|
||||||
return this.addPublicTags(tag)
|
return this.addPublicTags(tag)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pin a full tag (e.g. ["e", id, ...] or ["a", address, ...]) privately.
|
||||||
|
pinPrivately(tag: string[]) {
|
||||||
|
return this.addPrivateTags(tag)
|
||||||
|
}
|
||||||
|
|
||||||
unpin(value: string) {
|
unpin(value: string) {
|
||||||
return this.removeTagsWithValue(value)
|
return this.removeTagsWithValue(value)
|
||||||
}
|
}
|
||||||
|
|||||||
+102
-56
@@ -1,7 +1,8 @@
|
|||||||
import {now, uniq} from "@welshman/lib"
|
import {now, uniq, randomId} from "@welshman/lib"
|
||||||
import {POLL, getTagValue, getTagValues} from "@welshman/util"
|
import {POLL, getTagValue, getTagValues} from "@welshman/util"
|
||||||
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {DomainObject} from "./base.js"
|
import type {ISigner} from "@welshman/signer"
|
||||||
|
import {EventReader, EventBuilder} from "./base.js"
|
||||||
|
|
||||||
export type PollType = "singlechoice" | "multiplechoice"
|
export type PollType = "singlechoice" | "multiplechoice"
|
||||||
|
|
||||||
@@ -15,79 +16,61 @@ export type PollResult = {
|
|||||||
voters: number
|
voters: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PollValues = {
|
|
||||||
title: string
|
|
||||||
options: PollOption[]
|
|
||||||
pollType: PollType
|
|
||||||
endsAt?: number
|
|
||||||
relays: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makePollValues = (values: Partial<PollValues> = {}): PollValues => ({
|
|
||||||
title: "",
|
|
||||||
options: [],
|
|
||||||
pollType: "singlechoice",
|
|
||||||
relays: [],
|
|
||||||
...values,
|
|
||||||
})
|
|
||||||
|
|
||||||
// NIP-88 kind-1068 poll. The poll title/question lives in `content` as plain
|
// NIP-88 kind-1068 poll. The poll title/question lives in `content` as plain
|
||||||
// text (not JSON), options come from "option" tags, and the response tally is
|
// text (not JSON), options come from "option" tags, and the response tally is
|
||||||
// computed from sibling kind-1018 response events passed into `results`.
|
// computed from sibling kind-1018 response events passed into `results`.
|
||||||
export class Poll extends DomainObject<PollValues> {
|
export class Poll extends EventReader {
|
||||||
readonly kind = POLL
|
static kind = POLL
|
||||||
values = makePollValues()
|
|
||||||
|
|
||||||
protected normalizeValues(values: Partial<PollValues> = {}) {
|
protected validate() {
|
||||||
return makePollValues(values)
|
if (this.options().length === 0) {
|
||||||
}
|
throw new Error("Poll requires at least one option tag")
|
||||||
|
|
||||||
protected parseEvent(event: TrustedEvent): Partial<PollValues> {
|
|
||||||
const endsAtRaw = getTagValue("endsAt", event.tags)
|
|
||||||
const endsAt = endsAtRaw == null ? NaN : parseInt(endsAtRaw)
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: event.content || "",
|
|
||||||
options: event.tags
|
|
||||||
.filter(t => t[0] === "option")
|
|
||||||
.map(t => ({id: t[1], label: t[2] || t[1]})),
|
|
||||||
pollType: (getTagValue("polltype", event.tags) as PollType) || "singlechoice",
|
|
||||||
endsAt: Number.isNaN(endsAt) ? undefined : endsAt,
|
|
||||||
relays: getTagValues("relay", event.tags),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected reservedTagKeys() {
|
||||||
|
return ["option", "polltype", "endsAt", "relay"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// The poll title/question is plain-text content.
|
||||||
title() {
|
title() {
|
||||||
return this.values.title
|
return this.event.content || ""
|
||||||
}
|
}
|
||||||
|
|
||||||
options() {
|
options(): PollOption[] {
|
||||||
return this.values.options
|
return this.event.tags
|
||||||
|
.filter(t => t[0] === "option")
|
||||||
|
.map(t => ({id: t[1], label: t[2] || t[1]}))
|
||||||
}
|
}
|
||||||
|
|
||||||
pollType() {
|
pollType(): PollType {
|
||||||
return this.values.pollType
|
return (getTagValue("polltype", this.event.tags) as PollType) || "singlechoice"
|
||||||
}
|
}
|
||||||
|
|
||||||
endsAt() {
|
endsAt() {
|
||||||
return this.values.endsAt
|
const endsAt = parseInt(getTagValue("endsAt", this.event.tags) ?? "")
|
||||||
|
|
||||||
|
return isNaN(endsAt) ? undefined : endsAt
|
||||||
}
|
}
|
||||||
|
|
||||||
isClosed() {
|
isClosed() {
|
||||||
return this.values.endsAt != null && this.values.endsAt <= now()
|
const endsAt = this.endsAt()
|
||||||
|
|
||||||
|
return endsAt != null && endsAt <= now()
|
||||||
}
|
}
|
||||||
|
|
||||||
relays() {
|
relays() {
|
||||||
return this.values.relays
|
return getTagValues("relay", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tally the latest response per pubkey across the poll options. Each response
|
// Tally the latest response per pubkey across the poll options. Each response
|
||||||
// is a kind-1018 event whose "response" tags name selected option ids;
|
// is a kind-1018 event whose "response" tags name selected option ids;
|
||||||
// single-choice polls only honor the first selection.
|
// single-choice polls only honor the first selection.
|
||||||
results(responses: TrustedEvent[]): PollResult {
|
results(responses: TrustedEvent[]): PollResult {
|
||||||
const options = this.values.options.map(option => ({...option, votes: 0}))
|
const options = this.options().map(option => ({...option, votes: 0}))
|
||||||
const counts = new Map(options.map(option => [option.id, option]))
|
const counts = new Map(options.map(option => [option.id, option]))
|
||||||
const latestByPubkey = new Map<string, TrustedEvent>()
|
const latestByPubkey = new Map<string, TrustedEvent>()
|
||||||
|
const pollType = this.pollType()
|
||||||
|
|
||||||
for (const response of responses) {
|
for (const response of responses) {
|
||||||
const current = latestByPubkey.get(response.pubkey)
|
const current = latestByPubkey.get(response.pubkey)
|
||||||
@@ -99,8 +82,7 @@ export class Poll extends DomainObject<PollValues> {
|
|||||||
|
|
||||||
for (const response of latestByPubkey.values()) {
|
for (const response of latestByPubkey.values()) {
|
||||||
const selections = getTagValues("response", response.tags)
|
const selections = getTagValues("response", response.tags)
|
||||||
const ids =
|
const ids = pollType === "singlechoice" ? selections.slice(0, 1) : uniq(selections)
|
||||||
this.values.pollType === "singlechoice" ? selections.slice(0, 1) : uniq(selections)
|
|
||||||
|
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const option = counts.get(id)
|
const option = counts.get(id)
|
||||||
@@ -114,20 +96,84 @@ export class Poll extends DomainObject<PollValues> {
|
|||||||
return {options, voters: latestByPubkey.size}
|
return {options, voters: latestByPubkey.size}
|
||||||
}
|
}
|
||||||
|
|
||||||
async toTemplate(): Promise<EventTemplate> {
|
builder() {
|
||||||
|
const builder = new PollBuilder(this.title())
|
||||||
|
|
||||||
|
builder.options = this.options()
|
||||||
|
builder.pollType = this.pollType()
|
||||||
|
builder.endsAt = this.endsAt()
|
||||||
|
builder.relays = this.relays()
|
||||||
|
|
||||||
|
return this.seedBuilder(builder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PollBuilder extends EventBuilder {
|
||||||
|
static kind = POLL
|
||||||
|
|
||||||
|
options: PollOption[] = []
|
||||||
|
pollType: PollType = "singlechoice"
|
||||||
|
endsAt?: number
|
||||||
|
relays: string[] = []
|
||||||
|
|
||||||
|
constructor(public title = "") {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
setTitle(title: string) {
|
||||||
|
this.title = title
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
addOption(label: string, id = randomId()) {
|
||||||
|
this.options = [...this.options, {id, label}]
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setPollType(pollType: PollType) {
|
||||||
|
this.pollType = pollType
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setEndsAt(endsAt: number) {
|
||||||
|
this.endsAt = endsAt
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setRelays(relays: string[]) {
|
||||||
|
this.relays = relays
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
protected validate() {
|
||||||
|
if (this.options.length === 0) {
|
||||||
|
throw new Error("Poll requires at least one option")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildContent(_signer?: ISigner) {
|
||||||
|
return this.title
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildTags() {
|
||||||
const tags: string[][] = [
|
const tags: string[][] = [
|
||||||
...this.values.options.map(o => ["option", o.id, o.label]),
|
...this.options.map(o => ["option", o.id, o.label]),
|
||||||
["polltype", this.values.pollType],
|
["polltype", this.pollType],
|
||||||
]
|
]
|
||||||
|
|
||||||
if (this.values.endsAt != null) {
|
if (this.endsAt != null) {
|
||||||
tags.push(["endsAt", String(this.values.endsAt)])
|
tags.push(["endsAt", String(this.endsAt)])
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const relay of this.values.relays) {
|
for (const relay of this.relays) {
|
||||||
tags.push(["relay", relay])
|
tags.push(["relay", relay])
|
||||||
}
|
}
|
||||||
|
|
||||||
return {kind: this.kind, content: this.values.title, tags}
|
return tags
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,55 +1,66 @@
|
|||||||
import {uniq} from "@welshman/lib"
|
import {uniq} from "@welshman/lib"
|
||||||
import {POLL_RESPONSE, getTagValue, getTagValues} from "@welshman/util"
|
import {POLL_RESPONSE, getTagValue, getTagValues} from "@welshman/util"
|
||||||
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
import {EventReader, EventBuilder} from "./base.js"
|
||||||
import {DomainObject} from "./base.js"
|
|
||||||
|
|
||||||
export type PollResponseValues = {
|
|
||||||
pollId: string
|
|
||||||
selections: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makePollResponseValues = (
|
|
||||||
values: Partial<PollResponseValues> = {},
|
|
||||||
): PollResponseValues => ({
|
|
||||||
pollId: "",
|
|
||||||
selections: [],
|
|
||||||
...values,
|
|
||||||
})
|
|
||||||
|
|
||||||
// NIP-88 kind-1018 poll vote. Empty content; the target poll is referenced via
|
// NIP-88 kind-1018 poll vote. Empty content; the target poll is referenced via
|
||||||
// an "e" tag and each chosen option id lives in its own "response" tag. Tags-only
|
// an "e" tag and each chosen option id lives in its own "response" tag. Tags-only
|
||||||
// content, so it extends DomainObject directly rather than the encryptable list base.
|
// content, so it extends EventReader/EventBuilder directly.
|
||||||
export class PollResponse extends DomainObject<PollResponseValues> {
|
export class PollResponse extends EventReader {
|
||||||
readonly kind = POLL_RESPONSE
|
static kind = POLL_RESPONSE
|
||||||
values = makePollResponseValues()
|
|
||||||
|
|
||||||
protected normalizeValues(values: Partial<PollResponseValues> = {}) {
|
protected validate() {
|
||||||
return makePollResponseValues(values)
|
if (!this.pollId()) {
|
||||||
|
throw new Error("PollResponse requires an e tag")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected parseEvent(event: TrustedEvent): Partial<PollResponseValues> {
|
protected reservedTagKeys() {
|
||||||
return {
|
return ["e", "response"]
|
||||||
pollId: getTagValue("e", event.tags) || "",
|
|
||||||
selections: getTagValues("response", event.tags),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pollId() {
|
pollId() {
|
||||||
return this.values.pollId
|
return getTagValue("e", this.event.tags) || ""
|
||||||
}
|
}
|
||||||
|
|
||||||
selections() {
|
selections() {
|
||||||
return uniq(this.values.selections)
|
return uniq(getTagValues("response", this.event.tags))
|
||||||
}
|
}
|
||||||
|
|
||||||
async toTemplate(): Promise<EventTemplate> {
|
builder() {
|
||||||
return {
|
const builder = new PollResponseBuilder()
|
||||||
kind: this.kind,
|
|
||||||
content: "",
|
builder.pollId = this.pollId()
|
||||||
tags: [
|
builder.selections = this.selections()
|
||||||
["e", this.values.pollId],
|
|
||||||
...this.values.selections.map(id => ["response", id]),
|
return this.seedBuilder(builder)
|
||||||
],
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class PollResponseBuilder extends EventBuilder {
|
||||||
|
static kind = POLL_RESPONSE
|
||||||
|
|
||||||
|
pollId = ""
|
||||||
|
selections: string[] = []
|
||||||
|
|
||||||
|
setPollId(pollId: string) {
|
||||||
|
this.pollId = pollId
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
addSelection(id: string) {
|
||||||
|
this.selections = uniq([...this.selections, id])
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
protected validate() {
|
||||||
|
if (!this.pollId) {
|
||||||
|
throw new Error("PollResponse requires a pollId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildTags() {
|
||||||
|
return [["e", this.pollId], ...this.selections.map(id => ["response", id])]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {npubEncode} from "nostr-tools/nip19"
|
import {npubEncode} from "nostr-tools/nip19"
|
||||||
import {ellipsize, parseJson} from "@welshman/lib"
|
import {ellipsize, parseJson} from "@welshman/lib"
|
||||||
import {PROFILE, getLnUrl} from "@welshman/util"
|
import {PROFILE, getLnUrl} from "@welshman/util"
|
||||||
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
import type {ISigner} from "@welshman/signer"
|
||||||
import {DomainObject} from "./base.js"
|
import {EventReader, EventBuilder} from "./base.js"
|
||||||
|
|
||||||
export type ProfileValues = {
|
export type ProfileValues = {
|
||||||
name?: string
|
name?: string
|
||||||
@@ -40,60 +40,115 @@ export const displayPubkey = (pubkey: string) => {
|
|||||||
return d.slice(0, 8) + "…" + d.slice(-5)
|
return d.slice(0, 8) + "…" + d.slice(-5)
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Profile extends DomainObject<ProfileValues> {
|
// Read side for a NIP-01 kind-0 profile. The metadata lives in the JSON content,
|
||||||
readonly kind = PROFILE
|
// parsed once into `plain` (a ProfileValues with `lnurl` derived from lud06/lud16).
|
||||||
values = makeProfileValues()
|
// Accessors read `this.plain`; there are no represented tags.
|
||||||
|
export class Profile extends EventReader<ProfileValues> {
|
||||||
|
static kind = PROFILE
|
||||||
|
|
||||||
protected normalizeValues(values: Partial<ProfileValues> = {}) {
|
protected parsePlain() {
|
||||||
return makeProfileValues(values)
|
return makeProfileValues(parseJson(this.event.content) || {})
|
||||||
}
|
|
||||||
|
|
||||||
protected parseEvent(event: TrustedEvent): Partial<ProfileValues> {
|
|
||||||
return parseJson(event.content) || {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
name() {
|
name() {
|
||||||
return this.values.name || this.values.display_name
|
return this.plain.name || this.plain.display_name
|
||||||
}
|
}
|
||||||
|
|
||||||
nip05() {
|
nip05() {
|
||||||
return this.values.nip05
|
return this.plain.nip05
|
||||||
}
|
}
|
||||||
|
|
||||||
lnurl() {
|
lnurl() {
|
||||||
return this.values.lnurl
|
return this.plain.lnurl
|
||||||
}
|
}
|
||||||
|
|
||||||
about() {
|
about() {
|
||||||
return this.values.about
|
return this.plain.about
|
||||||
}
|
}
|
||||||
|
|
||||||
banner() {
|
banner() {
|
||||||
return this.values.banner
|
return this.plain.banner
|
||||||
}
|
}
|
||||||
|
|
||||||
picture() {
|
picture() {
|
||||||
return this.values.picture
|
return this.plain.picture
|
||||||
}
|
}
|
||||||
|
|
||||||
website() {
|
website() {
|
||||||
return this.values.website
|
return this.plain.website
|
||||||
}
|
}
|
||||||
|
|
||||||
display(fallback = "") {
|
display(fallback = "") {
|
||||||
const name = this.name()
|
const name = this.name()
|
||||||
|
|
||||||
if (name) return ellipsize(name, 60).trim()
|
if (name) return ellipsize(name, 60).trim()
|
||||||
if (this.event) return displayPubkey(this.event.pubkey).trim()
|
|
||||||
|
|
||||||
return fallback.trim()
|
return displayPubkey(this.event.pubkey).trim() || fallback.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
async toTemplate(): Promise<EventTemplate> {
|
builder() {
|
||||||
return {
|
const builder = new ProfileBuilder()
|
||||||
kind: this.kind,
|
|
||||||
content: JSON.stringify(this.values),
|
builder.values = makeProfileValues(this.plain)
|
||||||
tags: this.event?.tags || [],
|
|
||||||
}
|
return this.seedBuilder(builder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write side for a NIP-01 kind-0 profile. Holds the profile fields and serializes
|
||||||
|
// them to JSON content; emits no profile-specific tags.
|
||||||
|
export class ProfileBuilder extends EventBuilder<ProfileValues> {
|
||||||
|
static kind = PROFILE
|
||||||
|
|
||||||
|
values: ProfileValues = makeProfileValues()
|
||||||
|
|
||||||
|
setValues(values: Partial<ProfileValues>) {
|
||||||
|
this.values = makeProfileValues(values)
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setName(name: string) {
|
||||||
|
this.values = makeProfileValues({...this.values, name})
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setNip05(nip05: string) {
|
||||||
|
this.values = makeProfileValues({...this.values, nip05})
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setAbout(about: string) {
|
||||||
|
this.values = makeProfileValues({...this.values, about})
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setBanner(banner: string) {
|
||||||
|
this.values = makeProfileValues({...this.values, banner})
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setPicture(picture: string) {
|
||||||
|
this.values = makeProfileValues({...this.values, picture})
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setWebsite(website: string) {
|
||||||
|
this.values = makeProfileValues({...this.values, website})
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildTags() {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildContent(_signer?: ISigner) {
|
||||||
|
return JSON.stringify(this.values)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +1,46 @@
|
|||||||
import {RELAY_INVITE, getTagValue} from "@welshman/util"
|
import {RELAY_INVITE, getTagValue} from "@welshman/util"
|
||||||
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
import {EventReader, EventBuilder} from "./base.js"
|
||||||
import {DomainObject} from "./base.js"
|
|
||||||
|
|
||||||
export type RelayInviteValues = {
|
|
||||||
claim?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeRelayInviteValues = (
|
|
||||||
values: Partial<RelayInviteValues> = {},
|
|
||||||
): RelayInviteValues => ({
|
|
||||||
...values,
|
|
||||||
})
|
|
||||||
|
|
||||||
// NIP-29 kind-28935 ephemeral relay invite event. Its "claim" tag carries the
|
// NIP-29 kind-28935 ephemeral relay invite event. Its "claim" tag carries the
|
||||||
// invite code, which flotilla turns into a /join?r=&c= link. Flotilla only reads
|
// invite code, which flotilla turns into a /join?r=&c= link. Flotilla only reads
|
||||||
// this event (see app/relays.ts requestRelayClaim), so `claim` is the sole field.
|
// this event (see app/relays.ts requestRelayClaim), so `claim` is the sole field.
|
||||||
// Tags-only content, so it extends DomainObject directly.
|
// Tags-only content, so it extends EventReader/EventBuilder directly.
|
||||||
export class RelayInvite extends DomainObject<RelayInviteValues> {
|
export class RelayInvite extends EventReader {
|
||||||
readonly kind = RELAY_INVITE
|
static kind = RELAY_INVITE
|
||||||
values = makeRelayInviteValues()
|
|
||||||
|
|
||||||
protected normalizeValues(values: Partial<RelayInviteValues> = {}) {
|
protected reservedTagKeys() {
|
||||||
return makeRelayInviteValues(values)
|
return ["claim"]
|
||||||
}
|
|
||||||
|
|
||||||
protected parseEvent(event: TrustedEvent): Partial<RelayInviteValues> {
|
|
||||||
return {
|
|
||||||
claim: getTagValue("claim", event.tags),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
claim() {
|
claim() {
|
||||||
return this.values.claim
|
return getTagValue("claim", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
async toTemplate(): Promise<EventTemplate> {
|
builder() {
|
||||||
return {
|
const builder = new RelayInviteBuilder()
|
||||||
kind: this.kind,
|
|
||||||
tags: this.values.claim ? [["claim", this.values.claim]] : [],
|
builder.claim = this.claim()
|
||||||
content: "",
|
|
||||||
}
|
return this.seedBuilder(builder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RelayInviteBuilder extends EventBuilder {
|
||||||
|
static kind = RELAY_INVITE
|
||||||
|
|
||||||
|
claim?: string
|
||||||
|
|
||||||
|
setClaim(claim: string) {
|
||||||
|
this.claim = claim
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildTags() {
|
||||||
|
const tags: string[][] = []
|
||||||
|
|
||||||
|
if (this.claim) tags.push(["claim", this.claim])
|
||||||
|
|
||||||
|
return tags
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,55 +1,70 @@
|
|||||||
import {RELAY_JOIN, getTagValue} from "@welshman/util"
|
import {RELAY_JOIN, getTagValue} from "@welshman/util"
|
||||||
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
import type {ISigner} from "@welshman/signer"
|
||||||
import {DomainObject} from "./base.js"
|
import {EventReader, EventBuilder} from "./base.js"
|
||||||
|
|
||||||
export type RelayJoinValues = {
|
|
||||||
claim?: string
|
|
||||||
reason?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeRelayJoinValues = (values: Partial<RelayJoinValues> = {}): RelayJoinValues => ({
|
|
||||||
...values,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Ephemeral kind-28934 relay/space join request. Both written (the join flow)
|
// Ephemeral kind-28934 relay/space join request. Both written (the join flow)
|
||||||
// and read (membership status): it carries an optional invite "claim" tag and a
|
// and read (membership status): it carries an optional invite "claim" tag and a
|
||||||
// free-text reason in the event content, driving the space membership state
|
// free-text reason in the event content, driving the space membership state
|
||||||
// machine (RELAY_JOIN -> Pending/Granted). Tags-plus-content, so it extends
|
// machine (RELAY_JOIN -> Pending/Granted). The content is the plain free-text
|
||||||
// DomainObject directly.
|
// reason, so `plain` is the (possibly undefined) reason string.
|
||||||
export class RelayJoin extends DomainObject<RelayJoinValues> {
|
export class RelayJoin extends EventReader<string | undefined> {
|
||||||
readonly kind = RELAY_JOIN
|
static kind = RELAY_JOIN
|
||||||
values = makeRelayJoinValues()
|
|
||||||
|
|
||||||
protected normalizeValues(values: Partial<RelayJoinValues> = {}) {
|
protected async parsePlain() {
|
||||||
return makeRelayJoinValues(values)
|
return this.event.content || undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
protected parseEvent(event: TrustedEvent): Partial<RelayJoinValues> {
|
protected reservedTagKeys() {
|
||||||
return {
|
return ["claim"]
|
||||||
claim: getTagValue("claim", event.tags),
|
|
||||||
reason: event.content || undefined,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
claim() {
|
claim() {
|
||||||
return this.values.claim
|
return getTagValue("claim", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
reason() {
|
reason() {
|
||||||
return this.values.reason
|
return this.plain
|
||||||
}
|
}
|
||||||
|
|
||||||
async toTemplate(): Promise<EventTemplate> {
|
builder() {
|
||||||
const tags: string[][] = []
|
const builder = new RelayJoinBuilder()
|
||||||
|
|
||||||
if (this.values.claim) {
|
builder.claim = this.claim()
|
||||||
tags.push(["claim", this.values.claim])
|
builder.reason = this.reason()
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
builder.plain = this.plain
|
||||||
kind: this.kind,
|
|
||||||
tags,
|
return this.seedBuilder(builder)
|
||||||
content: this.values.reason || "",
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class RelayJoinBuilder extends EventBuilder<string | undefined> {
|
||||||
|
static kind = RELAY_JOIN
|
||||||
|
|
||||||
|
claim?: string
|
||||||
|
reason?: string
|
||||||
|
|
||||||
|
setClaim(claim: string) {
|
||||||
|
this.claim = claim
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setReason(reason: string) {
|
||||||
|
this.reason = reason
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildTags() {
|
||||||
|
const tags: string[][] = []
|
||||||
|
|
||||||
|
if (this.claim) tags.push(["claim", this.claim])
|
||||||
|
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildContent(_signer?: ISigner) {
|
||||||
|
return this.reason || ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,22 @@
|
|||||||
import {RELAY_LEAVE} from "@welshman/util"
|
import {RELAY_LEAVE} from "@welshman/util"
|
||||||
import type {EventTemplate} from "@welshman/util"
|
import {EventReader, EventBuilder} from "./base.js"
|
||||||
import {DomainObject} from "./base.js"
|
|
||||||
|
|
||||||
export type RelayLeaveValues = {}
|
|
||||||
|
|
||||||
export const makeRelayLeaveValues = (values: Partial<RelayLeaveValues> = {}): RelayLeaveValues => ({
|
|
||||||
...values,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Ephemeral kind-28936 relay/space leave marker, the counterpart to RelayJoin.
|
// Ephemeral kind-28936 relay/space leave marker, the counterpart to RelayJoin.
|
||||||
// Carries no tags and no content; flotilla both emits it (the leave flow) and
|
// Carries no tags and no content; flotilla both emits it (the leave flow) and
|
||||||
// consumes it to reset the space membership state machine (RELAY_LEAVE ->
|
// consumes it to reset the space membership state machine (RELAY_LEAVE ->
|
||||||
// Initial). State-free, so it extends DomainObject directly.
|
// Initial). Tags-only (in fact tag-free) content.
|
||||||
export class RelayLeave extends DomainObject<RelayLeaveValues> {
|
export class RelayLeave extends EventReader {
|
||||||
readonly kind = RELAY_LEAVE
|
static kind = RELAY_LEAVE
|
||||||
values = makeRelayLeaveValues()
|
|
||||||
|
|
||||||
protected normalizeValues(values: Partial<RelayLeaveValues> = {}) {
|
builder() {
|
||||||
return makeRelayLeaveValues(values)
|
return this.seedBuilder(new RelayLeaveBuilder())
|
||||||
}
|
}
|
||||||
|
}
|
||||||
protected parseEvent(): Partial<RelayLeaveValues> {
|
|
||||||
return {}
|
export class RelayLeaveBuilder extends EventBuilder {
|
||||||
}
|
static kind = RELAY_LEAVE
|
||||||
|
|
||||||
async toTemplate(): Promise<EventTemplate> {
|
protected buildTags() {
|
||||||
return {
|
return [] as string[][]
|
||||||
kind: this.kind,
|
|
||||||
tags: [],
|
|
||||||
content: "",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import {uniq, uniqBy} from "@welshman/lib"
|
import {uniq, uniqBy} from "@welshman/lib"
|
||||||
import {RELAYS, RelayMode, getRelayTags, getRelayTagValues, normalizeRelayUrl} from "@welshman/util"
|
import {RELAYS, RelayMode, getRelayTags, getRelayTagValues, normalizeRelayUrl} from "@welshman/util"
|
||||||
import {EncryptableList} from "./List.js"
|
import {ListReader, ListBuilder} from "./List.js"
|
||||||
|
|
||||||
// NIP-65 kind-10002 relay list (the outbox-model routing substrate). Entries are
|
// NIP-65 kind-10002 relay list (the outbox-model routing substrate). Entries are
|
||||||
// `["r", url, mode?]` tags where `mode` is RelayMode.Read or RelayMode.Write; a
|
// `["r", url, mode?]` tags where `mode` is RelayMode.Read or RelayMode.Write; a
|
||||||
// missing marker means the relay is used for both read and write. NIP-65 entries
|
// missing marker means the relay is used for both read and write. NIP-65 entries
|
||||||
// are public in practice, so mutations target the public tag set.
|
// are public in practice, so mutations target the public tag set.
|
||||||
export class RelayList extends EncryptableList {
|
export class RelayList extends ListReader {
|
||||||
readonly kind = RELAYS
|
static kind = RELAYS
|
||||||
|
|
||||||
// All relay urls, deduped by normalized url.
|
// All relay urls, deduped by normalized url.
|
||||||
urls() {
|
urls() {
|
||||||
@@ -34,26 +34,56 @@ export class RelayList extends EncryptableList {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
builder() {
|
||||||
|
return this.seedList(new RelayListBuilder())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RelayListBuilder extends ListBuilder {
|
||||||
|
static kind = RELAYS
|
||||||
|
|
||||||
|
// Relays usable for reading: includes modeless (both) entries.
|
||||||
|
readUrls() {
|
||||||
|
return uniqBy(
|
||||||
|
normalizeRelayUrl,
|
||||||
|
getRelayTags(this.publicTags)
|
||||||
|
.filter(t => !t[2] || t[2] === RelayMode.Read)
|
||||||
|
.map(t => t[1]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relays usable for writing: includes modeless (both) entries.
|
||||||
|
writeUrls() {
|
||||||
|
return uniqBy(
|
||||||
|
normalizeRelayUrl,
|
||||||
|
getRelayTags(this.publicTags)
|
||||||
|
.filter(t => !t[2] || t[2] === RelayMode.Write)
|
||||||
|
.map(t => t[1]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Upsert a relay for a given mode. If an existing entry already covered the
|
// Upsert a relay for a given mode. If an existing entry already covered the
|
||||||
// complementary mode (or was modeless), collapse to a modeless ["r", url] tag;
|
// complementary mode (or was modeless), collapse to a modeless ["r", url] tag;
|
||||||
// otherwise store ["r", url, mode].
|
// otherwise store ["r", url, mode].
|
||||||
addRelay(url: string, mode: RelayMode) {
|
addRelay(url: string, mode: RelayMode) {
|
||||||
const normalized = normalizeRelayUrl(url)
|
const normalized = normalizeRelayUrl(url)
|
||||||
const existing = getRelayTags(this.values.publicTags).filter(
|
const existing = getRelayTags(this.publicTags).filter(
|
||||||
t => normalizeRelayUrl(t[1]) === normalized,
|
t => normalizeRelayUrl(t[1]) === normalized,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Modes already covered by existing entries (undefined marker = both).
|
// Modes already covered by existing entries (undefined marker = both).
|
||||||
const priorModes = new Set<RelayMode | undefined>(existing.map(t => t[2] as RelayMode | undefined))
|
const priorModes = new Set<RelayMode | undefined>(
|
||||||
|
existing.map(t => t[2] as RelayMode | undefined),
|
||||||
|
)
|
||||||
|
|
||||||
const alt = mode === RelayMode.Read ? RelayMode.Write : RelayMode.Read
|
const alt = mode === RelayMode.Read ? RelayMode.Write : RelayMode.Read
|
||||||
const coversAlt = priorModes.has(undefined) || priorModes.has(alt)
|
const coversAlt = priorModes.has(undefined) || priorModes.has(alt)
|
||||||
|
|
||||||
this.values.publicTags = this.values.publicTags.filter(
|
this.publicTags = this.publicTags.filter(
|
||||||
t => !(t[0] === "r" && normalizeRelayUrl(t[1]) === normalized),
|
t => !(t[0] === "r" && normalizeRelayUrl(t[1]) === normalized),
|
||||||
)
|
)
|
||||||
|
|
||||||
this.values.publicTags.push(coversAlt ? ["r", url] : ["r", url, mode])
|
this.publicTags.push(coversAlt ? ["r", url] : ["r", url, mode])
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
@@ -63,7 +93,7 @@ export class RelayList extends EncryptableList {
|
|||||||
// covered `mode` is fully removed.
|
// covered `mode` is fully removed.
|
||||||
removeRelay(url: string, mode: RelayMode) {
|
removeRelay(url: string, mode: RelayMode) {
|
||||||
const normalized = normalizeRelayUrl(url)
|
const normalized = normalizeRelayUrl(url)
|
||||||
const existing = getRelayTags(this.values.publicTags).filter(
|
const existing = getRelayTags(this.publicTags).filter(
|
||||||
t => normalizeRelayUrl(t[1]) === normalized,
|
t => normalizeRelayUrl(t[1]) === normalized,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -72,12 +102,12 @@ export class RelayList extends EncryptableList {
|
|||||||
// Keep the alternate if any existing entry was modeless/both or the alt mode.
|
// Keep the alternate if any existing entry was modeless/both or the alt mode.
|
||||||
const keepAlt = existing.some(t => !t[2] || t[2] === alt)
|
const keepAlt = existing.some(t => !t[2] || t[2] === alt)
|
||||||
|
|
||||||
this.values.publicTags = this.values.publicTags.filter(
|
this.publicTags = this.publicTags.filter(
|
||||||
t => !(t[0] === "r" && normalizeRelayUrl(t[1]) === normalized),
|
t => !(t[0] === "r" && normalizeRelayUrl(t[1]) === normalized),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (keepAlt) {
|
if (keepAlt) {
|
||||||
this.values.publicTags.push(["r", url, alt])
|
this.publicTags.push(["r", url, alt])
|
||||||
}
|
}
|
||||||
|
|
||||||
return this
|
return this
|
||||||
@@ -101,7 +131,7 @@ export class RelayList extends EncryptableList {
|
|||||||
private setRelaysForModes(readUrls: string[], writeUrls: string[]) {
|
private setRelaysForModes(readUrls: string[], writeUrls: string[]) {
|
||||||
const read = new Set(readUrls.map(normalizeRelayUrl))
|
const read = new Set(readUrls.map(normalizeRelayUrl))
|
||||||
const write = new Set(writeUrls.map(normalizeRelayUrl))
|
const write = new Set(writeUrls.map(normalizeRelayUrl))
|
||||||
const otherTags = this.values.publicTags.filter(t => t[0] !== "r")
|
const otherTags = this.publicTags.filter(t => t[0] !== "r")
|
||||||
const relayTags = uniq([...read, ...write]).map(url =>
|
const relayTags = uniq([...read, ...write]).map(url =>
|
||||||
read.has(url) && write.has(url)
|
read.has(url) && write.has(url)
|
||||||
? ["r", url]
|
? ["r", url]
|
||||||
@@ -110,14 +140,14 @@ export class RelayList extends EncryptableList {
|
|||||||
: ["r", url, RelayMode.Write],
|
: ["r", url, RelayMode.Write],
|
||||||
)
|
)
|
||||||
|
|
||||||
this.values.publicTags = [...otherTags, ...relayTags]
|
this.publicTags = [...otherTags, ...relayTags]
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace the entire public tag set.
|
// Replace the entire public tag set.
|
||||||
setRelays(tags: string[][]) {
|
setRelays(tags: string[][]) {
|
||||||
this.values.publicTags = tags
|
this.publicTags = tags
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +1,52 @@
|
|||||||
import {uniq} from "@welshman/lib"
|
import {uniq} from "@welshman/lib"
|
||||||
import {RELAY_MEMBERS, getTagValues} from "@welshman/util"
|
import {RELAY_MEMBERS, getPubkeyTagValues} from "@welshman/util"
|
||||||
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
import {EventReader, EventBuilder} from "./base.js"
|
||||||
import {DomainObject} from "./base.js"
|
|
||||||
|
|
||||||
export type RelayMembersValues = {
|
|
||||||
members: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeRelayMembersValues = (
|
|
||||||
values: Partial<RelayMembersValues> = {},
|
|
||||||
): RelayMembersValues => ({
|
|
||||||
members: [],
|
|
||||||
...values,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Flotilla relay-wide (space) member-list snapshot, replaceable kind 13534.
|
// Flotilla relay-wide (space) member-list snapshot, replaceable kind 13534.
|
||||||
// Members are stored under "member" tags (tag[0] === "member"), NOT "p" tags,
|
// Members are stored as "p" tags. Not addressable (no "d" tag); tags-only
|
||||||
// so parsing uses getTagValues("member", ...) rather than getPubkeyTagValues.
|
// content, so it extends EventReader/EventBuilder directly.
|
||||||
// Not addressable (no "d" tag); tags-only content, so it extends DomainObject
|
export class RelayMembers extends EventReader {
|
||||||
// directly rather than the encryptable list base.
|
static kind = RELAY_MEMBERS
|
||||||
export class RelayMembers extends DomainObject<RelayMembersValues> {
|
|
||||||
readonly kind = RELAY_MEMBERS
|
|
||||||
values = makeRelayMembersValues()
|
|
||||||
|
|
||||||
protected normalizeValues(values: Partial<RelayMembersValues> = {}) {
|
protected reservedTagKeys() {
|
||||||
return makeRelayMembersValues(values)
|
return ["p"]
|
||||||
}
|
}
|
||||||
|
|
||||||
protected parseEvent(event: TrustedEvent): Partial<RelayMembersValues> {
|
pubkeys() {
|
||||||
return {
|
return uniq(getPubkeyTagValues(this.event.tags))
|
||||||
members: uniq(getTagValues("member", event.tags)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
members() {
|
|
||||||
return this.values.members
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isMember(pubkey: string) {
|
isMember(pubkey: string) {
|
||||||
return this.values.members.includes(pubkey)
|
return this.pubkeys().includes(pubkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
addMember(pubkey: string) {
|
builder() {
|
||||||
this.values.members = uniq([...this.values.members, pubkey])
|
const builder = new RelayMembersBuilder()
|
||||||
|
|
||||||
return this
|
builder.pubkeys = this.pubkeys()
|
||||||
}
|
|
||||||
|
|
||||||
removeMember(pubkey: string) {
|
return this.seedBuilder(builder)
|
||||||
this.values.members = this.values.members.filter(pk => pk !== pubkey)
|
}
|
||||||
|
}
|
||||||
return this
|
|
||||||
}
|
export class RelayMembersBuilder extends EventBuilder {
|
||||||
|
static kind = RELAY_MEMBERS
|
||||||
async toTemplate(): Promise<EventTemplate> {
|
|
||||||
const tags: string[][] = this.values.members.map(pk => ["member", pk])
|
pubkeys: string[] = []
|
||||||
|
|
||||||
return {kind: this.kind, tags, content: ""}
|
addPubkey(pubkey: string) {
|
||||||
|
this.pubkeys = uniq([...this.pubkeys, pubkey])
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
removePubkey(pubkey: string) {
|
||||||
|
this.pubkeys = this.pubkeys.filter(pk => pk !== pubkey)
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildTags() {
|
||||||
|
return this.pubkeys.map(pk => ["p", pk])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +1,72 @@
|
|||||||
import {uniq} from "@welshman/lib"
|
import {uniq} from "@welshman/lib"
|
||||||
import {RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, getPubkeyTagValues} from "@welshman/util"
|
import {RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, getPubkeyTagValues} from "@welshman/util"
|
||||||
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
import {EventReader, EventBuilder} from "./base.js"
|
||||||
import {DomainObject} from "./base.js"
|
|
||||||
|
|
||||||
export type RelayMembershipValues = {
|
// Relay/space-level moderation op for adding (kind 8000) or removing (kind 8001)
|
||||||
pubkeys: string[]
|
// members. Regular (non-addressable) events carrying the affected pubkeys in "p"
|
||||||
}
|
// tags. Unlike RoomMembershipOp these are relay-scoped, not room-scoped, so there
|
||||||
|
// is no group ("h") tag — just the "p" tags. Add and remove share this shape;
|
||||||
export const makeRelayMembershipValues = (
|
// each is its own concrete reader/builder fixing the kind via a static field.
|
||||||
values: Partial<RelayMembershipValues> = {},
|
|
||||||
): RelayMembershipValues => ({
|
|
||||||
pubkeys: [],
|
|
||||||
...values,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Relay/space-level moderation op carrying the affected pubkeys in "p" tags. Add
|
|
||||||
// (kind 8000) and remove (kind 8001) are regular (non-addressable) events that
|
|
||||||
// share this shape; each is its own concrete class fixing the kind.
|
|
||||||
//
|
//
|
||||||
// Flotilla's deriveUserSpaceMembershipStatus replays this history (RelayAddMember
|
// Flotilla's deriveUserSpaceMembershipStatus replays this history (RelayAddMember
|
||||||
// => isMember true, RelayRemoveMember => isMember false) when no RELAY_MEMBERS
|
// => isMember true, RelayRemoveMember => isMember false) when no RELAY_MEMBERS
|
||||||
// snapshot is available.
|
// snapshot is available.
|
||||||
export abstract class RelayMembershipOp extends DomainObject<RelayMembershipValues> {
|
export abstract class RelayMembershipOp extends EventReader {
|
||||||
values = makeRelayMembershipValues()
|
protected reservedTagKeys() {
|
||||||
|
return ["p"]
|
||||||
protected normalizeValues(values: Partial<RelayMembershipValues> = {}) {
|
|
||||||
return makeRelayMembershipValues(values)
|
|
||||||
}
|
|
||||||
|
|
||||||
protected parseEvent(event: TrustedEvent): Partial<RelayMembershipValues> {
|
|
||||||
return {pubkeys: uniq(getPubkeyTagValues(event.tags))}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The affected pubkeys, deduped.
|
||||||
pubkeys() {
|
pubkeys() {
|
||||||
return this.values.pubkeys
|
return uniq(getPubkeyTagValues(this.event.tags))
|
||||||
}
|
}
|
||||||
|
|
||||||
async toTemplate(): Promise<EventTemplate> {
|
abstract builder(): RelayMembershipOpBuilder
|
||||||
return {
|
}
|
||||||
kind: this.kind,
|
|
||||||
tags: this.values.pubkeys.map(pk => ["p", pk]),
|
// Shared write side: collect pubkeys, emit them as "p" tags.
|
||||||
content: "",
|
export abstract class RelayMembershipOpBuilder extends EventBuilder {
|
||||||
}
|
pubkeys: string[] = []
|
||||||
|
|
||||||
|
addPubkey(pubkey: string) {
|
||||||
|
this.pubkeys = uniq([...this.pubkeys, pubkey])
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildTags() {
|
||||||
|
return this.pubkeys.map(pk => ["p", pk])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RelayAddMember extends RelayMembershipOp {
|
export class RelayAddMember extends RelayMembershipOp {
|
||||||
readonly kind = RELAY_ADD_MEMBER
|
static kind = RELAY_ADD_MEMBER
|
||||||
|
|
||||||
|
builder() {
|
||||||
|
const builder = new RelayAddMemberBuilder()
|
||||||
|
|
||||||
|
builder.pubkeys = this.pubkeys()
|
||||||
|
|
||||||
|
return this.seedBuilder(builder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RelayAddMemberBuilder extends RelayMembershipOpBuilder {
|
||||||
|
static kind = RELAY_ADD_MEMBER
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RelayRemoveMember extends RelayMembershipOp {
|
export class RelayRemoveMember extends RelayMembershipOp {
|
||||||
readonly kind = RELAY_REMOVE_MEMBER
|
static kind = RELAY_REMOVE_MEMBER
|
||||||
|
|
||||||
|
builder() {
|
||||||
|
const builder = new RelayRemoveMemberBuilder()
|
||||||
|
|
||||||
|
builder.pubkeys = this.pubkeys()
|
||||||
|
|
||||||
|
return this.seedBuilder(builder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RelayRemoveMemberBuilder extends RelayMembershipOpBuilder {
|
||||||
|
static kind = RELAY_REMOVE_MEMBER
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,94 @@
|
|||||||
import {uniqBy} from "@welshman/lib"
|
import {randomId, uniqBy} from "@welshman/lib"
|
||||||
import {NAMED_RELAYS, getTagValue, getTagValues, normalizeRelayUrl} from "@welshman/util"
|
import {NAMED_RELAYS, getTagValue, getTagValues, normalizeRelayUrl} from "@welshman/util"
|
||||||
import {EncryptableList} from "./List.js"
|
import {ListReader, ListBuilder} from "./List.js"
|
||||||
|
|
||||||
// NIP-51 kind-30002 relay set: an addressable, named collection of relays
|
// NIP-51 kind-30002 relay set: an addressable, named collection of relays
|
||||||
// identified by its `d` tag. Entries are marker-less ['relay', url] tags (like
|
// identified by its `d` tag. Entries are marker-less ['relay', url] tags (like
|
||||||
// the other NIP-51 relay lists, NOT NIP-65 'r' tags with read/write markers).
|
// the other NIP-51 relay lists, NOT NIP-65 'r' tags with read/write markers).
|
||||||
// It also carries optional set metadata (title/description/image) used to label
|
// It also carries optional set metadata (title/description/image) used to label
|
||||||
// the set in UIs.
|
// the set in UIs.
|
||||||
export class RelaySet extends EncryptableList {
|
export class RelaySet extends ListReader {
|
||||||
readonly kind = NAMED_RELAYS
|
static kind = NAMED_RELAYS
|
||||||
|
|
||||||
identifier() {
|
protected validate() {
|
||||||
return getTagValue("d", this.tags()) || ""
|
if (!this.identifier()) {
|
||||||
|
throw new Error("RelaySet requires a d tag")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected reservedTagKeys() {
|
||||||
|
return ["d", "title", "description", "image", "relay"]
|
||||||
}
|
}
|
||||||
|
|
||||||
title() {
|
title() {
|
||||||
return getTagValue("title", this.tags())
|
return getTagValue("title", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
description() {
|
description() {
|
||||||
return getTagValue("description", this.tags())
|
return getTagValue("description", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
image() {
|
image() {
|
||||||
return getTagValue("image", this.tags())
|
return getTagValue("image", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
urls() {
|
urls() {
|
||||||
return uniqBy(normalizeRelayUrl, getTagValues("relay", this.tags()))
|
return uniqBy(normalizeRelayUrl, getTagValues("relay", this.tags()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
builder() {
|
||||||
|
const builder = new RelaySetBuilder()
|
||||||
|
|
||||||
|
builder.identifier = this.identifier() || ""
|
||||||
|
builder.title = this.title()
|
||||||
|
builder.description = this.description()
|
||||||
|
builder.image = this.image()
|
||||||
|
|
||||||
|
this.seedList(builder)
|
||||||
|
|
||||||
|
// The d/title/description/image tags are re-emitted from the dedicated
|
||||||
|
// fields above, so drop them from the carried-over public entries to avoid
|
||||||
|
// duplication. The marker-less relay entries stay as public list tags.
|
||||||
|
builder.publicTags = builder.publicTags.filter(
|
||||||
|
t => !["d", "title", "description", "image"].includes(t[0]),
|
||||||
|
)
|
||||||
|
|
||||||
|
return builder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RelaySetBuilder extends ListBuilder {
|
||||||
|
static kind = NAMED_RELAYS
|
||||||
|
|
||||||
|
identifier = randomId()
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
image?: string
|
||||||
|
|
||||||
|
setIdentifier(identifier: string) {
|
||||||
|
this.identifier = identifier
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setTitle(title: string) {
|
||||||
|
this.title = title
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setDescription(description: string) {
|
||||||
|
this.description = description
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setImage(image: string) {
|
||||||
|
this.image = image
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
addRelay(url: string) {
|
addRelay(url: string) {
|
||||||
return this.addPublicTags(["relay", normalizeRelayUrl(url)])
|
return this.addPublicTags(["relay", normalizeRelayUrl(url)])
|
||||||
}
|
}
|
||||||
@@ -45,27 +104,21 @@ export class RelaySet extends EncryptableList {
|
|||||||
return this.addPublicTags(...urls.map(url => ["relay", normalizeRelayUrl(url)]))
|
return this.addPublicTags(...urls.map(url => ["relay", normalizeRelayUrl(url)]))
|
||||||
}
|
}
|
||||||
|
|
||||||
setIdentifier(identifier: string) {
|
protected validate() {
|
||||||
this.removeTagsWithKey("d")
|
if (!this.identifier) {
|
||||||
|
throw new Error("RelaySet requires a d identifier")
|
||||||
return this.addPublicTags(["d", identifier])
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setTitle(title: string) {
|
protected buildTags() {
|
||||||
this.removeTagsWithKey("title")
|
const tags: string[][] = [["d", this.identifier]]
|
||||||
|
|
||||||
return this.addPublicTags(["title", title])
|
if (this.title) tags.push(["title", this.title])
|
||||||
}
|
if (this.description) tags.push(["description", this.description])
|
||||||
|
if (this.image) tags.push(["image", this.image])
|
||||||
|
|
||||||
setDescription(description: string) {
|
// Append the public list entries (relay tags); the base re-encrypts the
|
||||||
this.removeTagsWithKey("description")
|
// private tags into content separately.
|
||||||
|
return [...tags, ...this.publicTags]
|
||||||
return this.addPublicTags(["description", description])
|
|
||||||
}
|
|
||||||
|
|
||||||
setImage(image: string) {
|
|
||||||
this.removeTagsWithKey("image")
|
|
||||||
|
|
||||||
return this.addPublicTags(["image", image])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,79 +1,99 @@
|
|||||||
import {REPORT, getTag, getTagValue} from "@welshman/util"
|
import {REPORT, getTag, getTagValue} from "@welshman/util"
|
||||||
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
import {EventReader, EventBuilder} from "./base.js"
|
||||||
import {DomainObject} from "./base.js"
|
|
||||||
|
|
||||||
export type ReportValues = {
|
|
||||||
pubkey?: string
|
|
||||||
eventId?: string
|
|
||||||
reason?: string
|
|
||||||
content: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeReportValues = (values: Partial<ReportValues> = {}): ReportValues => ({
|
|
||||||
content: "",
|
|
||||||
...values,
|
|
||||||
})
|
|
||||||
|
|
||||||
// NIP-56 kind-1984 report, feeding flotilla's admin action-items / moderation
|
// NIP-56 kind-1984 report, feeding flotilla's admin action-items / moderation
|
||||||
// review queue (see app/actionItems.ts `deriveSpaceActionItems`). The reported
|
// review queue (see app/actionItems.ts `deriveSpaceActionItems`). The reported
|
||||||
// author is named in the "p" tag and the reported event in the "e" tag, with the
|
// author is named in the "p" tag and the reported event in the "e" tag, with the
|
||||||
// report reason carried as the 3rd element of the "e" tag (NOT a separate tag).
|
// report reason carried as the 3rd element of the "e" tag (NOT a separate tag).
|
||||||
// Flotilla destructures this by hand in ReactionSummary.svelte and
|
// Flotilla destructures this by hand in ReactionSummary.svelte and
|
||||||
// ReportMenu.svelte; `reason()` centralizes that access. The report body lives in
|
// ReportMenu.svelte; the accessors centralize that access. The report body lives
|
||||||
// `content` as plain text (not JSON).
|
// in `content` as plain text (not JSON), so there's no `plain` representation.
|
||||||
export class Report extends DomainObject<ReportValues> {
|
export class Report extends EventReader {
|
||||||
readonly kind = REPORT
|
static kind = REPORT
|
||||||
values = makeReportValues()
|
|
||||||
|
|
||||||
protected normalizeValues(values: Partial<ReportValues> = {}) {
|
protected reservedTagKeys() {
|
||||||
return makeReportValues(values)
|
return ["p", "e"]
|
||||||
}
|
}
|
||||||
|
|
||||||
protected parseEvent(event: TrustedEvent): Partial<ReportValues> {
|
// The reported author. Distinct from the base `pubkey()` (the reporter).
|
||||||
const eTag = getTag("e", event.tags)
|
reportedPubkey() {
|
||||||
|
return getTagValue("p", this.event.tags)
|
||||||
return {
|
|
||||||
pubkey: getTagValue("p", event.tags),
|
|
||||||
eventId: eTag?.[1],
|
|
||||||
reason: eTag?.[2],
|
|
||||||
content: event.content || "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pubkey() {
|
|
||||||
return this.values.pubkey
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The reported event, if any.
|
||||||
eventId() {
|
eventId() {
|
||||||
return this.values.eventId
|
return getTag("e", this.event.tags)?.[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The report reason, carried as the 3rd element of the "e" tag.
|
||||||
reason() {
|
reason() {
|
||||||
return this.values.reason
|
return getTag("e", this.event.tags)?.[2]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The report body, plain text.
|
||||||
content() {
|
content() {
|
||||||
return this.values.content
|
return this.event.content || ""
|
||||||
}
|
}
|
||||||
|
|
||||||
setContent(content: string) {
|
builder() {
|
||||||
this.values.content = content
|
const builder = new ReportBuilder()
|
||||||
|
|
||||||
|
builder.reportedPubkey = this.reportedPubkey()
|
||||||
|
builder.eventId = this.eventId()
|
||||||
|
builder.reason = this.reason()
|
||||||
|
builder.content = this.content()
|
||||||
|
|
||||||
|
return this.seedBuilder(builder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReportBuilder extends EventBuilder {
|
||||||
|
static kind = REPORT
|
||||||
|
|
||||||
|
reportedPubkey?: string
|
||||||
|
eventId?: string
|
||||||
|
reason?: string
|
||||||
|
content = ""
|
||||||
|
|
||||||
|
setReportedPubkey(reportedPubkey: string) {
|
||||||
|
this.reportedPubkey = reportedPubkey
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
async toTemplate(): Promise<EventTemplate> {
|
setEventId(eventId: string) {
|
||||||
const {pubkey, eventId, reason, content} = this.values
|
this.eventId = eventId
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setReason(reason: string) {
|
||||||
|
this.reason = reason
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent(content: string) {
|
||||||
|
this.content = content
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildTags() {
|
||||||
const tags: string[][] = []
|
const tags: string[][] = []
|
||||||
|
|
||||||
if (pubkey) {
|
if (this.reportedPubkey) {
|
||||||
tags.push(["p", pubkey])
|
tags.push(["p", this.reportedPubkey])
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eventId) {
|
if (this.eventId) {
|
||||||
tags.push(["e", eventId, ...(reason ? [reason] : [])])
|
tags.push(["e", this.eventId, ...(this.reason ? [this.reason] : [])])
|
||||||
}
|
}
|
||||||
|
|
||||||
return {kind: this.kind, content, tags}
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildContent() {
|
||||||
|
return this.content
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,71 +1,68 @@
|
|||||||
import {uniq} from "@welshman/lib"
|
import {randomId, uniq} from "@welshman/lib"
|
||||||
import {ROOM_ADMINS, getIdentifier, getPubkeyTagValues} from "@welshman/util"
|
import {ROOM_ADMINS, getPubkeyTagValues} from "@welshman/util"
|
||||||
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
import {EventReader, EventBuilder} from "./base.js"
|
||||||
import {DomainObject} from "./base.js"
|
|
||||||
|
|
||||||
export type RoomAdminsValues = {
|
|
||||||
h: string
|
|
||||||
admins: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeRoomAdminsValues = (
|
|
||||||
values: Partial<RoomAdminsValues> = {},
|
|
||||||
): RoomAdminsValues => ({
|
|
||||||
h: "",
|
|
||||||
admins: [],
|
|
||||||
...values,
|
|
||||||
})
|
|
||||||
|
|
||||||
// NIP-29 kind-39001 relay-generated room admin list. Addressable, with the group
|
// NIP-29 kind-39001 relay-generated room admin list. Addressable, with the group
|
||||||
// id ("h") stored in the "d" tag and admins as "p" tags. Tags-only content, so it
|
// id ("h") stored in the "d" tag and admins as "p" tags. Tags-only content.
|
||||||
// extends DomainObject directly rather than the encryptable list base.
|
export class RoomAdmins extends EventReader {
|
||||||
export class RoomAdmins extends DomainObject<RoomAdminsValues> {
|
static kind = ROOM_ADMINS
|
||||||
readonly kind = ROOM_ADMINS
|
|
||||||
values = makeRoomAdminsValues()
|
|
||||||
|
|
||||||
protected normalizeValues(values: Partial<RoomAdminsValues> = {}) {
|
protected validate() {
|
||||||
return makeRoomAdminsValues(values)
|
if (!this.identifier()) {
|
||||||
}
|
throw new Error("RoomAdmins requires a d tag")
|
||||||
|
|
||||||
protected parseEvent(event: TrustedEvent): Partial<RoomAdminsValues> {
|
|
||||||
return {
|
|
||||||
h: getIdentifier(event) || "",
|
|
||||||
admins: uniq(getPubkeyTagValues(event.tags)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected reservedTagKeys() {
|
||||||
|
return ["d", "p"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// The group id is the addressable identifier (the "d" tag).
|
||||||
h() {
|
h() {
|
||||||
return this.values.h
|
return this.identifier()
|
||||||
}
|
}
|
||||||
|
|
||||||
admins() {
|
pubkeys() {
|
||||||
return this.values.admins
|
return uniq(getPubkeyTagValues(this.event.tags))
|
||||||
}
|
}
|
||||||
|
|
||||||
isAdmin(pubkey: string) {
|
builder() {
|
||||||
return this.values.admins.includes(pubkey)
|
const builder = new RoomAdminsBuilder()
|
||||||
}
|
|
||||||
|
|
||||||
addAdmin(pubkey: string) {
|
builder.h = this.identifier() || ""
|
||||||
if (!this.values.admins.includes(pubkey)) {
|
builder.pubkeys = this.pubkeys()
|
||||||
this.values.admins.push(pubkey)
|
|
||||||
}
|
|
||||||
|
|
||||||
return this
|
return this.seedBuilder(builder)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
removeAdmin(pubkey: string) {
|
|
||||||
this.values.admins = this.values.admins.filter(pk => pk !== pubkey)
|
export class RoomAdminsBuilder extends EventBuilder {
|
||||||
|
static kind = ROOM_ADMINS
|
||||||
return this
|
|
||||||
}
|
h = randomId()
|
||||||
|
pubkeys: string[] = []
|
||||||
async toTemplate(): Promise<EventTemplate> {
|
|
||||||
const tags: string[][] = [
|
setH(h: string) {
|
||||||
["d", this.values.h],
|
this.h = h
|
||||||
...this.values.admins.map(pk => ["p", pk]),
|
|
||||||
]
|
return this
|
||||||
|
}
|
||||||
return {kind: this.kind, tags, content: ""}
|
|
||||||
|
addPubkey(pubkey: string) {
|
||||||
|
if (!this.pubkeys.includes(pubkey)) {
|
||||||
|
this.pubkeys.push(pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
protected validate() {
|
||||||
|
if (!this.h) {
|
||||||
|
throw new Error("RoomAdmins requires an h/d identifier")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildTags() {
|
||||||
|
return [["d", this.h], ...this.pubkeys.map(pk => ["p", pk])]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +1,22 @@
|
|||||||
import {ROOM_CREATE, getTagValue} from "@welshman/util"
|
import {ROOM_CREATE} from "@welshman/util"
|
||||||
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
import {EventReader, EventBuilder} from "./base.js"
|
||||||
import {DomainObject} from "./base.js"
|
|
||||||
|
|
||||||
export type RoomCreateValues = {
|
|
||||||
h: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeRoomCreateValues = (
|
|
||||||
values: Partial<RoomCreateValues> = {},
|
|
||||||
): RoomCreateValues => ({
|
|
||||||
h: "",
|
|
||||||
...values,
|
|
||||||
})
|
|
||||||
|
|
||||||
// NIP-29 kind-9007 create-room action op. A regular (write-primarily) event
|
// NIP-29 kind-9007 create-room action op. A regular (write-primarily) event
|
||||||
// carrying only the target group id ("h") tag. Tags-only content, so it extends
|
// carrying only the target group id ("h") tag. The "h" tag is a base behavior
|
||||||
// DomainObject directly rather than the encryptable list base.
|
// tag, so the reader exposes it via the inherited group() accessor and the
|
||||||
export class RoomCreate extends DomainObject<RoomCreateValues> {
|
// builder sets it via setGroup — there are no kind-specific represented tags.
|
||||||
readonly kind = ROOM_CREATE
|
export class RoomCreate extends EventReader {
|
||||||
values = makeRoomCreateValues()
|
static kind = ROOM_CREATE
|
||||||
|
|
||||||
protected normalizeValues(values: Partial<RoomCreateValues> = {}) {
|
builder() {
|
||||||
return makeRoomCreateValues(values)
|
return this.seedBuilder(new RoomCreateBuilder())
|
||||||
}
|
}
|
||||||
|
}
|
||||||
protected parseEvent(event: TrustedEvent): Partial<RoomCreateValues> {
|
|
||||||
return {
|
export class RoomCreateBuilder extends EventBuilder {
|
||||||
h: getTagValue("h", event.tags) || "",
|
static kind = ROOM_CREATE
|
||||||
}
|
|
||||||
}
|
protected buildTags() {
|
||||||
|
return []
|
||||||
h() {
|
|
||||||
return this.values.h
|
|
||||||
}
|
|
||||||
|
|
||||||
async toTemplate(): Promise<EventTemplate> {
|
|
||||||
return {
|
|
||||||
kind: this.kind,
|
|
||||||
tags: [["h", this.values.h]],
|
|
||||||
content: "",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +1,46 @@
|
|||||||
import {uniq} from "@welshman/lib"
|
import {uniq} from "@welshman/lib"
|
||||||
import {ROOM_CREATE_PERMISSION, getPubkeyTagValues} from "@welshman/util"
|
import {ROOM_CREATE_PERMISSION, getPubkeyTagValues} from "@welshman/util"
|
||||||
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
import {EventReader, EventBuilder} from "./base.js"
|
||||||
import {DomainObject} from "./base.js"
|
|
||||||
|
|
||||||
export type RoomCreatePermissionValues = {
|
|
||||||
pubkeys: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeRoomCreatePermissionValues = (
|
|
||||||
values: Partial<RoomCreatePermissionValues> = {},
|
|
||||||
): RoomCreatePermissionValues => ({
|
|
||||||
pubkeys: [],
|
|
||||||
...values,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Flotilla/NIP-29 extension: relay-authored grant of room-creation permission
|
// Flotilla/NIP-29 extension: relay-authored grant of room-creation permission
|
||||||
// (kind 19004). The "p" tags list the pubkeys allowed to create rooms. Read-only
|
// (kind 19004). The "p" tags list the pubkeys allowed to create rooms. Read-only
|
||||||
// in practice. Tags-only content, so it extends DomainObject directly rather than
|
// in practice. Tags-only content.
|
||||||
// the encryptable list base.
|
export class RoomCreatePermission extends EventReader {
|
||||||
export class RoomCreatePermission extends DomainObject<RoomCreatePermissionValues> {
|
static kind = ROOM_CREATE_PERMISSION
|
||||||
readonly kind = ROOM_CREATE_PERMISSION
|
|
||||||
values = makeRoomCreatePermissionValues()
|
|
||||||
|
|
||||||
protected normalizeValues(values: Partial<RoomCreatePermissionValues> = {}) {
|
protected reservedTagKeys() {
|
||||||
return makeRoomCreatePermissionValues(values)
|
return ["p"]
|
||||||
}
|
|
||||||
|
|
||||||
protected parseEvent(event: TrustedEvent): Partial<RoomCreatePermissionValues> {
|
|
||||||
return {
|
|
||||||
pubkeys: uniq(getPubkeyTagValues(event.tags)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pubkeys() {
|
pubkeys() {
|
||||||
return this.values.pubkeys
|
return uniq(getPubkeyTagValues(this.event.tags))
|
||||||
}
|
}
|
||||||
|
|
||||||
canCreate(pubkey: string) {
|
canCreate(pubkey: string) {
|
||||||
return this.values.pubkeys.includes(pubkey)
|
return this.pubkeys().includes(pubkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
async toTemplate(): Promise<EventTemplate> {
|
builder() {
|
||||||
return {
|
const builder = new RoomCreatePermissionBuilder()
|
||||||
kind: this.kind,
|
|
||||||
tags: this.values.pubkeys.map(pk => ["p", pk]),
|
builder.pubkeys = this.pubkeys()
|
||||||
content: "",
|
|
||||||
}
|
return this.seedBuilder(builder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RoomCreatePermissionBuilder extends EventBuilder {
|
||||||
|
static kind = ROOM_CREATE_PERMISSION
|
||||||
|
|
||||||
|
pubkeys: string[] = []
|
||||||
|
|
||||||
|
setPubkeys(pubkeys: string[]) {
|
||||||
|
this.pubkeys = pubkeys
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildTags() {
|
||||||
|
return uniq(this.pubkeys).map(pk => ["p", pk])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,63 +1,79 @@
|
|||||||
import {ROOM_DELETE, getTagValues} from "@welshman/util"
|
import {ROOM_DELETE, getTagValues} from "@welshman/util"
|
||||||
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
import {EventReader, EventBuilder} from "./base.js"
|
||||||
import {DomainObject} from "./base.js"
|
|
||||||
|
|
||||||
export type RoomDeleteValues = {
|
|
||||||
hs: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeRoomDeleteValues = (
|
|
||||||
values: Partial<RoomDeleteValues> = {},
|
|
||||||
): RoomDeleteValues => ({
|
|
||||||
hs: [],
|
|
||||||
...values,
|
|
||||||
})
|
|
||||||
|
|
||||||
// NIP-29 kind-9008 delete-room/tombstone op. A regular event that may carry
|
// NIP-29 kind-9008 delete-room/tombstone op. A regular event that may carry
|
||||||
// MULTIPLE group id ("h") tags, allowing a single delete event to tombstone
|
// MULTIPLE group id ("h") tags, allowing a single delete event to tombstone
|
||||||
// several rooms at once. Tags-only content, so it extends DomainObject directly
|
// several rooms at once. Tags-only content.
|
||||||
// rather than the encryptable list base.
|
//
|
||||||
export class RoomDelete extends DomainObject<RoomDeleteValues> {
|
// Note: unlike most kinds, "h" here is a repeatable identity tag (the rooms to
|
||||||
readonly kind = ROOM_DELETE
|
// delete), not the base's single behavior group. So we handle "h" explicitly —
|
||||||
values = makeRoomDeleteValues()
|
// hs() reads them all, the builder emits one tag per id, and "h" is reserved —
|
||||||
|
// and we do NOT use the base group accessor/setter.
|
||||||
|
export class RoomDelete extends EventReader {
|
||||||
|
static kind = ROOM_DELETE
|
||||||
|
|
||||||
protected normalizeValues(values: Partial<RoomDeleteValues> = {}) {
|
protected validate() {
|
||||||
return makeRoomDeleteValues(values)
|
if (this.hs().length === 0) {
|
||||||
}
|
throw new Error("RoomDelete requires at least one h tag")
|
||||||
|
|
||||||
protected parseEvent(event: TrustedEvent): Partial<RoomDeleteValues> {
|
|
||||||
return {
|
|
||||||
hs: getTagValues("h", event.tags),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hs() {
|
protected reservedTagKeys() {
|
||||||
return this.values.hs
|
return ["h"]
|
||||||
}
|
}
|
||||||
|
|
||||||
h() {
|
// All group ids tombstoned by this event.
|
||||||
return this.values.hs[0]
|
hs() {
|
||||||
|
return getTagValues("h", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convenience accessor for the first group id.
|
||||||
|
h() {
|
||||||
|
return this.hs()[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
builder() {
|
||||||
|
const builder = new RoomDeleteBuilder()
|
||||||
|
|
||||||
|
builder.hs = this.hs()
|
||||||
|
|
||||||
|
this.seedBuilder(builder)
|
||||||
|
|
||||||
|
// "h" here is a repeatable room-id tag emitted by buildTags(), not the base's
|
||||||
|
// single behavior group. seedBuilder copies the first "h" into builder.group,
|
||||||
|
// which would make the base emit a duplicate "h" tag — so clear it.
|
||||||
|
builder.group = undefined
|
||||||
|
|
||||||
|
return builder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RoomDeleteBuilder extends EventBuilder {
|
||||||
|
static kind = ROOM_DELETE
|
||||||
|
|
||||||
|
hs: string[] = []
|
||||||
|
|
||||||
addRoom(h: string) {
|
addRoom(h: string) {
|
||||||
if (!this.values.hs.includes(h)) {
|
if (!this.hs.includes(h)) {
|
||||||
this.values.hs.push(h)
|
this.hs.push(h)
|
||||||
}
|
}
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
removeRoom(h: string) {
|
removeRoom(h: string) {
|
||||||
this.values.hs = this.values.hs.filter(value => value !== h)
|
this.hs = this.hs.filter(value => value !== h)
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
async toTemplate(): Promise<EventTemplate> {
|
protected validate() {
|
||||||
return {
|
if (this.hs.length === 0) {
|
||||||
kind: this.kind,
|
throw new Error("RoomDelete requires at least one h tag")
|
||||||
tags: this.values.hs.map(h => ["h", h]),
|
|
||||||
content: "",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected buildTags() {
|
||||||
|
return this.hs.map(h => ["h", h])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,62 +1,78 @@
|
|||||||
import {ROOM_JOIN, getTagValue} from "@welshman/util"
|
import {ROOM_JOIN, getTagValue} from "@welshman/util"
|
||||||
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
import type {ISigner} from "@welshman/signer"
|
||||||
import {DomainObject} from "./base.js"
|
import {EventReader, EventBuilder} from "./base.js"
|
||||||
|
|
||||||
export type RoomJoinValues = {
|
|
||||||
h: string
|
|
||||||
claim?: string
|
|
||||||
reason?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeRoomJoinValues = (values: Partial<RoomJoinValues> = {}): RoomJoinValues => ({
|
|
||||||
h: "",
|
|
||||||
...values,
|
|
||||||
})
|
|
||||||
|
|
||||||
// NIP-29 kind-9021 room join request. A regular (read-and-written) event
|
// NIP-29 kind-9021 room join request. A regular (read-and-written) event
|
||||||
// carrying the target group id ("h") tag, an optional invite "claim" tag, and a
|
// carrying the target group id ("h", handled by the base group accessor), an
|
||||||
// free-text reason in the event content. Drives the membership state machine
|
// optional invite "claim" tag (exposed as code), and a free-text reason in the
|
||||||
// (ROOM_JOIN -> Pending/Granted) and the pending-join admin queue, grouped by
|
// event content. Drives the membership state machine (ROOM_JOIN ->
|
||||||
// h + pubkey. Tags-plus-content, so it extends DomainObject directly.
|
// Pending/Granted) and the pending-join admin queue, grouped by h + pubkey.
|
||||||
export class RoomJoin extends DomainObject<RoomJoinValues> {
|
export class RoomJoin extends EventReader {
|
||||||
readonly kind = ROOM_JOIN
|
static kind = ROOM_JOIN
|
||||||
values = makeRoomJoinValues()
|
|
||||||
|
|
||||||
protected normalizeValues(values: Partial<RoomJoinValues> = {}) {
|
protected validate() {
|
||||||
return makeRoomJoinValues(values)
|
if (!this.group()) {
|
||||||
}
|
throw new Error("RoomJoin requires an h tag")
|
||||||
|
|
||||||
protected parseEvent(event: TrustedEvent): Partial<RoomJoinValues> {
|
|
||||||
return {
|
|
||||||
h: getTagValue("h", event.tags) || "",
|
|
||||||
claim: getTagValue("claim", event.tags),
|
|
||||||
reason: event.content || undefined,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
h() {
|
protected reservedTagKeys() {
|
||||||
return this.values.h
|
return ["claim"]
|
||||||
}
|
}
|
||||||
|
|
||||||
claim() {
|
// The invite "claim" tag.
|
||||||
return this.values.claim
|
code() {
|
||||||
|
return getTagValue("claim", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Free-text reason carried in the event content.
|
||||||
reason() {
|
reason() {
|
||||||
return this.values.reason
|
return this.event.content || undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
async toTemplate(): Promise<EventTemplate> {
|
builder() {
|
||||||
const tags: string[][] = [["h", this.values.h]]
|
const builder = new RoomJoinBuilder()
|
||||||
|
|
||||||
if (this.values.claim) {
|
builder.code = this.code()
|
||||||
tags.push(["claim", this.values.claim])
|
builder.reason = this.reason()
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return this.seedBuilder(builder)
|
||||||
kind: this.kind,
|
}
|
||||||
tags,
|
}
|
||||||
content: this.values.reason || "",
|
|
||||||
}
|
export class RoomJoinBuilder extends EventBuilder {
|
||||||
|
static kind = ROOM_JOIN
|
||||||
|
|
||||||
|
code?: string
|
||||||
|
reason?: string
|
||||||
|
|
||||||
|
setCode(code: string) {
|
||||||
|
this.code = code
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setReason(reason: string) {
|
||||||
|
this.reason = reason
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
protected validate() {
|
||||||
|
if (!this.group) {
|
||||||
|
throw new Error("RoomJoin requires an h/group")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildContent(_signer?: ISigner) {
|
||||||
|
return this.reason || ""
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildTags() {
|
||||||
|
const tags: string[][] = []
|
||||||
|
|
||||||
|
if (this.code) tags.push(["claim", this.code])
|
||||||
|
|
||||||
|
return tags
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,41 @@
|
|||||||
import {ROOM_LEAVE, getTagValue} from "@welshman/util"
|
import {ROOM_LEAVE} from "@welshman/util"
|
||||||
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
import {EventReader, EventBuilder} from "./base.js"
|
||||||
import {DomainObject} from "./base.js"
|
|
||||||
|
|
||||||
export type RoomLeaveValues = {
|
|
||||||
h: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeRoomLeaveValues = (values: Partial<RoomLeaveValues> = {}): RoomLeaveValues => ({
|
|
||||||
h: "",
|
|
||||||
...values,
|
|
||||||
})
|
|
||||||
|
|
||||||
// NIP-29 kind-9022 room leave op, the counterpart to RoomJoin. A regular event
|
// NIP-29 kind-9022 room leave op, the counterpart to RoomJoin. A regular event
|
||||||
// carrying the target group id ("h") tag, which resets the membership state
|
// carrying the target group id ("h") tag, which resets the membership state
|
||||||
// machine (ROOM_LEAVE -> Initial). Tags-only, so it extends DomainObject directly.
|
// machine (ROOM_LEAVE -> Initial). The only represented tag is the group ("h"),
|
||||||
export class RoomLeave extends DomainObject<RoomLeaveValues> {
|
// which the base owns as a behavior tag, so buildTags is empty. Tags-only content.
|
||||||
readonly kind = ROOM_LEAVE
|
export class RoomLeave extends EventReader {
|
||||||
values = makeRoomLeaveValues()
|
static kind = ROOM_LEAVE
|
||||||
|
|
||||||
protected normalizeValues(values: Partial<RoomLeaveValues> = {}) {
|
protected validate() {
|
||||||
return makeRoomLeaveValues(values)
|
if (!this.group()) {
|
||||||
}
|
throw new Error("RoomLeave requires an h tag")
|
||||||
|
|
||||||
protected parseEvent(event: TrustedEvent): Partial<RoomLeaveValues> {
|
|
||||||
return {
|
|
||||||
h: getTagValue("h", event.tags) || "",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The group id ("h") is read via the base group() accessor.
|
||||||
h() {
|
h() {
|
||||||
return this.values.h
|
return this.group()
|
||||||
}
|
}
|
||||||
|
|
||||||
async toTemplate(): Promise<EventTemplate> {
|
builder() {
|
||||||
return {
|
const builder = new RoomLeaveBuilder()
|
||||||
kind: this.kind,
|
|
||||||
tags: [["h", this.values.h]],
|
return this.seedBuilder(builder)
|
||||||
content: "",
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class RoomLeaveBuilder extends EventBuilder {
|
||||||
|
static kind = ROOM_LEAVE
|
||||||
|
|
||||||
|
protected validate() {
|
||||||
|
if (!this.group) {
|
||||||
|
throw new Error("RoomLeave requires an h identifier")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildTags() {
|
||||||
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import {ROOMS, getGroupTags, getGroupTagValues} from "@welshman/util"
|
import {ROOMS, getGroupTags, getGroupTagValues} from "@welshman/util"
|
||||||
import {EncryptableList} from "./List.js"
|
import {ListReader, ListBuilder} from "./List.js"
|
||||||
|
|
||||||
// NIP-51 / NIP-29 kind-10009 simple-groups membership list. Each entry is a
|
// NIP-51 / NIP-29 kind-10009 simple-groups membership list. Each entry is a
|
||||||
// group tag `["group", groupId, relayUrl]` (legacy `"h"` is also accepted).
|
// group tag `["group", groupId, relayUrl]` (legacy `"h"` is also accepted on
|
||||||
// Distinct from the NIP-29 room management events, which are not lists.
|
// read). Distinct from the NIP-29 room management events, which are not lists.
|
||||||
export class RoomList extends EncryptableList {
|
export class RoomList extends ListReader {
|
||||||
readonly kind = ROOMS
|
static kind = ROOMS
|
||||||
|
|
||||||
groups() {
|
groups() {
|
||||||
return getGroupTagValues(this.tags())
|
return getGroupTagValues(this.tags())
|
||||||
@@ -15,6 +15,14 @@ export class RoomList extends EncryptableList {
|
|||||||
return getGroupTags(this.tags())
|
return getGroupTags(this.tags())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
builder() {
|
||||||
|
return this.seedList(new RoomListBuilder())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RoomListBuilder extends ListBuilder {
|
||||||
|
static kind = ROOMS
|
||||||
|
|
||||||
join(groupId: string, relayUrl: string) {
|
join(groupId: string, relayUrl: string) {
|
||||||
return this.addPublicTags(["group", groupId, relayUrl])
|
return this.addPublicTags(["group", groupId, relayUrl])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,70 +1,71 @@
|
|||||||
import {uniq} from "@welshman/lib"
|
import {uniq} from "@welshman/lib"
|
||||||
import {ROOM_MEMBERS, getIdentifier, getPubkeyTagValues} from "@welshman/util"
|
import {ROOM_MEMBERS, getPubkeyTagValues} from "@welshman/util"
|
||||||
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
import {EventReader, EventBuilder} from "./base.js"
|
||||||
import {DomainObject} from "./base.js"
|
|
||||||
|
|
||||||
export type RoomMembersValues = {
|
|
||||||
h: string
|
|
||||||
members: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeRoomMembersValues = (
|
|
||||||
values: Partial<RoomMembersValues> = {},
|
|
||||||
): RoomMembersValues => ({
|
|
||||||
h: "",
|
|
||||||
members: [],
|
|
||||||
...values,
|
|
||||||
})
|
|
||||||
|
|
||||||
// NIP-29 kind-39002 relay-authored room member-list snapshot. Addressable, with
|
// NIP-29 kind-39002 relay-authored room member-list snapshot. Addressable, with
|
||||||
// the group id ("h") stored in the "d" tag and members listed as "p" tags.
|
// the group id ("h") stored in the "d" tag and members listed as "p" tags.
|
||||||
// Tags-only content, so it extends DomainObject directly rather than the
|
// Tags-only content.
|
||||||
// encryptable list base.
|
export class RoomMembers extends EventReader {
|
||||||
export class RoomMembers extends DomainObject<RoomMembersValues> {
|
static kind = ROOM_MEMBERS
|
||||||
readonly kind = ROOM_MEMBERS
|
|
||||||
values = makeRoomMembersValues()
|
|
||||||
|
|
||||||
protected normalizeValues(values: Partial<RoomMembersValues> = {}) {
|
protected validate() {
|
||||||
return makeRoomMembersValues(values)
|
if (!this.identifier()) {
|
||||||
}
|
throw new Error("RoomMembers requires a d tag")
|
||||||
|
|
||||||
protected parseEvent(event: TrustedEvent): Partial<RoomMembersValues> {
|
|
||||||
return {
|
|
||||||
h: getIdentifier(event) || "",
|
|
||||||
members: uniq(getPubkeyTagValues(event.tags)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected reservedTagKeys() {
|
||||||
|
return ["d", "p"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// The group id is the addressable identifier (the "d" tag).
|
||||||
h() {
|
h() {
|
||||||
return this.values.h
|
return this.identifier()
|
||||||
}
|
}
|
||||||
|
|
||||||
members() {
|
members() {
|
||||||
return this.values.members
|
return uniq(getPubkeyTagValues(this.event.tags))
|
||||||
}
|
}
|
||||||
|
|
||||||
isMember(pubkey: string) {
|
isMember(pubkey: string) {
|
||||||
return this.values.members.includes(pubkey)
|
return this.members().includes(pubkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
builder() {
|
||||||
|
const builder = new RoomMembersBuilder()
|
||||||
|
|
||||||
|
builder.h = this.identifier() || ""
|
||||||
|
builder.members = this.members()
|
||||||
|
|
||||||
|
return this.seedBuilder(builder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RoomMembersBuilder extends EventBuilder {
|
||||||
|
static kind = ROOM_MEMBERS
|
||||||
|
|
||||||
|
h = ""
|
||||||
|
members: string[] = []
|
||||||
|
|
||||||
addMember(pubkey: string) {
|
addMember(pubkey: string) {
|
||||||
this.values.members = uniq([...this.values.members, pubkey])
|
this.members = uniq([...this.members, pubkey])
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
removeMember(pubkey: string) {
|
removeMember(pubkey: string) {
|
||||||
this.values.members = this.values.members.filter(pk => pk !== pubkey)
|
this.members = this.members.filter(pk => pk !== pubkey)
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
async toTemplate(): Promise<EventTemplate> {
|
protected validate() {
|
||||||
const tags: string[][] = [
|
if (!this.h) {
|
||||||
["d", this.values.h],
|
throw new Error("RoomMembers requires an h/d identifier")
|
||||||
...this.values.members.map(pk => ["p", pk]),
|
}
|
||||||
]
|
}
|
||||||
|
|
||||||
return {kind: this.kind, tags, content: ""}
|
protected buildTags() {
|
||||||
|
return [["d", this.h], ...this.members.map(pk => ["p", pk])]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +1,72 @@
|
|||||||
import {uniq} from "@welshman/lib"
|
import {uniq} from "@welshman/lib"
|
||||||
import {ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER, getPubkeyTagValues} from "@welshman/util"
|
import {ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER, getPubkeyTagValues} from "@welshman/util"
|
||||||
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
import {EventReader, EventBuilder} from "./base.js"
|
||||||
import {DomainObject} from "./base.js"
|
|
||||||
|
|
||||||
export type RoomMembershipValues = {
|
|
||||||
pubkeys: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeRoomMembershipValues = (
|
|
||||||
values: Partial<RoomMembershipValues> = {},
|
|
||||||
): RoomMembershipValues => ({
|
|
||||||
pubkeys: [],
|
|
||||||
...values,
|
|
||||||
})
|
|
||||||
|
|
||||||
// NIP-29 moderation op for adding (kind 9000) or removing (kind 9001) room
|
// NIP-29 moderation op for adding (kind 9000) or removing (kind 9001) room
|
||||||
// members. Regular (non-addressable) events carrying the affected pubkeys in "p"
|
// 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
|
// tags; the target group id is the base `group` ("h") behavior tag. Add and
|
||||||
// remove share this shape; each is its own concrete class fixing the kind.
|
// remove share this shape; each is its own concrete reader/builder fixing the
|
||||||
|
// kind via a static field.
|
||||||
//
|
//
|
||||||
// Flotilla's membership replay treats RoomAddMember => member, RoomRemoveMember
|
// Flotilla's membership replay treats RoomAddMember => member, RoomRemoveMember
|
||||||
// => not a member.
|
// => not a member.
|
||||||
export abstract class RoomMembershipOp extends DomainObject<RoomMembershipValues> {
|
export abstract class RoomMembershipOp extends EventReader {
|
||||||
values = makeRoomMembershipValues()
|
protected reservedTagKeys() {
|
||||||
|
return ["p"]
|
||||||
protected normalizeValues(values: Partial<RoomMembershipValues> = {}) {
|
|
||||||
return makeRoomMembershipValues(values)
|
|
||||||
}
|
|
||||||
|
|
||||||
protected parseEvent(event: TrustedEvent): Partial<RoomMembershipValues> {
|
|
||||||
return {pubkeys: uniq(getPubkeyTagValues(event.tags))}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The affected pubkeys, deduped.
|
||||||
pubkeys() {
|
pubkeys() {
|
||||||
return this.values.pubkeys
|
return uniq(getPubkeyTagValues(this.event.tags))
|
||||||
}
|
}
|
||||||
|
|
||||||
async toTemplate(): Promise<EventTemplate> {
|
abstract builder(): RoomMembershipOpBuilder
|
||||||
return {
|
}
|
||||||
kind: this.kind,
|
|
||||||
tags: this.values.pubkeys.map(pk => ["p", pk]),
|
// Shared write side: collect pubkeys, emit them as "p" tags. The target group id
|
||||||
content: "",
|
// ("h") is set via the base group behavior tag.
|
||||||
}
|
export abstract class RoomMembershipOpBuilder extends EventBuilder {
|
||||||
|
pubkeys: string[] = []
|
||||||
|
|
||||||
|
addPubkey(pubkey: string) {
|
||||||
|
this.pubkeys = uniq([...this.pubkeys, pubkey])
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildTags() {
|
||||||
|
return this.pubkeys.map(pk => ["p", pk])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RoomAddMember extends RoomMembershipOp {
|
export class RoomAddMember extends RoomMembershipOp {
|
||||||
readonly kind = ROOM_ADD_MEMBER
|
static kind = ROOM_ADD_MEMBER
|
||||||
|
|
||||||
|
builder() {
|
||||||
|
const builder = new RoomAddMemberBuilder()
|
||||||
|
|
||||||
|
builder.pubkeys = this.pubkeys()
|
||||||
|
|
||||||
|
return this.seedBuilder(builder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RoomAddMemberBuilder extends RoomMembershipOpBuilder {
|
||||||
|
static kind = ROOM_ADD_MEMBER
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RoomRemoveMember extends RoomMembershipOp {
|
export class RoomRemoveMember extends RoomMembershipOp {
|
||||||
readonly kind = ROOM_REMOVE_MEMBER
|
static kind = ROOM_REMOVE_MEMBER
|
||||||
|
|
||||||
|
builder() {
|
||||||
|
const builder = new RoomRemoveMemberBuilder()
|
||||||
|
|
||||||
|
builder.pubkeys = this.pubkeys()
|
||||||
|
|
||||||
|
return this.seedBuilder(builder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RoomRemoveMemberBuilder extends RoomMembershipOpBuilder {
|
||||||
|
static kind = ROOM_REMOVE_MEMBER
|
||||||
}
|
}
|
||||||
|
|||||||
+116
-97
@@ -1,115 +1,134 @@
|
|||||||
import {randomId} from "@welshman/lib"
|
import {randomId} from "@welshman/lib"
|
||||||
import {ROOM_META, getIdentifier, getTag, getTagValue} from "@welshman/util"
|
import {ROOM_META, getTag, getTagValue} from "@welshman/util"
|
||||||
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
import {EventReader, EventBuilder} from "./base.js"
|
||||||
import {DomainObject} from "./base.js"
|
|
||||||
|
|
||||||
export type RoomMetaValues = {
|
// NIP-29 kind-39000 relay-generated group metadata. Addressable, with the group
|
||||||
h: string
|
// id ("h") stored in the "d" tag. Tags-only content.
|
||||||
|
export class RoomMeta extends EventReader {
|
||||||
|
static kind = ROOM_META
|
||||||
|
|
||||||
|
protected validate() {
|
||||||
|
if (!this.identifier()) {
|
||||||
|
throw new Error("RoomMeta requires a d tag")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected reservedTagKeys() {
|
||||||
|
return ["d", "name", "about", "picture", "closed", "hidden", "private", "restricted", "livekit"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// The group id is the addressable identifier (the "d" tag).
|
||||||
|
h() {
|
||||||
|
return this.identifier()
|
||||||
|
}
|
||||||
|
|
||||||
|
name() {
|
||||||
|
return getTagValue("name", this.event.tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
about() {
|
||||||
|
return getTagValue("about", this.event.tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
picture() {
|
||||||
|
return getTag("picture", this.event.tags)?.[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
pictureMeta() {
|
||||||
|
const tag = getTag("picture", this.event.tags)
|
||||||
|
|
||||||
|
return tag ? tag.slice(2) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
isClosed() {
|
||||||
|
return this.event.tags.some(t => t[0] === "closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
isHidden() {
|
||||||
|
return this.event.tags.some(t => t[0] === "hidden")
|
||||||
|
}
|
||||||
|
|
||||||
|
isPrivate() {
|
||||||
|
return this.event.tags.some(t => t[0] === "private")
|
||||||
|
}
|
||||||
|
|
||||||
|
isRestricted() {
|
||||||
|
return this.event.tags.some(t => t[0] === "restricted")
|
||||||
|
}
|
||||||
|
|
||||||
|
livekit() {
|
||||||
|
return this.event.tags.some(t => t[0] === "livekit")
|
||||||
|
}
|
||||||
|
|
||||||
|
builder() {
|
||||||
|
const builder = new RoomMetaBuilder()
|
||||||
|
|
||||||
|
builder.h = this.identifier() || ""
|
||||||
|
builder.name = this.name()
|
||||||
|
builder.about = this.about()
|
||||||
|
builder.picture = this.picture()
|
||||||
|
builder.pictureMeta = this.pictureMeta()
|
||||||
|
builder.closed = this.isClosed()
|
||||||
|
builder.hidden = this.isHidden()
|
||||||
|
builder.isPrivate = this.isPrivate()
|
||||||
|
builder.restricted = this.isRestricted()
|
||||||
|
builder.livekit = this.livekit()
|
||||||
|
|
||||||
|
return this.seedBuilder(builder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RoomMetaBuilder extends EventBuilder {
|
||||||
|
static kind = ROOM_META
|
||||||
|
|
||||||
|
h = randomId()
|
||||||
name?: string
|
name?: string
|
||||||
about?: string
|
about?: string
|
||||||
picture?: string
|
picture?: string
|
||||||
pictureMeta?: string[]
|
pictureMeta?: string[]
|
||||||
isClosed: boolean
|
closed = false
|
||||||
isHidden: boolean
|
hidden = false
|
||||||
isPrivate: boolean
|
isPrivate = false
|
||||||
isRestricted: boolean
|
restricted = false
|
||||||
livekit: boolean
|
livekit = false
|
||||||
}
|
|
||||||
|
|
||||||
export const makeRoomMetaValues = (values: Partial<RoomMetaValues> = {}): RoomMetaValues => ({
|
setName(name: string) {
|
||||||
h: values.h || randomId(),
|
this.name = name
|
||||||
isClosed: false,
|
|
||||||
isHidden: false,
|
|
||||||
isPrivate: false,
|
|
||||||
isRestricted: false,
|
|
||||||
livekit: false,
|
|
||||||
...values,
|
|
||||||
})
|
|
||||||
|
|
||||||
// NIP-29 kind-39000 relay-generated group metadata. Addressable, with the group
|
return this
|
||||||
// id ("h") stored in the "d" tag. Tags-only content, so it extends DomainObject
|
|
||||||
// directly rather than the encryptable list base.
|
|
||||||
export class RoomMeta extends DomainObject<RoomMetaValues> {
|
|
||||||
readonly kind = ROOM_META
|
|
||||||
values = makeRoomMetaValues()
|
|
||||||
|
|
||||||
protected normalizeValues(values: Partial<RoomMetaValues> = {}) {
|
|
||||||
return makeRoomMetaValues(values)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected parseEvent(event: TrustedEvent): Partial<RoomMetaValues> {
|
setAbout(about: string) {
|
||||||
const pic = getTag("picture", event.tags)
|
this.about = about
|
||||||
|
|
||||||
return {
|
return this
|
||||||
h: getIdentifier(event) || "",
|
}
|
||||||
name: getTagValue("name", event.tags),
|
|
||||||
about: getTagValue("about", event.tags),
|
setPicture(picture: string, meta?: string[]) {
|
||||||
picture: pic?.[1],
|
this.picture = picture
|
||||||
pictureMeta: pic ? pic.slice(2) : undefined,
|
this.pictureMeta = meta
|
||||||
isClosed: Boolean(getTag("closed", event.tags)),
|
|
||||||
isHidden: Boolean(getTag("hidden", event.tags)),
|
return this
|
||||||
isPrivate: Boolean(getTag("private", event.tags)),
|
}
|
||||||
isRestricted: Boolean(getTag("restricted", event.tags)),
|
|
||||||
livekit: Boolean(getTag("livekit", event.tags)),
|
protected validate() {
|
||||||
|
if (!this.h) {
|
||||||
|
throw new Error("RoomMeta requires an h/d identifier")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
h() {
|
protected buildTags() {
|
||||||
return this.values.h
|
const tags: string[][] = [["d", this.h]]
|
||||||
}
|
|
||||||
|
|
||||||
name() {
|
if (this.name) tags.push(["name", this.name])
|
||||||
return this.values.name
|
if (this.about) tags.push(["about", this.about])
|
||||||
}
|
if (this.picture) tags.push(["picture", this.picture, ...(this.pictureMeta || [])])
|
||||||
|
if (this.closed) tags.push(["closed"])
|
||||||
|
if (this.hidden) tags.push(["hidden"])
|
||||||
|
if (this.isPrivate) tags.push(["private"])
|
||||||
|
if (this.restricted) tags.push(["restricted"])
|
||||||
|
if (this.livekit) tags.push(["livekit"])
|
||||||
|
|
||||||
about() {
|
return tags
|
||||||
return this.values.about
|
|
||||||
}
|
|
||||||
|
|
||||||
picture() {
|
|
||||||
return this.values.picture
|
|
||||||
}
|
|
||||||
|
|
||||||
pictureMeta() {
|
|
||||||
return this.values.pictureMeta
|
|
||||||
}
|
|
||||||
|
|
||||||
isClosed() {
|
|
||||||
return this.values.isClosed
|
|
||||||
}
|
|
||||||
|
|
||||||
isHidden() {
|
|
||||||
return this.values.isHidden
|
|
||||||
}
|
|
||||||
|
|
||||||
isPrivate() {
|
|
||||||
return this.values.isPrivate
|
|
||||||
}
|
|
||||||
|
|
||||||
isRestricted() {
|
|
||||||
return this.values.isRestricted
|
|
||||||
}
|
|
||||||
|
|
||||||
livekit() {
|
|
||||||
return this.values.livekit
|
|
||||||
}
|
|
||||||
|
|
||||||
async toTemplate(): Promise<EventTemplate> {
|
|
||||||
const tags: string[][] = [["d", this.values.h]]
|
|
||||||
|
|
||||||
if (this.values.name) tags.push(["name", this.values.name])
|
|
||||||
if (this.values.about) tags.push(["about", this.values.about])
|
|
||||||
|
|
||||||
if (this.values.picture) {
|
|
||||||
tags.push(["picture", this.values.picture, ...(this.values.pictureMeta || [])])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.values.isClosed) tags.push(["closed"])
|
|
||||||
if (this.values.isHidden) tags.push(["hidden"])
|
|
||||||
if (this.values.isPrivate) tags.push(["private"])
|
|
||||||
if (this.values.isRestricted) tags.push(["restricted"])
|
|
||||||
if (this.values.livekit) tags.push(["livekit"])
|
|
||||||
|
|
||||||
return {kind: this.kind, tags, content: ""}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,35 @@
|
|||||||
import {uniqBy} from "@welshman/lib"
|
import {uniqBy} from "@welshman/lib"
|
||||||
import {SEARCH_RELAYS, getTagValues, normalizeRelayUrl} from "@welshman/util"
|
import {SEARCH_RELAYS, getTagValues, normalizeRelayUrl} from "@welshman/util"
|
||||||
import {EncryptableList} from "./List.js"
|
import {ListReader, ListBuilder} from "./List.js"
|
||||||
|
|
||||||
// NIP-51 kind-10007 search relays (NIP-50). Entries are marker-less
|
// NIP-51 kind-10007 search relays (NIP-50). Entries are marker-less
|
||||||
// ['relay', url] tags (NOT NIP-65 'r' tags with read/write markers). Identical
|
// ['relay', url] tags (NOT NIP-65 'r' tags with read/write markers). Identical
|
||||||
// structure to BlockedRelayList; `urls()` stays a flat, normalized set.
|
// structure to BlockedRelayList; `urls()` stays a flat, normalized set.
|
||||||
export class SearchRelayList extends EncryptableList {
|
export class SearchRelayList extends ListReader {
|
||||||
readonly kind = SEARCH_RELAYS
|
static kind = SEARCH_RELAYS
|
||||||
|
|
||||||
urls() {
|
urls() {
|
||||||
return uniqBy(normalizeRelayUrl, getTagValues("relay", this.tags()))
|
return uniqBy(normalizeRelayUrl, getTagValues("relay", this.tags()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
includes(url: string) {
|
||||||
|
return this.urls().includes(normalizeRelayUrl(url))
|
||||||
|
}
|
||||||
|
|
||||||
|
builder() {
|
||||||
|
return this.seedList(new SearchRelayListBuilder())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SearchRelayListBuilder extends ListBuilder {
|
||||||
|
static kind = SEARCH_RELAYS
|
||||||
|
|
||||||
addRelay(url: string) {
|
addRelay(url: string) {
|
||||||
return this.addPublicTags(["relay", normalizeRelayUrl(url)])
|
return this.addPublicTags(["relay", normalizeRelayUrl(url)])
|
||||||
}
|
}
|
||||||
|
|
||||||
removeRelay(url: string) {
|
removeRelay(url: string) {
|
||||||
return this.removeTagsWithValue(url)
|
return this.removeTagsWithValue(normalizeRelayUrl(url))
|
||||||
}
|
}
|
||||||
|
|
||||||
setRelays(urls: string[]) {
|
setRelays(urls: string[]) {
|
||||||
|
|||||||
@@ -1,16 +1,5 @@
|
|||||||
import {THREAD, getTagValue} from "@welshman/util"
|
import {THREAD, getTagValue} from "@welshman/util"
|
||||||
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
import {EventReader, EventBuilder} from "./base.js"
|
||||||
import {DomainObject} from "./base.js"
|
|
||||||
|
|
||||||
export type ThreadValues = {
|
|
||||||
title?: string
|
|
||||||
content: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeThreadValues = (values: Partial<ThreadValues> = {}): ThreadValues => ({
|
|
||||||
content: "",
|
|
||||||
...values,
|
|
||||||
})
|
|
||||||
|
|
||||||
// NIP-7D kind-11 forum thread root. The body lives in `content` as plain text
|
// NIP-7D kind-11 forum thread root. The body lives in `content` as plain text
|
||||||
// (not JSON) and the title is carried in a "title" tag; room scoping is handled
|
// (not JSON) and the title is carried in a "title" tag; room scoping is handled
|
||||||
@@ -18,40 +7,58 @@ export const makeThreadValues = (values: Partial<ThreadValues> = {}): ThreadValu
|
|||||||
// replies are COMMENT (kind 1111) via "#E". Flotilla also appends editor/inline
|
// replies are COMMENT (kind 1111) via "#E". Flotilla also appends editor/inline
|
||||||
// tags at call sites; those round-trip via the base `extraTags` (with "title"
|
// tags at call sites; those round-trip via the base `extraTags` (with "title"
|
||||||
// declared reserved so it isn't double-counted).
|
// declared reserved so it isn't double-counted).
|
||||||
export class Thread extends DomainObject<ThreadValues> {
|
export class Thread extends EventReader {
|
||||||
readonly kind = THREAD
|
static kind = THREAD
|
||||||
values = makeThreadValues()
|
|
||||||
|
|
||||||
protected normalizeValues(values: Partial<ThreadValues> = {}) {
|
|
||||||
return makeThreadValues(values)
|
|
||||||
}
|
|
||||||
|
|
||||||
protected reservedTagKeys() {
|
protected reservedTagKeys() {
|
||||||
return ["title"]
|
return ["title"]
|
||||||
}
|
}
|
||||||
|
|
||||||
protected parseEvent(event: TrustedEvent): Partial<ThreadValues> {
|
|
||||||
return {
|
|
||||||
title: getTagValue("title", event.tags),
|
|
||||||
content: event.content || "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
title() {
|
title() {
|
||||||
return this.values.title
|
return getTagValue("title", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
content() {
|
content() {
|
||||||
return this.values.content
|
return this.event.content || ""
|
||||||
}
|
}
|
||||||
|
|
||||||
async toTemplate(): Promise<EventTemplate> {
|
builder() {
|
||||||
const tags: string[][] = []
|
const builder = new ThreadBuilder()
|
||||||
|
|
||||||
if (this.values.title) {
|
builder.title = this.title()
|
||||||
tags.push(["title", this.values.title])
|
builder.content = this.content()
|
||||||
}
|
|
||||||
|
|
||||||
return {kind: this.kind, content: this.values.content, tags}
|
return this.seedBuilder(builder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ThreadBuilder extends EventBuilder {
|
||||||
|
static kind = THREAD
|
||||||
|
|
||||||
|
title?: string
|
||||||
|
content = ""
|
||||||
|
|
||||||
|
setTitle(title: string) {
|
||||||
|
this.title = title
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent(content: string) {
|
||||||
|
this.content = content
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildTags() {
|
||||||
|
const tags: string[][] = []
|
||||||
|
|
||||||
|
if (this.title) tags.push(["title", this.title])
|
||||||
|
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildContent() {
|
||||||
|
return this.content
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,101 +1,124 @@
|
|||||||
import {range, DAY} from "@welshman/lib"
|
import {randomId, range, DAY} from "@welshman/lib"
|
||||||
import {EVENT_TIME, getIdentifier, getTagValue} from "@welshman/util"
|
import {EVENT_TIME, getTagValue} from "@welshman/util"
|
||||||
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
import {EventReader, EventBuilder} from "./base.js"
|
||||||
import {DomainObject} from "./base.js"
|
|
||||||
|
|
||||||
export type TimeEventValues = {
|
|
||||||
identifier: string
|
|
||||||
title?: string
|
|
||||||
location?: string
|
|
||||||
content: string
|
|
||||||
start?: number
|
|
||||||
end?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeTimeEventValues = (
|
|
||||||
values: Partial<TimeEventValues> = {},
|
|
||||||
): TimeEventValues => ({
|
|
||||||
identifier: "",
|
|
||||||
content: "",
|
|
||||||
...values,
|
|
||||||
})
|
|
||||||
|
|
||||||
// NIP-52 kind-31923 time-based calendar event. Addressable via the "d" tag.
|
// NIP-52 kind-31923 time-based calendar event. Addressable via the "d" tag.
|
||||||
// `start`/`end` are unix-second timestamps carried in "start"/"end" tags
|
// `start`/`end` are unix-second timestamps carried in "start"/"end" tags
|
||||||
// (parsed with parseInt), `title` falls back to the legacy "name" tag, and the
|
// (parsed with parseInt), `title` falls back to the legacy "name" tag, and the
|
||||||
// plain-text body lives in `content`. Room scoping is handled by the base
|
// plain-text body lives in the event content. Room scoping is handled by the
|
||||||
// `group` behavior tag. Named
|
// base `group` behavior tag. Named TimeEvent (not CalendarEvent) to leave room
|
||||||
// TimeEvent (not CalendarEvent) to leave room for a future date-based event
|
// for a future date-based event (EVENT_DATE 31922); CALENDAR 31924 /
|
||||||
// (EVENT_DATE 31922); CALENDAR 31924 / EVENT_RSVP 31925 are not used. Tags +
|
// EVENT_RSVP 31925 are not used. Tags + plain-text content, so it extends
|
||||||
// plain content, so it extends DomainObject directly.
|
// EventReader/EventBuilder directly (no parsed `plain`).
|
||||||
//
|
//
|
||||||
// The "D" day tags are NOT intrinsic state — they're a derived index over
|
// 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
|
// start..end used purely so calendar events can be filtered by day, so they're
|
||||||
// dropped on parse and recomputed in toTemplate (matching flotilla's
|
// dropped on read and recomputed in buildTags (matching flotilla's
|
||||||
// daysBetween: one tag per epoch-day floor(seconds / DAY) the event spans).
|
// daysBetween: one tag per epoch-day floor(seconds / DAY) the event spans).
|
||||||
export class TimeEvent extends DomainObject<TimeEventValues> {
|
export class TimeEvent extends EventReader {
|
||||||
readonly kind = EVENT_TIME
|
static kind = EVENT_TIME
|
||||||
values = makeTimeEventValues()
|
|
||||||
|
|
||||||
protected normalizeValues(values: Partial<TimeEventValues> = {}) {
|
protected reservedTagKeys() {
|
||||||
return makeTimeEventValues(values)
|
return ["d", "title", "name", "location", "start", "end", "D"]
|
||||||
}
|
|
||||||
|
|
||||||
protected parseEvent(event: TrustedEvent): Partial<TimeEventValues> {
|
|
||||||
const start = parseInt(getTagValue("start", event.tags)!)
|
|
||||||
const end = parseInt(getTagValue("end", event.tags)!)
|
|
||||||
|
|
||||||
return {
|
|
||||||
identifier: getIdentifier(event) || "",
|
|
||||||
title: getTagValue("title", event.tags) || getTagValue("name", event.tags),
|
|
||||||
location: getTagValue("location", event.tags),
|
|
||||||
content: event.content || "",
|
|
||||||
start: isNaN(start) ? undefined : start,
|
|
||||||
end: isNaN(end) ? undefined : end,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
identifier() {
|
|
||||||
return this.values.identifier
|
|
||||||
}
|
}
|
||||||
|
|
||||||
title() {
|
title() {
|
||||||
return this.values.title
|
return getTagValue("title", this.event.tags) || getTagValue("name", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
location() {
|
location() {
|
||||||
return this.values.location
|
return getTagValue("location", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
content() {
|
content() {
|
||||||
return this.values.content
|
return this.event.content || ""
|
||||||
}
|
}
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
return this.values.start
|
const start = parseInt(getTagValue("start", this.event.tags)!)
|
||||||
|
|
||||||
|
return isNaN(start) ? undefined : start
|
||||||
}
|
}
|
||||||
|
|
||||||
end() {
|
end() {
|
||||||
return this.values.end
|
const end = parseInt(getTagValue("end", this.event.tags)!)
|
||||||
|
|
||||||
|
return isNaN(end) ? undefined : end
|
||||||
}
|
}
|
||||||
|
|
||||||
async toTemplate(): Promise<EventTemplate> {
|
builder() {
|
||||||
const {identifier, title, location, content, start, end} = this.values
|
const builder = new TimeEventBuilder()
|
||||||
|
|
||||||
const tags: string[][] = [["d", identifier]]
|
builder.identifier = this.identifier() || ""
|
||||||
|
builder.title = this.title()
|
||||||
|
builder.location = this.location()
|
||||||
|
builder.content = this.content()
|
||||||
|
builder.start = this.start()
|
||||||
|
builder.end = this.end()
|
||||||
|
|
||||||
if (title) tags.push(["title", title])
|
return this.seedBuilder(builder)
|
||||||
if (location) tags.push(["location", location])
|
}
|
||||||
if (start != null) tags.push(["start", String(start)])
|
}
|
||||||
if (end != null) tags.push(["end", String(end)])
|
|
||||||
|
export class TimeEventBuilder extends EventBuilder {
|
||||||
|
static kind = EVENT_TIME
|
||||||
|
|
||||||
|
identifier = randomId()
|
||||||
|
title?: string
|
||||||
|
location?: string
|
||||||
|
content = ""
|
||||||
|
start?: number
|
||||||
|
end?: number
|
||||||
|
|
||||||
|
setTitle(title: string) {
|
||||||
|
this.title = title
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocation(location: string) {
|
||||||
|
this.location = location
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent(content: string) {
|
||||||
|
this.content = content
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setStart(start: number) {
|
||||||
|
this.start = start
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setEnd(end: number) {
|
||||||
|
this.end = end
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildContent() {
|
||||||
|
return this.content
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildTags() {
|
||||||
|
const tags: string[][] = [["d", this.identifier]]
|
||||||
|
|
||||||
|
if (this.title) tags.push(["title", this.title])
|
||||||
|
if (this.location) tags.push(["location", this.location])
|
||||||
|
if (this.start != null) tags.push(["start", String(this.start)])
|
||||||
|
if (this.end != null) tags.push(["end", String(this.end)])
|
||||||
|
|
||||||
// Derived day index for filtering: one "D" tag per epoch-day the event spans.
|
// Derived day index for filtering: one "D" tag per epoch-day the event spans.
|
||||||
if (start != null && end != null) {
|
if (this.start != null && this.end != null) {
|
||||||
for (const t of range(start, end, DAY)) {
|
for (const t of range(this.start, this.end, DAY)) {
|
||||||
tags.push(["D", String(Math.floor(t / DAY))])
|
tags.push(["D", String(Math.floor(t / DAY))])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {kind: this.kind, content, tags}
|
return tags
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import {uniq} from "@welshman/lib"
|
import {uniq} from "@welshman/lib"
|
||||||
import {TOPICS, getTopicTagValues, getAddressTagValues} from "@welshman/util"
|
import {TOPICS, getTopicTagValues, getAddressTagValues} from "@welshman/util"
|
||||||
import {EncryptableList} from "./List.js"
|
import {ListReader, ListBuilder} from "./List.js"
|
||||||
|
|
||||||
// NIP-51 kind-10015 interests/followed-topics list. Followed hashtags are stored
|
// NIP-51 kind-10015 interests/followed-topics list. Followed hashtags are stored
|
||||||
// as `t` tags; the list may also reference interest sets (kind 30015) via `a`
|
// as `t` tags; the list may also reference interest sets (kind 30015) via `a`
|
||||||
// tags. Extends EncryptableList so entries may be public (tags) or private
|
// tags. Extends ListReader/ListBuilder so entries may be public (tags) or private
|
||||||
// (encrypted content), treated as one merged set by the accessors.
|
// (encrypted content), treated as one merged set by the accessors.
|
||||||
export class TopicList extends EncryptableList {
|
export class TopicList extends ListReader {
|
||||||
readonly kind = TOPICS
|
static kind = TOPICS
|
||||||
|
|
||||||
topics() {
|
topics() {
|
||||||
return uniq(getTopicTagValues(this.tags()))
|
return uniq(getTopicTagValues(this.tags()))
|
||||||
@@ -17,10 +17,30 @@ export class TopicList extends EncryptableList {
|
|||||||
return uniq(getAddressTagValues(this.tags()))
|
return uniq(getAddressTagValues(this.tags()))
|
||||||
}
|
}
|
||||||
|
|
||||||
follow(topic: string) {
|
includes(topic: string) {
|
||||||
|
return this.topics().includes(topic)
|
||||||
|
}
|
||||||
|
|
||||||
|
builder() {
|
||||||
|
return this.seedList(new TopicListBuilder())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TopicListBuilder extends ListBuilder {
|
||||||
|
static kind = TOPICS
|
||||||
|
|
||||||
|
followPublicly(topic: string) {
|
||||||
return this.addPublicTags(["t", topic])
|
return this.addPublicTags(["t", topic])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
followPrivately(topic: string) {
|
||||||
|
return this.addPrivateTags(["t", topic])
|
||||||
|
}
|
||||||
|
|
||||||
|
follow(topic: string) {
|
||||||
|
return this.followPublicly(topic)
|
||||||
|
}
|
||||||
|
|
||||||
unfollow(topic: string) {
|
unfollow(topic: string) {
|
||||||
return this.removeTagsWithValue(topic)
|
return this.removeTagsWithValue(topic)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,5 @@
|
|||||||
import {ZAP_GOAL, getTagValue, getTagValues} from "@welshman/util"
|
import {ZAP_GOAL, getTagValue, getTagValues} from "@welshman/util"
|
||||||
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
import {EventReader, EventBuilder} from "./base.js"
|
||||||
import {DomainObject} from "./base.js"
|
|
||||||
|
|
||||||
export type ZapGoalValues = {
|
|
||||||
title: string
|
|
||||||
summary?: string
|
|
||||||
amount: number
|
|
||||||
relays: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeZapGoalValues = (values: Partial<ZapGoalValues> = {}): ZapGoalValues => ({
|
|
||||||
title: "",
|
|
||||||
amount: 0,
|
|
||||||
relays: [],
|
|
||||||
...values,
|
|
||||||
})
|
|
||||||
|
|
||||||
// NIP-75 kind-9041 zap goal. A fundraising target that drives flotilla's goals
|
// NIP-75 kind-9041 zap goal. A fundraising target that drives flotilla's goals
|
||||||
// feature: the goal title lives in `content` as plain text (not JSON), the body
|
// feature: the goal title lives in `content` as plain text (not JSON), the body
|
||||||
@@ -23,53 +8,104 @@ export const makeZapGoalValues = (values: Partial<ZapGoalValues> = {}): ZapGoalV
|
|||||||
// "relays" tags; room scoping is handled by the base `group` behavior tag.
|
// "relays" tags; room scoping is handled by the base `group` behavior tag.
|
||||||
// Non-addressable (referenced by event id via "#E"); the funding tally is
|
// Non-addressable (referenced by event id via "#E"); the funding tally is
|
||||||
// computed elsewhere from sibling zap receipts (ZAP_RESPONSE) and is not modeled
|
// computed elsewhere from sibling zap receipts (ZAP_RESPONSE) and is not modeled
|
||||||
// here. Tags-only metadata, so it extends DomainObject directly.
|
// here. Tags + plain-text content, so it extends EventReader/EventBuilder.
|
||||||
export class ZapGoal extends DomainObject<ZapGoalValues> {
|
export class ZapGoal extends EventReader {
|
||||||
readonly kind = ZAP_GOAL
|
static kind = ZAP_GOAL
|
||||||
values = makeZapGoalValues()
|
|
||||||
|
|
||||||
protected normalizeValues(values: Partial<ZapGoalValues> = {}) {
|
protected validate() {
|
||||||
return makeZapGoalValues(values)
|
if (!this.title()) {
|
||||||
}
|
throw new Error("ZapGoal requires a title")
|
||||||
|
|
||||||
protected parseEvent(event: TrustedEvent): Partial<ZapGoalValues> {
|
|
||||||
return {
|
|
||||||
title: event.content || "",
|
|
||||||
summary: getTagValue("summary", event.tags),
|
|
||||||
amount: parseInt(getTagValue("amount", event.tags) || "0") || 0,
|
|
||||||
relays: getTagValues("relays", event.tags),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected reservedTagKeys() {
|
||||||
|
return ["summary", "amount", "relays"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// The goal title is plain-text content, not JSON or encrypted.
|
||||||
title() {
|
title() {
|
||||||
return this.values.title
|
return this.event.content || ""
|
||||||
}
|
}
|
||||||
|
|
||||||
summary() {
|
summary() {
|
||||||
return this.values.summary
|
return getTagValue("summary", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
amount() {
|
amount() {
|
||||||
return this.values.amount
|
return parseInt(getTagValue("amount", this.event.tags) || "0") || 0
|
||||||
}
|
}
|
||||||
|
|
||||||
relays() {
|
relays() {
|
||||||
return this.values.relays
|
return getTagValues("relays", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
async toTemplate(): Promise<EventTemplate> {
|
builder() {
|
||||||
|
const builder = new ZapGoalBuilder(this.title())
|
||||||
|
|
||||||
|
builder.summary = this.summary()
|
||||||
|
builder.amount = this.amount()
|
||||||
|
builder.relays = this.relays()
|
||||||
|
|
||||||
|
return this.seedBuilder(builder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ZapGoalBuilder extends EventBuilder {
|
||||||
|
static kind = ZAP_GOAL
|
||||||
|
|
||||||
|
summary?: string
|
||||||
|
amount = 0
|
||||||
|
relays: string[] = []
|
||||||
|
|
||||||
|
constructor(public title = "") {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
setTitle(title: string) {
|
||||||
|
this.title = title
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setSummary(summary: string) {
|
||||||
|
this.summary = summary
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setAmount(amount: number) {
|
||||||
|
this.amount = amount
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setRelays(relays: string[]) {
|
||||||
|
this.relays = relays
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
protected validate() {
|
||||||
|
if (!this.title) {
|
||||||
|
throw new Error("ZapGoal requires a title")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildContent() {
|
||||||
|
return this.title
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildTags() {
|
||||||
const tags: string[][] = []
|
const tags: string[][] = []
|
||||||
|
|
||||||
if (this.values.summary) {
|
if (this.summary) tags.push(["summary", this.summary])
|
||||||
tags.push(["summary", this.values.summary])
|
|
||||||
}
|
|
||||||
|
|
||||||
tags.push(["amount", String(this.values.amount)])
|
tags.push(["amount", String(this.amount)])
|
||||||
|
|
||||||
for (const relay of this.values.relays) {
|
for (const relay of this.relays) {
|
||||||
tags.push(["relays", relay])
|
tags.push(["relays", relay])
|
||||||
}
|
}
|
||||||
|
|
||||||
return {kind: this.kind, content: this.values.title, tags}
|
return tags
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,87 +1,78 @@
|
|||||||
import {parseJson} from "@welshman/lib"
|
import {parseJson} from "@welshman/lib"
|
||||||
import {ZAP_RECEIPT, getTagValue, getInvoiceAmount} from "@welshman/util"
|
import {ZAP_RECEIPT, getTagValue, getInvoiceAmount} from "@welshman/util"
|
||||||
import type {EventTemplate, TrustedEvent, Zapper} from "@welshman/util"
|
import type {TrustedEvent, Zapper} from "@welshman/util"
|
||||||
import {DomainObject} from "./base.js"
|
import {EventReader, EventBuilder} from "./base.js"
|
||||||
|
|
||||||
export type ZapReceiptValues = {
|
// NIP-57 kind-9735 zap receipt. Relay/LN-generated, so it's effectively read-only:
|
||||||
bolt11?: string
|
// we parse the bolt11 invoice and the embedded kind-9734 zap request (carried in
|
||||||
invoiceAmount?: number
|
// the JSON "description" tag, which we expose as `plain`). The builder exists for
|
||||||
request?: TrustedEvent
|
// completeness but is rarely used in practice.
|
||||||
recipient?: string
|
export class ZapReceipt extends EventReader<TrustedEvent | undefined> {
|
||||||
eventId?: string
|
static kind = ZAP_RECEIPT
|
||||||
preimage?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeZapReceiptValues = (
|
// The embedded kind-9734 zap request lives in the "description" tag as JSON.
|
||||||
values: Partial<ZapReceiptValues> = {},
|
protected parsePlain() {
|
||||||
): ZapReceiptValues => ({...values})
|
const description = getTagValue("description", this.event.tags)
|
||||||
|
|
||||||
// NIP-57 kind-9735 zap receipt. Relay/LN-generated, so it's read-only in spirit:
|
return description ? parseJson(description) || undefined : undefined
|
||||||
// we parse the bolt11 invoice and the embedded kind-9734 request, and round-trip
|
|
||||||
// the source event when serializing.
|
|
||||||
export class ZapReceipt extends DomainObject<ZapReceiptValues> {
|
|
||||||
readonly kind = ZAP_RECEIPT
|
|
||||||
values = makeZapReceiptValues()
|
|
||||||
|
|
||||||
protected normalizeValues(values: Partial<ZapReceiptValues> = {}) {
|
|
||||||
return makeZapReceiptValues(values)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected parseEvent(event: TrustedEvent): Partial<ZapReceiptValues> {
|
protected reservedTagKeys() {
|
||||||
const bolt11 = getTagValue("bolt11", event.tags)
|
return ["bolt11", "description", "preimage", "p", "e"]
|
||||||
const description = getTagValue("description", event.tags)
|
|
||||||
|
|
||||||
return {
|
|
||||||
bolt11,
|
|
||||||
invoiceAmount: bolt11 ? getInvoiceAmount(bolt11) : undefined,
|
|
||||||
request: description ? parseJson(description) || undefined : undefined,
|
|
||||||
recipient: getTagValue("p", event.tags),
|
|
||||||
eventId: getTagValue("e", event.tags),
|
|
||||||
preimage: getTagValue("preimage", event.tags),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bolt11() {
|
bolt11() {
|
||||||
return this.values.bolt11
|
return getTagValue("bolt11", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invoice amount in millisats.
|
// Invoice amount in millisats. getInvoiceAmount throws on a malformed bolt11,
|
||||||
|
// so swallow that and report undefined (matches zapFromEvent's try/catch).
|
||||||
invoiceAmount() {
|
invoiceAmount() {
|
||||||
return this.values.invoiceAmount
|
const bolt11 = this.bolt11()
|
||||||
|
|
||||||
|
if (!bolt11) return undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
return getInvoiceAmount(bolt11)
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The embedded kind-9734 zap request.
|
// The embedded kind-9734 zap request.
|
||||||
request() {
|
request() {
|
||||||
return this.values.request
|
return this.plain
|
||||||
}
|
}
|
||||||
|
|
||||||
// The pubkey that requested the zap.
|
// The pubkey that requested the zap.
|
||||||
sender() {
|
sender() {
|
||||||
return this.values.request?.pubkey
|
return this.plain?.pubkey
|
||||||
}
|
}
|
||||||
|
|
||||||
recipient() {
|
recipient() {
|
||||||
return this.values.recipient
|
return getTagValue("p", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The zapped event, if any.
|
// The zapped event, if any.
|
||||||
eventId() {
|
eventId() {
|
||||||
return this.values.eventId
|
return getTagValue("e", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The comment the sender attached to the zap request.
|
// The comment the sender attached to the zap request.
|
||||||
comment() {
|
comment() {
|
||||||
return this.values.request?.content
|
return this.plain?.content
|
||||||
}
|
}
|
||||||
|
|
||||||
preimage() {
|
preimage() {
|
||||||
return this.values.preimage
|
return getTagValue("preimage", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Port of zapFromEvent's NIP-57 verification (util/src/Zaps.ts). Returns false
|
// Port of zapFromEvent's NIP-57 verification (util/src/Zaps.ts). Returns false
|
||||||
// unless the receipt is a legitimate, unforged zap from the given zapper.
|
// unless the receipt is a legitimate, unforged zap from the given zapper.
|
||||||
validate(zapper: Zapper): boolean {
|
verify(zapper: Zapper): boolean {
|
||||||
const {request, invoiceAmount, recipient} = this.values
|
const request = this.request()
|
||||||
|
const invoiceAmount = this.invoiceAmount()
|
||||||
|
const recipient = this.recipient()
|
||||||
|
|
||||||
// We need a parsed request and a parsed invoice amount to verify anything.
|
// We need a parsed request and a parsed invoice amount to verify anything.
|
||||||
if (!request || invoiceAmount === undefined) {
|
if (!request || invoiceAmount === undefined) {
|
||||||
@@ -102,7 +93,7 @@ export class ZapReceipt extends DomainObject<ZapReceiptValues> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If the recipient and the zapper are the same person, it's legit.
|
// If the recipient and the zapper are the same person, it's legit.
|
||||||
if (recipient === this.event?.pubkey) {
|
if (recipient === this.event.pubkey) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,19 +103,76 @@ export class ZapReceipt extends DomainObject<ZapReceiptValues> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify that the receipt actually came from the recipient's zapper.
|
// Verify that the receipt actually came from the recipient's zapper.
|
||||||
if (this.event?.pubkey !== zapper.nostrPubkey) {
|
if (this.event.pubkey !== zapper.nostrPubkey) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Receipts are relay/LN-generated; round-trip the source event verbatim.
|
builder() {
|
||||||
async toTemplate(): Promise<EventTemplate> {
|
const builder = new ZapReceiptBuilder()
|
||||||
return {
|
|
||||||
kind: this.kind,
|
builder.bolt11 = this.bolt11()
|
||||||
content: this.event?.content || "",
|
builder.description = getTagValue("description", this.event.tags)
|
||||||
tags: this.event?.tags || [],
|
builder.recipient = this.recipient()
|
||||||
}
|
builder.eventId = this.eventId()
|
||||||
|
builder.preimage = this.preimage()
|
||||||
|
|
||||||
|
return this.seedBuilder(builder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write side for a zap receipt. Receipts are normally relay/LN-generated, so this
|
||||||
|
// is rarely used; it builds the represented tags from raw draft fields.
|
||||||
|
export class ZapReceiptBuilder extends EventBuilder<TrustedEvent | undefined> {
|
||||||
|
static kind = ZAP_RECEIPT
|
||||||
|
|
||||||
|
bolt11?: string
|
||||||
|
description?: string
|
||||||
|
recipient?: string
|
||||||
|
eventId?: string
|
||||||
|
preimage?: string
|
||||||
|
|
||||||
|
setBolt11(bolt11: string) {
|
||||||
|
this.bolt11 = bolt11
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setDescription(description: string) {
|
||||||
|
this.description = description
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setRecipient(recipient: string) {
|
||||||
|
this.recipient = recipient
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setEventId(eventId: string) {
|
||||||
|
this.eventId = eventId
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreimage(preimage: string) {
|
||||||
|
this.preimage = preimage
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildTags() {
|
||||||
|
const tags: string[][] = []
|
||||||
|
|
||||||
|
if (this.bolt11) tags.push(["bolt11", this.bolt11])
|
||||||
|
if (this.description) tags.push(["description", this.description])
|
||||||
|
if (this.recipient) tags.push(["p", this.recipient])
|
||||||
|
if (this.eventId) tags.push(["e", this.eventId])
|
||||||
|
if (this.preimage) tags.push(["preimage", this.preimage])
|
||||||
|
|
||||||
|
return tags
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,146 +1,129 @@
|
|||||||
import {ZAP_REQUEST, getTag, getTagValue} from "@welshman/util"
|
import {ZAP_REQUEST, getTag, getTagValue} from "@welshman/util"
|
||||||
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
import {EventReader, EventBuilder} from "./base.js"
|
||||||
import {DomainObject} from "./base.js"
|
|
||||||
|
|
||||||
export type ZapRequestValues = {
|
|
||||||
amount?: number
|
|
||||||
lnurl?: string
|
|
||||||
recipient?: string
|
|
||||||
relays: string[]
|
|
||||||
eventId?: string
|
|
||||||
anonymous: boolean
|
|
||||||
content: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeZapRequestValues = (
|
|
||||||
values: Partial<ZapRequestValues> = {},
|
|
||||||
): ZapRequestValues => ({
|
|
||||||
relays: [],
|
|
||||||
anonymous: false,
|
|
||||||
content: "",
|
|
||||||
...values,
|
|
||||||
})
|
|
||||||
|
|
||||||
// NIP-57 kind-9734 zap request: zap metadata in tags plus an optional comment in
|
// NIP-57 kind-9734 zap request: zap metadata in tags plus an optional comment in
|
||||||
// content. `amount` is in millisats.
|
// content. `amount` is in millisats. Tags-only structured data; the comment lives
|
||||||
export class ZapRequest extends DomainObject<ZapRequestValues> {
|
// in the event content.
|
||||||
readonly kind = ZAP_REQUEST
|
export class ZapRequest extends EventReader {
|
||||||
values = makeZapRequestValues()
|
static kind = ZAP_REQUEST
|
||||||
|
|
||||||
protected normalizeValues(values: Partial<ZapRequestValues> = {}) {
|
protected reservedTagKeys() {
|
||||||
return makeZapRequestValues(values)
|
return ["amount", "lnurl", "p", "e", "relays", "anon"]
|
||||||
}
|
|
||||||
|
|
||||||
protected parseEvent(event: TrustedEvent): Partial<ZapRequestValues> {
|
|
||||||
const amount = getTagValue("amount", event.tags)
|
|
||||||
const relaysTag = getTag("relays", event.tags)
|
|
||||||
|
|
||||||
return {
|
|
||||||
amount: amount ? parseInt(amount) : undefined,
|
|
||||||
lnurl: getTagValue("lnurl", event.tags),
|
|
||||||
recipient: getTagValue("p", event.tags),
|
|
||||||
relays: relaysTag ? relaysTag.slice(1) : [],
|
|
||||||
eventId: getTagValue("e", event.tags),
|
|
||||||
anonymous: Boolean(event.tags.find(t => t[0] === "anon")),
|
|
||||||
content: event.content,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
amount() {
|
amount() {
|
||||||
return this.values.amount
|
const amount = getTagValue("amount", this.event.tags)
|
||||||
}
|
|
||||||
|
|
||||||
setAmount(amount: number) {
|
return amount ? parseInt(amount) : undefined
|
||||||
this.values.amount = amount
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lnurl() {
|
lnurl() {
|
||||||
return this.values.lnurl
|
return getTagValue("lnurl", this.event.tags)
|
||||||
}
|
|
||||||
|
|
||||||
setLnurl(lnurl: string) {
|
|
||||||
this.values.lnurl = lnurl
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
}
|
||||||
|
|
||||||
recipient() {
|
recipient() {
|
||||||
return this.values.recipient
|
return getTagValue("p", this.event.tags)
|
||||||
}
|
|
||||||
|
|
||||||
setRecipient(recipient: string) {
|
|
||||||
this.values.recipient = recipient
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
relays() {
|
|
||||||
return this.values.relays
|
|
||||||
}
|
|
||||||
|
|
||||||
setRelays(relays: string[]) {
|
|
||||||
this.values.relays = relays
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
}
|
||||||
|
|
||||||
eventId() {
|
eventId() {
|
||||||
return this.values.eventId
|
return getTagValue("e", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
setEventId(eventId: string) {
|
relays() {
|
||||||
this.values.eventId = eventId
|
const tag = getTag("relays", this.event.tags)
|
||||||
|
|
||||||
return this
|
return tag ? tag.slice(1) : []
|
||||||
}
|
}
|
||||||
|
|
||||||
isAnonymous() {
|
isAnonymous() {
|
||||||
return this.values.anonymous
|
return this.event.tags.some(t => t[0] === "anon")
|
||||||
}
|
|
||||||
|
|
||||||
setAnonymous(anonymous: boolean) {
|
|
||||||
this.values.anonymous = anonymous
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
}
|
||||||
|
|
||||||
comment() {
|
comment() {
|
||||||
return this.values.content
|
return this.event.content
|
||||||
}
|
}
|
||||||
|
|
||||||
setComment(content: string) {
|
builder() {
|
||||||
this.values.content = content
|
const builder = new ZapRequestBuilder()
|
||||||
|
|
||||||
|
builder.amount = this.amount()
|
||||||
|
builder.lnurl = this.lnurl()
|
||||||
|
builder.recipient = this.recipient()
|
||||||
|
builder.eventId = this.eventId()
|
||||||
|
builder.relays = this.relays()
|
||||||
|
builder.anonymous = this.isAnonymous()
|
||||||
|
builder.comment = this.comment()
|
||||||
|
|
||||||
|
return this.seedBuilder(builder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ZapRequestBuilder extends EventBuilder {
|
||||||
|
static kind = ZAP_REQUEST
|
||||||
|
|
||||||
|
amount?: number
|
||||||
|
lnurl?: string
|
||||||
|
recipient?: string
|
||||||
|
eventId?: string
|
||||||
|
relays: string[] = []
|
||||||
|
anonymous = false
|
||||||
|
comment = ""
|
||||||
|
|
||||||
|
setAmount(amount: number) {
|
||||||
|
this.amount = amount
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
async toTemplate(): Promise<EventTemplate> {
|
setLnurl(lnurl: string) {
|
||||||
const {amount, lnurl, recipient, relays, eventId, anonymous, content} = this.values
|
this.lnurl = lnurl
|
||||||
|
|
||||||
const tags: string[][] = [["relays", ...relays]]
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
if (amount !== undefined) {
|
setRecipient(recipient: string) {
|
||||||
tags.push(["amount", String(amount)])
|
this.recipient = recipient
|
||||||
}
|
|
||||||
|
|
||||||
if (lnurl !== undefined) {
|
return this
|
||||||
tags.push(["lnurl", lnurl])
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (recipient !== undefined) {
|
setEventId(eventId: string) {
|
||||||
tags.push(["p", recipient])
|
this.eventId = eventId
|
||||||
}
|
|
||||||
|
|
||||||
if (eventId) {
|
return this
|
||||||
tags.push(["e", eventId])
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (anonymous) {
|
setRelays(relays: string[]) {
|
||||||
tags.push(["anon"])
|
this.relays = relays
|
||||||
}
|
|
||||||
|
|
||||||
return {kind: this.kind, tags, content}
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setAnonymous(anonymous = true) {
|
||||||
|
this.anonymous = anonymous
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setComment(comment: string) {
|
||||||
|
this.comment = comment
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildTags() {
|
||||||
|
const tags: string[][] = [["relays", ...this.relays]]
|
||||||
|
|
||||||
|
if (this.amount !== undefined) tags.push(["amount", String(this.amount)])
|
||||||
|
if (this.lnurl !== undefined) tags.push(["lnurl", this.lnurl])
|
||||||
|
if (this.recipient !== undefined) tags.push(["p", this.recipient])
|
||||||
|
if (this.eventId) tags.push(["e", this.eventId])
|
||||||
|
if (this.anonymous) tags.push(["anon"])
|
||||||
|
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildContent() {
|
||||||
|
return this.comment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+157
-107
@@ -1,123 +1,152 @@
|
|||||||
import {stamp, prep, getTagValue} from "@welshman/util"
|
import {stamp, prep, getTagValue, getAddress} from "@welshman/util"
|
||||||
import type {EventTemplate, SignedEvent, HashedEvent, TrustedEvent} from "@welshman/util"
|
import type {EventTemplate, SignedEvent, HashedEvent, TrustedEvent} from "@welshman/util"
|
||||||
import type {ISigner} from "@welshman/signer"
|
import type {ISigner} from "@welshman/signer"
|
||||||
|
|
||||||
// The tag keys the base owns as publish-time behavior tags (group/protect/expires).
|
// Tag keys the base owns as publish-time behavior tags (group/protect/expires).
|
||||||
const BEHAVIOR_TAG_KEYS = ["h", "-", "expiration"]
|
export const BEHAVIOR_TAG_KEYS = ["h", "-", "expiration"]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The base class for domain objects.
|
* Read side of a domain object: a lazy, read-only view over a single nostr event.
|
||||||
*
|
*
|
||||||
* A domain object is an in-memory, mutable view of a single nostr event whose
|
* Construct via the static `fromEvent(event, signer?)`, which validates the kind,
|
||||||
* state lives in a plain `values` property. The pattern is "decrypt on parse,
|
* eagerly computes the `plain` representation (decrypting and/or parsing the
|
||||||
* mutate in memory, encrypt on serialize": concrete subclasses decrypt private
|
* event content — the one thing that must happen up front, since it can be
|
||||||
* content up front (in `parse`), expose synchronous accessors and mutators over
|
* async), runs `validate()` (throws on missing *required* tags, lenient
|
||||||
* `values`, and only touch the signer again when building an event.
|
* otherwise) and returns the reader. The event is always present, so identity
|
||||||
|
* accessors (`id`/`identifier`/`address`/…) are total — no optional handling.
|
||||||
*
|
*
|
||||||
* There are two construction entry points, both of which populate `values` and
|
* Everything else is read lazily through methods rather than parsed into fields.
|
||||||
* return `this`:
|
* Subclasses:
|
||||||
|
* - declare `static kind`
|
||||||
|
* - add domain accessors over `this.event.tags` (and `this.plain`)
|
||||||
|
* - override `parsePlain` when the event has encrypted/JSON content
|
||||||
|
* - override `validate` to enforce required tags
|
||||||
|
* - implement `builder()` to return the matching mutable builder
|
||||||
*
|
*
|
||||||
* - `init(values?)` builds a fresh object from raw input
|
* `plain` is generic: its shape varies per kind (decrypted private tags for
|
||||||
* - `parse(event, signer?)` reads (and, when possible, decrypts) an event
|
* lists, a parsed metadata object for JSON kinds, undefined for tag-only kinds),
|
||||||
*
|
* so each reader/builder knows what to do with it.
|
||||||
* Subclasses also implement `toTemplate(signer?)` to build (and, when needed,
|
|
||||||
* encrypt) the event template; the base provides the signing/wrapping
|
|
||||||
* orchestration on top of it.
|
|
||||||
*/
|
*/
|
||||||
export abstract class DomainObject<V extends Record<string, unknown>> {
|
export abstract class EventReader<P = undefined> {
|
||||||
abstract readonly kind: number
|
// Concrete subclasses declare `static kind = SOME_KIND`.
|
||||||
abstract values: V
|
plain!: P
|
||||||
event?: TrustedEvent
|
|
||||||
|
|
||||||
// Publish-time behavior tags, shared by every kind and applied to the template
|
constructor(readonly event: TrustedEvent) {}
|
||||||
// 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.
|
static async fromEvent<T extends EventReader<unknown>>(
|
||||||
// Handled the same way as the behavior tags above: parsed in the base (minus
|
this: (new (event: TrustedEvent) => T) & {kind: number},
|
||||||
// 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"]>,
|
|
||||||
): T {
|
|
||||||
return new this().init(values)
|
|
||||||
}
|
|
||||||
|
|
||||||
static parse<T extends DomainObject<Record<string, unknown>>>(
|
|
||||||
this: new () => T,
|
|
||||||
event: TrustedEvent,
|
event: TrustedEvent,
|
||||||
signer?: ISigner,
|
signer?: ISigner,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
return new this().parse(event, signer)
|
|
||||||
}
|
|
||||||
|
|
||||||
init(values: Partial<V> = {}) {
|
|
||||||
this.values = this.normalizeValues(values)
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
async parse(event: TrustedEvent, signer?: ISigner) {
|
|
||||||
if (event.kind !== this.kind) {
|
if (event.kind !== this.kind) {
|
||||||
throw new Error(`Expected a kind ${this.kind} event, got kind ${event.kind}`)
|
throw new Error(`Expected a kind ${this.kind} event, got kind ${event.kind}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.event = event
|
const reader = new this(event)
|
||||||
this.group = getTagValue("h", event.tags)
|
|
||||||
this.protect = event.tags.some(t => t[0] === "-")
|
|
||||||
|
|
||||||
const expiration = parseInt(getTagValue("expiration", event.tags) ?? "")
|
reader.plain = (await reader.parsePlain(signer)) as T["plain"]
|
||||||
this.expires = isNaN(expiration) ? undefined : expiration
|
reader.validate()
|
||||||
|
|
||||||
const reserved = this.reservedTagKeys()
|
return reader
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected abstract normalizeValues(values?: Partial<V>): V
|
// Eagerly compute the `plain` representation (decrypt and/or parse content).
|
||||||
|
// Default: nothing to compute. Runs once in fromEvent.
|
||||||
// Tag keys a subclass parses into dedicated attributes (and rebuilds in
|
protected async parsePlain(_signer?: ISigner): Promise<P> {
|
||||||
// toTemplate); the base behavior keys are always reserved too. Return null
|
return undefined as P
|
||||||
// (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(
|
// Throw on missing required tags. Lenient by default — keep "required" narrow.
|
||||||
event: TrustedEvent,
|
protected validate(): void {}
|
||||||
signer?: ISigner,
|
|
||||||
): Partial<V> | Promise<Partial<V>>
|
|
||||||
|
|
||||||
abstract toTemplate(signer?: ISigner): Promise<EventTemplate>
|
// Tag keys this kind represents via dedicated accessors; combined with the
|
||||||
|
// behavior keys, these are excluded from extraTags() so a reader -> builder ->
|
||||||
// Append the publish-time behavior tags to a freshly built template, just
|
// event round-trip doesn't lose or duplicate unknown tags. Default: none.
|
||||||
// before hashing/signing. A tag is skipped when the subclass's toTemplate
|
protected reservedTagKeys(): string[] {
|
||||||
// already emitted that key, so kinds that own "h" as core content (NIP-29
|
return []
|
||||||
// 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}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tags not represented by any accessor, for lossless carry-over into a builder.
|
||||||
|
extraTags(): string[][] {
|
||||||
|
const reserved = [...BEHAVIOR_TAG_KEYS, ...this.reservedTagKeys()]
|
||||||
|
|
||||||
|
return this.event.tags.filter(t => !reserved.includes(t[0]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identity accessors — total, since the event is always present.
|
||||||
|
id() {
|
||||||
|
return this.event.id
|
||||||
|
}
|
||||||
|
|
||||||
|
pubkey() {
|
||||||
|
return this.event.pubkey
|
||||||
|
}
|
||||||
|
|
||||||
|
createdAt() {
|
||||||
|
return this.event.created_at
|
||||||
|
}
|
||||||
|
|
||||||
|
identifier() {
|
||||||
|
return getTagValue("d", this.event.tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
address() {
|
||||||
|
return getAddress(this.event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Behavior-tag accessors.
|
||||||
|
group() {
|
||||||
|
return getTagValue("h", this.event.tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
protect() {
|
||||||
|
return this.event.tags.some(t => t[0] === "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
expires() {
|
||||||
|
const expiration = parseInt(getTagValue("expiration", this.event.tags) ?? "")
|
||||||
|
|
||||||
|
return isNaN(expiration) ? undefined : expiration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the behavior tags + carry-over tags onto a freshly created builder.
|
||||||
|
// Concrete readers call this from builder() after setting kind-specific fields.
|
||||||
|
protected seedBuilder<B extends EventBuilder<P>>(builder: B): B {
|
||||||
|
builder.group = this.group()
|
||||||
|
builder.protect = this.protect()
|
||||||
|
builder.expires = this.expires()
|
||||||
|
builder.extraTags = this.extraTags()
|
||||||
|
|
||||||
|
return builder
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract builder(): EventBuilder<P>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write side of a domain object: a mutable draft assembled via setters and
|
||||||
|
* emitted via `toTemplate`/`toRumor`/`toEvent`.
|
||||||
|
*
|
||||||
|
* A builder may sit in an invalid/incomplete state for as long as you like;
|
||||||
|
* validation only runs at emit time (`validate()` throws then). Construct a
|
||||||
|
* fresh builder with `new XBuilder()` and required params, or seed one from a
|
||||||
|
* reader via `reader.builder()` to edit a replaceable event.
|
||||||
|
*
|
||||||
|
* Subclasses:
|
||||||
|
* - declare `static kind`
|
||||||
|
* - hold draft fields + chainable setters
|
||||||
|
* - implement `buildTags()` (the represented tags; do NOT emit behavior tags)
|
||||||
|
* - override `buildContent` for JSON/encrypted content
|
||||||
|
* - override `validate` to throw on an invalid draft
|
||||||
|
*/
|
||||||
|
export abstract class EventBuilder<P = undefined> {
|
||||||
|
// Concrete subclasses declare `static kind = SOME_KIND`.
|
||||||
|
group?: string
|
||||||
|
protect = false
|
||||||
|
expires?: number
|
||||||
|
extraTags: string[][] = []
|
||||||
|
plain!: P
|
||||||
|
|
||||||
setGroup(group: string) {
|
setGroup(group: string) {
|
||||||
this.group = group
|
this.group = group
|
||||||
|
|
||||||
@@ -136,25 +165,46 @@ export abstract class DomainObject<V extends Record<string, unknown>> {
|
|||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The tags built from this kind's own fields. Must NOT include behavior tags
|
||||||
|
// (h/-/expiration) or the carried-over extraTags — the base appends those.
|
||||||
|
// Receives the signer (like buildContent) for kinds that need to encrypt tags.
|
||||||
|
protected abstract buildTags(signer?: ISigner): string[][] | Promise<string[][]>
|
||||||
|
|
||||||
|
// The event content. Override for JSON metadata or encrypted content.
|
||||||
|
protected buildContent(_signer?: ISigner): string | Promise<string> {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throw on an invalid draft. Runs only at emit time.
|
||||||
|
protected validate(): void {}
|
||||||
|
|
||||||
|
private behaviorTags(): string[][] {
|
||||||
|
const tags: string[][] = []
|
||||||
|
|
||||||
|
if (this.group) tags.push(["h", this.group])
|
||||||
|
if (this.protect) tags.push(["-"])
|
||||||
|
if (this.expires != null) tags.push(["expiration", String(this.expires)])
|
||||||
|
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
async toTemplate(signer?: ISigner): Promise<EventTemplate> {
|
||||||
|
this.validate()
|
||||||
|
|
||||||
|
const kind = (this.constructor as unknown as {kind: number}).kind
|
||||||
|
const content = await this.buildContent(signer)
|
||||||
|
const tags = [...(await this.buildTags(signer)), ...this.extraTags, ...this.behaviorTags()]
|
||||||
|
|
||||||
|
return {kind, content, tags}
|
||||||
|
}
|
||||||
|
|
||||||
async toRumor(signer: ISigner): Promise<HashedEvent> {
|
async toRumor(signer: ISigner): Promise<HashedEvent> {
|
||||||
const [template, pubkey] = await Promise.all([this.toTemplate(signer), signer.getPubkey()])
|
const [template, pubkey] = await Promise.all([this.toTemplate(signer), signer.getPubkey()])
|
||||||
|
|
||||||
return prep(this.addBehaviorTags(template), pubkey)
|
return prep(template, pubkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
async toEvent(signer: ISigner): Promise<SignedEvent> {
|
async toEvent(signer: ISigner): Promise<SignedEvent> {
|
||||||
const template = this.addBehaviorTags(await this.toTemplate(signer))
|
return signer.sign(stamp(await this.toTemplate(signer)))
|
||||||
|
|
||||||
return signer.sign(stamp(template))
|
|
||||||
}
|
|
||||||
|
|
||||||
get<K extends keyof V>(key: K): V[K] {
|
|
||||||
return this.values[key]
|
|
||||||
}
|
|
||||||
|
|
||||||
set<K extends keyof V>(key: K, value: V[K]) {
|
|
||||||
this.values[key] = value
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user