This commit is contained in:
@@ -4,3 +4,4 @@ docs/reference
|
|||||||
docs/.vitepress/cache
|
docs/.vitepress/cache
|
||||||
dist
|
dist
|
||||||
build
|
build
|
||||||
|
.git
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@pomade/core": "^0.2.1",
|
"@pomade/core": "^0.2.1",
|
||||||
|
"@welshman/domain": "workspace:*",
|
||||||
"@welshman/feeds": "workspace:*",
|
"@welshman/feeds": "workspace:*",
|
||||||
"@welshman/lib": "workspace:*",
|
"@welshman/lib": "workspace:*",
|
||||||
"@welshman/net": "workspace:*",
|
"@welshman/net": "workspace:*",
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
"typescript": "~5.8.0",
|
"typescript": "~5.8.0",
|
||||||
"@pomade/core": "^0.2.1",
|
"@pomade/core": "^0.2.1",
|
||||||
"@types/throttle-debounce": "^5.0.2",
|
"@types/throttle-debounce": "^5.0.2",
|
||||||
|
"@welshman/domain": "workspace:*",
|
||||||
"@welshman/feeds": "workspace:*",
|
"@welshman/feeds": "workspace:*",
|
||||||
"@welshman/lib": "workspace:*",
|
"@welshman/lib": "workspace:*",
|
||||||
"@welshman/net": "workspace:*",
|
"@welshman/net": "workspace:*",
|
||||||
|
|||||||
@@ -1,18 +1,8 @@
|
|||||||
import {
|
import {BLOCKED_RELAYS} from "@welshman/util"
|
||||||
BLOCKED_RELAYS,
|
import {BlockedRelayList, BlockedRelayListBuilder} from "@welshman/domain"
|
||||||
asDecryptedEvent,
|
|
||||||
readList,
|
|
||||||
getRelaysFromList,
|
|
||||||
makeList,
|
|
||||||
makeEvent,
|
|
||||||
addToListPublicly,
|
|
||||||
removeFromList,
|
|
||||||
} from "@welshman/util"
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
|
||||||
import {DerivedPlugin} from "./base.js"
|
import {DerivedPlugin} from "./base.js"
|
||||||
import type {Projection} from "./base.js"
|
import type {Projection} from "./base.js"
|
||||||
import {Network} from "./network.js"
|
import {Network} from "./network.js"
|
||||||
import {Router} from "./router.js"
|
|
||||||
import {User} from "../user.js"
|
import {User} from "../user.js"
|
||||||
import {Thunks} from "./thunk.js"
|
import {Thunks} from "./thunk.js"
|
||||||
import type {IApp} from "../app.js"
|
import type {IApp} from "../app.js"
|
||||||
@@ -22,12 +12,12 @@ import type {IApp} from "../app.js"
|
|||||||
* so it depends on the relay-list collection. Feeds `RelayStats.getQuality` so
|
* so it depends on the relay-list collection. Feeds `RelayStats.getQuality` so
|
||||||
* blocked relays are never selected.
|
* blocked relays are never selected.
|
||||||
*/
|
*/
|
||||||
export class BlockedRelayLists extends DerivedPlugin<ReturnType<typeof readList>> {
|
export class BlockedRelayLists extends DerivedPlugin<BlockedRelayList> {
|
||||||
constructor(app: IApp) {
|
constructor(app: IApp) {
|
||||||
super(app, {
|
super(app, {
|
||||||
filters: [{kinds: [BLOCKED_RELAYS]}],
|
filters: [{kinds: [BLOCKED_RELAYS]}],
|
||||||
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
eventToItem: BlockedRelayList.factory(app.user?.signer),
|
||||||
getKey: list => list.event.pubkey,
|
getKey: list => list.author(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,27 +26,22 @@ export class BlockedRelayLists extends DerivedPlugin<ReturnType<typeof readList>
|
|||||||
}
|
}
|
||||||
|
|
||||||
urls = (pubkey: string): Projection<string[]> =>
|
urls = (pubkey: string): Projection<string[]> =>
|
||||||
this.project(pubkey, list => getRelaysFromList(list))
|
this.project(pubkey, list => list?.urls() ?? [])
|
||||||
|
|
||||||
addRelay = async (url: string) => {
|
update = async (fn: (builder: BlockedRelayListBuilder) => void) => {
|
||||||
const user = User.require(this.app)
|
const user = User.require(this.app)
|
||||||
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: BLOCKED_RELAYS})
|
const builder = new BlockedRelayListBuilder(await this.forceLoad(user.pubkey))
|
||||||
const event = await addToListPublicly(list, ["relay", url]).reconcile(user.nip44EncryptToSelf)
|
|
||||||
|
fn(builder)
|
||||||
|
|
||||||
|
const event = await builder.toTemplate(user.signer)
|
||||||
|
|
||||||
return this.app.use(Thunks).publishToOutbox({event})
|
return this.app.use(Thunks).publishToOutbox({event})
|
||||||
}
|
}
|
||||||
|
|
||||||
removeRelay = async (url: string) => {
|
addUrl = (url: string) => this.update(builder => builder.addUrl(url))
|
||||||
const user = User.require(this.app)
|
|
||||||
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: BLOCKED_RELAYS})
|
|
||||||
const event = await removeFromList(list, url).reconcile(user.nip44EncryptToSelf)
|
|
||||||
|
|
||||||
return this.app.use(Thunks).publishToOutbox({event})
|
removeUrl = (url: string) => this.update(builder => builder.removeUrl(url))
|
||||||
}
|
|
||||||
|
|
||||||
setRelays = (urls: string[]) =>
|
setUrls = (urls: string[]) => this.update(builder => builder.setUrls(urls))
|
||||||
this.app.use(Thunks).publish({
|
|
||||||
event: makeEvent(BLOCKED_RELAYS, {tags: urls.map(url => ["relay", url])}),
|
|
||||||
relays: this.app.use(Router).FromUser().getUrls(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {BLOSSOM_SERVERS, asDecryptedEvent, readList} from "@welshman/util"
|
import {BLOSSOM_SERVERS} from "@welshman/util"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import {BlossomServerList} from "@welshman/domain"
|
||||||
import {DerivedPlugin} from "./base.js"
|
import {DerivedPlugin} from "./base.js"
|
||||||
import {Network} from "./network.js"
|
import {Network} from "./network.js"
|
||||||
import type {IApp} from "../app.js"
|
import type {IApp} from "../app.js"
|
||||||
@@ -8,12 +8,12 @@ import type {IApp} from "../app.js"
|
|||||||
* Blossom server lists (kind 10063), keyed by pubkey. Loaded via the outbox
|
* Blossom server lists (kind 10063), keyed by pubkey. Loaded via the outbox
|
||||||
* model (the author's write relays), so it depends on the relay-list collection.
|
* model (the author's write relays), so it depends on the relay-list collection.
|
||||||
*/
|
*/
|
||||||
export class BlossomServerLists extends DerivedPlugin<ReturnType<typeof readList>> {
|
export class BlossomServerLists extends DerivedPlugin<BlossomServerList> {
|
||||||
constructor(app: IApp) {
|
constructor(app: IApp) {
|
||||||
super(app, {
|
super(app, {
|
||||||
filters: [{kinds: [BLOSSOM_SERVERS]}],
|
filters: [{kinds: [BLOSSOM_SERVERS]}],
|
||||||
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
eventToItem: BlossomServerList.factory(app.user?.signer),
|
||||||
getKey: list => list.event.pubkey,
|
getKey: list => list.author(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
import {
|
import {FOLLOWS} from "@welshman/util"
|
||||||
FOLLOWS,
|
import {FollowList, FollowListBuilder} from "@welshman/domain"
|
||||||
asDecryptedEvent,
|
|
||||||
readList,
|
|
||||||
makeList,
|
|
||||||
addToListPublicly,
|
|
||||||
removeFromList,
|
|
||||||
} from "@welshman/util"
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
|
||||||
import {DerivedPlugin} from "./base.js"
|
import {DerivedPlugin} from "./base.js"
|
||||||
import {Network} from "./network.js"
|
import {Network} from "./network.js"
|
||||||
import {Thunks} from "./thunk.js"
|
import {Thunks} from "./thunk.js"
|
||||||
@@ -17,12 +10,12 @@ import type {IApp} from "../app.js"
|
|||||||
* Kind-3 follow lists, keyed by pubkey. Loaded via the outbox model (the
|
* Kind-3 follow lists, keyed by pubkey. Loaded via the outbox model (the
|
||||||
* author's write relays), so it depends on the relay-list collection.
|
* author's write relays), so it depends on the relay-list collection.
|
||||||
*/
|
*/
|
||||||
export class FollowLists extends DerivedPlugin<ReturnType<typeof readList>> {
|
export class FollowLists extends DerivedPlugin<FollowList> {
|
||||||
constructor(app: IApp) {
|
constructor(app: IApp) {
|
||||||
super(app, {
|
super(app, {
|
||||||
filters: [{kinds: [FOLLOWS]}],
|
filters: [{kinds: [FOLLOWS]}],
|
||||||
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
eventToItem: FollowList.factory(app.user?.signer),
|
||||||
getKey: followList => followList.event.pubkey,
|
getKey: followList => followList.author(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,19 +23,18 @@ export class FollowLists extends DerivedPlugin<ReturnType<typeof readList>> {
|
|||||||
return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [FOLLOWS]}, relayHints)
|
return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [FOLLOWS]}, relayHints)
|
||||||
}
|
}
|
||||||
|
|
||||||
follow = async (tag: string[]) => {
|
update = async (fn: (builder: FollowListBuilder) => void) => {
|
||||||
const user = User.require(this.app)
|
const user = User.require(this.app)
|
||||||
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: FOLLOWS})
|
const builder = new FollowListBuilder(await this.forceLoad(user.pubkey))
|
||||||
const event = await addToListPublicly(list, tag).reconcile(user.nip44EncryptToSelf)
|
|
||||||
|
fn(builder)
|
||||||
|
|
||||||
|
const event = await builder.toTemplate(user.signer)
|
||||||
|
|
||||||
return this.app.use(Thunks).publishToOutbox({event})
|
return this.app.use(Thunks).publishToOutbox({event})
|
||||||
}
|
}
|
||||||
|
|
||||||
unfollow = async (value: string) => {
|
follow = (tag: string[]) => this.update(builder => builder.addPublic(tag))
|
||||||
const user = User.require(this.app)
|
|
||||||
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: FOLLOWS})
|
|
||||||
const event = await removeFromList(list, value).reconcile(user.nip44EncryptToSelf)
|
|
||||||
|
|
||||||
return this.app.use(Thunks).publishToOutbox({event})
|
unfollow = (value: string) => this.update(builder => builder.removeFollow(value))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {tryCatch, batcher, postJson} from "@welshman/lib"
|
|||||||
import type {Maybe} from "@welshman/lib"
|
import type {Maybe} from "@welshman/lib"
|
||||||
import {queryProfile, displayNip05} from "@welshman/util"
|
import {queryProfile, displayNip05} from "@welshman/util"
|
||||||
import type {Handle} from "@welshman/util"
|
import type {Handle} from "@welshman/util"
|
||||||
|
import type {Profile} from "@welshman/domain"
|
||||||
import {deriveDeduplicated} from "@welshman/store"
|
import {deriveDeduplicated} from "@welshman/store"
|
||||||
import {LoadableMapPlugin, projection} from "./base.js"
|
import {LoadableMapPlugin, projection} from "./base.js"
|
||||||
import type {Projection} from "./base.js"
|
import type {Projection} from "./base.js"
|
||||||
@@ -60,16 +61,20 @@ export class Handles extends LoadableMapPlugin<Handle> {
|
|||||||
loadForPubkey = async (pubkey: string, relays: string[] = []) => {
|
loadForPubkey = async (pubkey: string, relays: string[] = []) => {
|
||||||
const $profile = await this.app.use(Profiles).load(pubkey, relays)
|
const $profile = await this.app.use(Profiles).load(pubkey, relays)
|
||||||
|
|
||||||
return $profile?.nip05 ? this.load($profile.nip05) : undefined
|
const nip05 = $profile?.nip05()
|
||||||
|
|
||||||
|
return nip05 ? this.load(nip05) : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
forPubkey = (pubkey: string, relays: string[] = []): Projection<Maybe<Handle>> => {
|
forPubkey = (pubkey: string, relays: string[] = []): Projection<Maybe<Handle>> => {
|
||||||
this.loadForPubkey(pubkey, relays)
|
this.loadForPubkey(pubkey, relays)
|
||||||
|
|
||||||
const read = ([$handlesByNip05, $profile]: [ReadonlyMap<string, Handle>, Maybe<{nip05?: string}>]) => {
|
const read = ([$handlesByNip05, $profile]: [ReadonlyMap<string, Handle>, Maybe<Profile>]) => {
|
||||||
if (!$profile?.nip05) return undefined
|
const nip05 = $profile?.nip05()
|
||||||
|
|
||||||
const handle = $handlesByNip05.get($profile.nip05)
|
if (!nip05) return undefined
|
||||||
|
|
||||||
|
const handle = $handlesByNip05.get(nip05)
|
||||||
|
|
||||||
if (handle?.pubkey !== pubkey) return undefined
|
if (handle?.pubkey !== pubkey) return undefined
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,8 @@
|
|||||||
import {
|
import {MESSAGING_RELAYS} from "@welshman/util"
|
||||||
MESSAGING_RELAYS,
|
import {MessagingRelayList, MessagingRelayListBuilder} from "@welshman/domain"
|
||||||
asDecryptedEvent,
|
|
||||||
readList,
|
|
||||||
getRelaysFromList,
|
|
||||||
makeList,
|
|
||||||
makeEvent,
|
|
||||||
addToListPublicly,
|
|
||||||
removeFromList,
|
|
||||||
} from "@welshman/util"
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
|
||||||
import {DerivedPlugin} from "./base.js"
|
import {DerivedPlugin} from "./base.js"
|
||||||
import type {Projection} from "./base.js"
|
import type {Projection} from "./base.js"
|
||||||
import {Network} from "./network.js"
|
import {Network} from "./network.js"
|
||||||
import {Router} from "./router.js"
|
|
||||||
import {User} from "../user.js"
|
import {User} from "../user.js"
|
||||||
import {Thunks} from "./thunk.js"
|
import {Thunks} from "./thunk.js"
|
||||||
import type {IApp} from "../app.js"
|
import type {IApp} from "../app.js"
|
||||||
@@ -22,12 +12,12 @@ import type {IApp} from "../app.js"
|
|||||||
* outbox model (the author's write relays), so it depends on the relay-list
|
* outbox model (the author's write relays), so it depends on the relay-list
|
||||||
* collection.
|
* collection.
|
||||||
*/
|
*/
|
||||||
export class MessagingRelayLists extends DerivedPlugin<ReturnType<typeof readList>> {
|
export class MessagingRelayLists extends DerivedPlugin<MessagingRelayList> {
|
||||||
constructor(app: IApp) {
|
constructor(app: IApp) {
|
||||||
super(app, {
|
super(app, {
|
||||||
filters: [{kinds: [MESSAGING_RELAYS]}],
|
filters: [{kinds: [MESSAGING_RELAYS]}],
|
||||||
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
eventToItem: MessagingRelayList.factory(app.user?.signer),
|
||||||
getKey: list => list.event.pubkey,
|
getKey: list => list.author(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,27 +26,22 @@ export class MessagingRelayLists extends DerivedPlugin<ReturnType<typeof readLis
|
|||||||
}
|
}
|
||||||
|
|
||||||
urls = (pubkey: string): Projection<string[]> =>
|
urls = (pubkey: string): Projection<string[]> =>
|
||||||
this.project(pubkey, list => getRelaysFromList(list))
|
this.project(pubkey, list => list?.urls() ?? [])
|
||||||
|
|
||||||
addRelay = async (url: string) => {
|
update = async (fn: (builder: MessagingRelayListBuilder) => void) => {
|
||||||
const user = User.require(this.app)
|
const user = User.require(this.app)
|
||||||
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: MESSAGING_RELAYS})
|
const builder = new MessagingRelayListBuilder(await this.forceLoad(user.pubkey))
|
||||||
const event = await addToListPublicly(list, ["relay", url]).reconcile(user.nip44EncryptToSelf)
|
|
||||||
|
fn(builder)
|
||||||
|
|
||||||
|
const event = await builder.toTemplate(user.signer)
|
||||||
|
|
||||||
return this.app.use(Thunks).publishToOutbox({event})
|
return this.app.use(Thunks).publishToOutbox({event})
|
||||||
}
|
}
|
||||||
|
|
||||||
removeRelay = async (url: string) => {
|
addUrl = (url: string) => this.update(builder => builder.addUrl(url))
|
||||||
const user = User.require(this.app)
|
|
||||||
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: MESSAGING_RELAYS})
|
|
||||||
const event = await removeFromList(list, url).reconcile(user.nip44EncryptToSelf)
|
|
||||||
|
|
||||||
return this.app.use(Thunks).publishToOutbox({event})
|
removeUrl = (url: string) => this.update(builder => builder.removeUrl(url))
|
||||||
}
|
|
||||||
|
|
||||||
setRelays = (urls: string[]) =>
|
setUrls = (urls: string[]) => this.update(builder => builder.setUrls(urls))
|
||||||
this.app.use(Thunks).publish({
|
|
||||||
event: makeEvent(MESSAGING_RELAYS, {tags: urls.map(url => ["relay", url])}),
|
|
||||||
relays: this.app.use(Router).FromUser().getUrls(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import {
|
import {nthEq} from "@welshman/lib"
|
||||||
MUTES,
|
import {MUTES} from "@welshman/util"
|
||||||
asDecryptedEvent,
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
readList,
|
import type {ISigner} from "@welshman/signer"
|
||||||
makeList,
|
import {MuteList, MuteListBuilder} from "@welshman/domain"
|
||||||
addToListPublicly,
|
|
||||||
addToListPrivately,
|
|
||||||
removeFromList,
|
|
||||||
updateList,
|
|
||||||
} from "@welshman/util"
|
|
||||||
import type {TrustedEvent, PublishedList} from "@welshman/util"
|
|
||||||
import {DerivedPlugin} from "./base.js"
|
import {DerivedPlugin} from "./base.js"
|
||||||
import type {IApp} from "../app.js"
|
import type {IApp} from "../app.js"
|
||||||
import {Network} from "./network.js"
|
import {Network} from "./network.js"
|
||||||
@@ -17,19 +11,47 @@ import {Plaintext} from "./plaintext.js"
|
|||||||
import {User} from "../user.js"
|
import {User} from "../user.js"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kind-10000 mute lists, keyed by pubkey. Mute lists carry private entries in
|
* A signer that decrypts via the app's plaintext cache (keyed by event), falling
|
||||||
* encrypted content, so decoding goes through the plaintext cache.
|
* back to the real signer. Lets `MuteList.fromEvent(event, signer)` reuse cached
|
||||||
|
* decryptions instead of re-decrypting. Returns undefined when there's no user,
|
||||||
|
* so the reader falls back to public-only.
|
||||||
*/
|
*/
|
||||||
export class MuteLists extends DerivedPlugin<PublishedList> {
|
const makeCachedSigner = (app: IApp, event: TrustedEvent): ISigner | undefined => {
|
||||||
|
const user = app.user
|
||||||
|
|
||||||
|
if (!user) return undefined
|
||||||
|
|
||||||
|
const {signer} = user
|
||||||
|
const decryptVia =
|
||||||
|
(fallback: (pubkey: string, message: string) => Promise<string>) =>
|
||||||
|
async (pubkey: string, message: string) =>
|
||||||
|
(await app.use(Plaintext).ensure(event)) ?? fallback(pubkey, message)
|
||||||
|
|
||||||
|
return {
|
||||||
|
sign: (event, options) => signer.sign(event, options),
|
||||||
|
getPubkey: () => signer.getPubkey(),
|
||||||
|
nip04: {
|
||||||
|
encrypt: (pubkey, message) => signer.nip04.encrypt(pubkey, message),
|
||||||
|
decrypt: decryptVia((pubkey, message) => signer.nip04.decrypt(pubkey, message)),
|
||||||
|
},
|
||||||
|
nip44: {
|
||||||
|
encrypt: (pubkey, message) => signer.nip44.encrypt(pubkey, message),
|
||||||
|
decrypt: decryptVia((pubkey, message) => signer.nip44.decrypt(pubkey, message)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kind-10000 mute lists, keyed by pubkey. Mute lists carry private entries in
|
||||||
|
* encrypted content, decoded through the plaintext cache (via a cache-backed
|
||||||
|
* signer passed to the reader).
|
||||||
|
*/
|
||||||
|
export class MuteLists extends DerivedPlugin<MuteList> {
|
||||||
constructor(app: IApp) {
|
constructor(app: IApp) {
|
||||||
super(app, {
|
super(app, {
|
||||||
filters: [{kinds: [MUTES]}],
|
filters: [{kinds: [MUTES]}],
|
||||||
eventToItem: async (event: TrustedEvent) => {
|
eventToItem: event => MuteList.fromEvent(event, makeCachedSigner(app, event)),
|
||||||
const content = await app.use(Plaintext).ensure(event)
|
getKey: mute => mute.author(),
|
||||||
|
|
||||||
return readList(asDecryptedEvent(event, {content}))
|
|
||||||
},
|
|
||||||
getKey: mute => mute.event.pubkey,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,35 +59,26 @@ export class MuteLists extends DerivedPlugin<PublishedList> {
|
|||||||
return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [MUTES]}, relayHints)
|
return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [MUTES]}, relayHints)
|
||||||
}
|
}
|
||||||
|
|
||||||
mutePublicly = async (tag: string[]) => {
|
update = async (fn: (builder: MuteListBuilder) => void) => {
|
||||||
const user = User.require(this.app)
|
const user = User.require(this.app)
|
||||||
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: MUTES})
|
const builder = new MuteListBuilder(await this.forceLoad(user.pubkey))
|
||||||
const event = await addToListPublicly(list, tag).reconcile(user.nip44EncryptToSelf)
|
|
||||||
|
fn(builder)
|
||||||
|
|
||||||
|
const event = await builder.toTemplate(user.signer)
|
||||||
|
|
||||||
return this.app.use(Thunks).publishToOutbox({event})
|
return this.app.use(Thunks).publishToOutbox({event})
|
||||||
}
|
}
|
||||||
|
|
||||||
mutePrivately = async (tag: string[]) => {
|
mutePublicly = (tag: string[]) => this.update(builder => builder.addPublic(tag))
|
||||||
const user = User.require(this.app)
|
|
||||||
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: MUTES})
|
|
||||||
const event = await addToListPrivately(list, tag).reconcile(user.nip44EncryptToSelf)
|
|
||||||
|
|
||||||
return this.app.use(Thunks).publishToOutbox({event})
|
mutePrivately = (tag: string[]) => this.update(builder => builder.addPrivate(tag))
|
||||||
}
|
|
||||||
|
|
||||||
unmute = async (value: string) => {
|
unmute = (value: string) => this.update(builder => builder.drop(nthEq(1, value)))
|
||||||
const user = User.require(this.app)
|
|
||||||
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: MUTES})
|
|
||||||
const event = await removeFromList(list, value).reconcile(user.nip44EncryptToSelf)
|
|
||||||
|
|
||||||
return this.app.use(Thunks).publishToOutbox({event})
|
setMutes = (updates: {publicTags?: string[][]; privateTags?: string[][]}) =>
|
||||||
}
|
this.update(builder => {
|
||||||
|
if (updates.publicTags) builder.clearPublic().addPublic(...updates.publicTags)
|
||||||
setMutes = async (updates: {publicTags?: string[][]; privateTags?: string[][]}) => {
|
if (updates.privateTags) builder.clearPrivate().addPrivate(...updates.privateTags)
|
||||||
const user = User.require(this.app)
|
})
|
||||||
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: MUTES})
|
|
||||||
const event = await updateList(list, updates).reconcile(user.nip44EncryptToSelf)
|
|
||||||
|
|
||||||
return this.app.use(Thunks).publishToOutbox({event})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {chunk, first} from "@welshman/lib"
|
import {chunk, first} from "@welshman/lib"
|
||||||
import {RelayMode, getRelaysFromList, sortEventsDesc} from "@welshman/util"
|
import {sortEventsDesc} from "@welshman/util"
|
||||||
import type {Filter} from "@welshman/util"
|
import type {Filter} from "@welshman/util"
|
||||||
import {request, publish, diff, pull, push, makeLoader} from "@welshman/net"
|
import {request, publish, diff, pull, push, makeLoader} from "@welshman/net"
|
||||||
import type {
|
import type {
|
||||||
@@ -44,7 +44,7 @@ export class Network {
|
|||||||
|
|
||||||
loadUsingOutbox = async (pubkey: string, filter: Filter = {}, relayHints: string[] = []) => {
|
loadUsingOutbox = async (pubkey: string, filter: Filter = {}, relayHints: string[] = []) => {
|
||||||
const filters: Filter[] = [{...filter, authors: [pubkey]}]
|
const filters: Filter[] = [{...filter, authors: [pubkey]}]
|
||||||
const writeRelays = getRelaysFromList(await this.app.use(RelayLists).load(pubkey), RelayMode.Write)
|
const writeRelays = (await this.app.use(RelayLists).load(pubkey))?.writeUrls() ?? []
|
||||||
const allRelays = this.app
|
const allRelays = this.app
|
||||||
.use(Router)
|
.use(Router)
|
||||||
.FromRelays([...relayHints, ...writeRelays])
|
.FromRelays([...relayHints, ...writeRelays])
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
import {
|
import {PINS} from "@welshman/util"
|
||||||
PINS,
|
import {PinList, PinListBuilder} from "@welshman/domain"
|
||||||
asDecryptedEvent,
|
|
||||||
readList,
|
|
||||||
makeList,
|
|
||||||
addToListPublicly,
|
|
||||||
removeFromList,
|
|
||||||
} from "@welshman/util"
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
|
||||||
import {DerivedPlugin} from "./base.js"
|
import {DerivedPlugin} from "./base.js"
|
||||||
import {Network} from "./network.js"
|
import {Network} from "./network.js"
|
||||||
import {Thunks} from "./thunk.js"
|
import {Thunks} from "./thunk.js"
|
||||||
@@ -17,12 +10,12 @@ import type {IApp} from "../app.js"
|
|||||||
* NIP-51 pin lists (kind 10001), keyed by pubkey. Loaded via the outbox model
|
* NIP-51 pin lists (kind 10001), keyed by pubkey. Loaded via the outbox model
|
||||||
* (the author's write relays), so it depends on the relay-list collection.
|
* (the author's write relays), so it depends on the relay-list collection.
|
||||||
*/
|
*/
|
||||||
export class PinLists extends DerivedPlugin<ReturnType<typeof readList>> {
|
export class PinLists extends DerivedPlugin<PinList> {
|
||||||
constructor(app: IApp) {
|
constructor(app: IApp) {
|
||||||
super(app, {
|
super(app, {
|
||||||
filters: [{kinds: [PINS]}],
|
filters: [{kinds: [PINS]}],
|
||||||
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
eventToItem: PinList.factory(app.user?.signer),
|
||||||
getKey: pins => pins.event.pubkey,
|
getKey: pins => pins.author(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,19 +23,18 @@ export class PinLists extends DerivedPlugin<ReturnType<typeof readList>> {
|
|||||||
return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [PINS]}, relayHints)
|
return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [PINS]}, relayHints)
|
||||||
}
|
}
|
||||||
|
|
||||||
pin = async (tag: string[]) => {
|
update = async (fn: (builder: PinListBuilder) => void) => {
|
||||||
const user = User.require(this.app)
|
const user = User.require(this.app)
|
||||||
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: PINS})
|
const builder = new PinListBuilder(await this.forceLoad(user.pubkey))
|
||||||
const event = await addToListPublicly(list, tag).reconcile(user.nip44EncryptToSelf)
|
|
||||||
|
fn(builder)
|
||||||
|
|
||||||
|
const event = await builder.toTemplate(user.signer)
|
||||||
|
|
||||||
return this.app.use(Thunks).publishToOutbox({event})
|
return this.app.use(Thunks).publishToOutbox({event})
|
||||||
}
|
}
|
||||||
|
|
||||||
unpin = async (value: string) => {
|
pin = (tag: string[]) => this.update(builder => builder.pinPublicly(tag))
|
||||||
const user = User.require(this.app)
|
|
||||||
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: PINS})
|
|
||||||
const event = await removeFromList(list, value).reconcile(user.nip44EncryptToSelf)
|
|
||||||
|
|
||||||
return this.app.use(Thunks).publishToOutbox({event})
|
unpin = (value: string) => this.update(builder => builder.unpin(value))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,25 @@
|
|||||||
import {derived, readable} from "svelte/store"
|
import {derived, readable} from "svelte/store"
|
||||||
import {
|
import {PROFILE} from "@welshman/util"
|
||||||
readProfile,
|
|
||||||
displayProfile,
|
|
||||||
displayPubkey,
|
|
||||||
isPublishedProfile,
|
|
||||||
createProfile,
|
|
||||||
editProfile,
|
|
||||||
PROFILE,
|
|
||||||
} from "@welshman/util"
|
|
||||||
import type {Profile} from "@welshman/util"
|
|
||||||
import type {Maybe} from "@welshman/lib"
|
import type {Maybe} from "@welshman/lib"
|
||||||
|
import {Profile, ProfileBuilder, displayPubkey} from "@welshman/domain"
|
||||||
import {DerivedPlugin, projection} from "./base.js"
|
import {DerivedPlugin, projection} from "./base.js"
|
||||||
import type {Projection} from "./base.js"
|
import type {Projection} from "./base.js"
|
||||||
import {Network} from "./network.js"
|
import {Network} from "./network.js"
|
||||||
import {Router} from "./router.js"
|
import {Router} from "./router.js"
|
||||||
import {Thunks} from "./thunk.js"
|
import {Thunks} from "./thunk.js"
|
||||||
|
import {User} from "../user.js"
|
||||||
import type {IApp} from "../app.js"
|
import type {IApp} from "../app.js"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kind-0 profiles, keyed by pubkey. Loaded via the outbox model (the author's
|
* Kind-0 profiles, keyed by pubkey. Loaded via the outbox model (the author's
|
||||||
* write relays), resolved through the relay-list collection at fetch time.
|
* write relays), resolved through the relay-list collection at fetch time.
|
||||||
*/
|
*/
|
||||||
export class Profiles extends DerivedPlugin<ReturnType<typeof readProfile>> {
|
export class Profiles extends DerivedPlugin<Profile> {
|
||||||
constructor(app: IApp) {
|
constructor(app: IApp) {
|
||||||
super(app, {
|
super(app, {
|
||||||
filters: [{kinds: [PROFILE]}],
|
filters: [{kinds: [PROFILE]}],
|
||||||
eventToItem: readProfile,
|
eventToItem: Profile.factory(app.user?.signer),
|
||||||
getKey: profile => profile.event.pubkey,
|
getKey: profile => profile.author(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,17 +27,21 @@ export class Profiles extends DerivedPlugin<ReturnType<typeof readProfile>> {
|
|||||||
return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [PROFILE]}, relayHints)
|
return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [PROFILE]}, relayHints)
|
||||||
}
|
}
|
||||||
|
|
||||||
publish = (profile: Profile) => {
|
// Publish the app user's kind-0, merging `values` over their current profile
|
||||||
|
// (preserving any unknown metadata fields and source tags).
|
||||||
|
publish = async (values: Record<string, any>) => {
|
||||||
|
const user = User.require(this.app)
|
||||||
const router = this.app.use(Router)
|
const router = this.app.use(Router)
|
||||||
const relays = router.merge([router.Index(), router.FromUser()]).getUrls()
|
const relays = router.merge([router.Index(), router.FromUser()]).getUrls()
|
||||||
const event = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile)
|
const builder = new ProfileBuilder(this.get(user.pubkey)).update(values)
|
||||||
|
const event = await builder.toTemplate()
|
||||||
|
|
||||||
return this.app.use(Thunks).publish({event, relays})
|
return this.app.use(Thunks).publish({event, relays})
|
||||||
}
|
}
|
||||||
|
|
||||||
display = (pubkey: string | undefined, ...args: any[]): Projection<string> => {
|
display = (pubkey: string | undefined, ...args: any[]): Projection<string> => {
|
||||||
const read = ($profile: Maybe<ReturnType<typeof readProfile>>) =>
|
const read = ($profile: Maybe<Profile>) =>
|
||||||
pubkey ? displayProfile($profile, displayPubkey(pubkey)) : ""
|
pubkey ? ($profile?.display() ?? displayPubkey(pubkey)) : ""
|
||||||
|
|
||||||
return projection(
|
return projection(
|
||||||
pubkey ? derived(this.one(pubkey, ...args), read) : readable(""),
|
pubkey ? derived(this.one(pubkey, ...args), read) : readable(""),
|
||||||
|
|||||||
@@ -1,17 +1,5 @@
|
|||||||
import {reject, nth, nthNe, nthEq, removeUndefined} from "@welshman/lib"
|
import {RELAYS, RelayMode, getRelayTagValues} from "@welshman/util"
|
||||||
import {
|
import {RelayList, RelayListBuilder} from "@welshman/domain"
|
||||||
RELAYS,
|
|
||||||
RelayMode,
|
|
||||||
asDecryptedEvent,
|
|
||||||
readList,
|
|
||||||
getRelaysFromList,
|
|
||||||
getRelayTags,
|
|
||||||
getListTags,
|
|
||||||
getRelayTagValues,
|
|
||||||
makeList,
|
|
||||||
makeEvent,
|
|
||||||
} from "@welshman/util"
|
|
||||||
import type {TrustedEvent, PublishedList} from "@welshman/util"
|
|
||||||
import {DerivedPlugin} from "./base.js"
|
import {DerivedPlugin} from "./base.js"
|
||||||
import type {Projection} from "./base.js"
|
import type {Projection} from "./base.js"
|
||||||
import {addMinimalFallbacks} from "@welshman/router"
|
import {addMinimalFallbacks} from "@welshman/router"
|
||||||
@@ -25,12 +13,12 @@ import type {IApp} from "../app.js"
|
|||||||
* NIP-65 relay lists, keyed by pubkey. This is the routing substrate every other
|
* NIP-65 relay lists, keyed by pubkey. This is the routing substrate every other
|
||||||
* outbox-model load depends on (see `Network.loadUsingOutbox`).
|
* outbox-model load depends on (see `Network.loadUsingOutbox`).
|
||||||
*/
|
*/
|
||||||
export class RelayLists extends DerivedPlugin<PublishedList> {
|
export class RelayLists extends DerivedPlugin<RelayList> {
|
||||||
constructor(app: IApp) {
|
constructor(app: IApp) {
|
||||||
super(app, {
|
super(app, {
|
||||||
filters: [{kinds: [RELAYS]}],
|
filters: [{kinds: [RELAYS]}],
|
||||||
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
eventToItem: RelayList.factory(app.user?.signer),
|
||||||
getKey: (list: PublishedList) => list.event.pubkey,
|
getKey: (list: RelayList) => list.author(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,40 +35,37 @@ export class RelayLists extends DerivedPlugin<PublishedList> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
urls = (pubkey: string): Projection<string[]> =>
|
urls = (pubkey: string): Projection<string[]> =>
|
||||||
this.project(pubkey, list => getRelaysFromList(list))
|
this.project(pubkey, list => list?.urls() ?? [])
|
||||||
|
|
||||||
readUrls = (pubkey: string): Projection<string[]> =>
|
readUrls = (pubkey: string): Projection<string[]> =>
|
||||||
this.project(pubkey, list => getRelaysFromList(list, RelayMode.Read))
|
this.project(pubkey, list => list?.readUrls() ?? [])
|
||||||
|
|
||||||
writeUrls = (pubkey: string): Projection<string[]> =>
|
writeUrls = (pubkey: string): Projection<string[]> =>
|
||||||
this.project(pubkey, list => getRelaysFromList(list, RelayMode.Write))
|
this.project(pubkey, list => list?.writeUrls() ?? [])
|
||||||
|
|
||||||
// NIP-65 relay-list mutations for the app's user
|
// NIP-65 relay-list mutations for the app's user
|
||||||
|
|
||||||
addRelay = async (url: string, mode: RelayMode) => {
|
update = async (fn: (builder: RelayListBuilder) => void) => {
|
||||||
const user = User.require(this.app)
|
const user = User.require(this.app)
|
||||||
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: RELAYS})
|
const builder = new RelayListBuilder(await this.forceLoad(user.pubkey))
|
||||||
const dup = getRelayTags(getListTags(list)).find(nthEq(1, url))
|
|
||||||
const tag = removeUndefined(["r", url, dup && dup[2] !== mode ? undefined : mode])
|
fn(builder)
|
||||||
const tags = [...list.publicTags.filter(nthNe(1, url)), tag]
|
|
||||||
const event = {kind: list.kind, content: list.event?.content || "", tags}
|
const event = await builder.toTemplate(user.signer)
|
||||||
|
|
||||||
return this.app.use(Thunks).publishToOutbox({event})
|
return this.app.use(Thunks).publishToOutbox({event})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addRelay = (url: string, mode: RelayMode) => this.update(builder => builder.addUrl(url, mode))
|
||||||
|
|
||||||
|
setReadRelays = (urls: string[]) => this.update(builder => builder.setReadUrls(urls))
|
||||||
|
|
||||||
|
setWriteRelays = (urls: string[]) => this.update(builder => builder.setWriteUrls(urls))
|
||||||
|
|
||||||
removeRelay = async (url: string, mode: RelayMode) => {
|
removeRelay = async (url: string, mode: RelayMode) => {
|
||||||
const user = User.require(this.app)
|
const user = User.require(this.app)
|
||||||
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: RELAYS})
|
const builder = new RelayListBuilder(await this.forceLoad(user.pubkey))
|
||||||
const dup = getRelayTags(getListTags(list)).find(nthEq(1, url))
|
const event = await builder.removeUrl(url, mode).toTemplate(user.signer)
|
||||||
const alt = mode === RelayMode.Read ? RelayMode.Write : RelayMode.Read
|
|
||||||
const tags = list.publicTags.filter(nthNe(1, url))
|
|
||||||
|
|
||||||
// If we had a duplicate that was used as the alt mode, keep the alt
|
|
||||||
if (dup && (!dup[2] || dup[2] === alt)) {
|
|
||||||
tags.push(["r", url, alt])
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = {kind: list.kind, content: list.event?.content || "", tags}
|
|
||||||
|
|
||||||
// publishToOutbox is outbox-only, so build relays here to also notify the
|
// publishToOutbox is outbox-only, so build relays here to also notify the
|
||||||
// removed relay of its removal
|
// removed relay of its removal
|
||||||
@@ -89,37 +74,15 @@ export class RelayLists extends DerivedPlugin<PublishedList> {
|
|||||||
return this.app.use(Thunks).publish({event, relays})
|
return this.app.use(Thunks).publish({event, relays})
|
||||||
}
|
}
|
||||||
|
|
||||||
setRelays = (tags: string[][]) => {
|
setRelays = async (tags: string[][]) => {
|
||||||
|
const user = User.require(this.app)
|
||||||
const router = this.app.use(Router)
|
const router = this.app.use(Router)
|
||||||
const event = makeEvent(RELAYS, {tags})
|
const builder = new RelayListBuilder(await this.forceLoad(user.pubkey))
|
||||||
|
const event = await builder.setTags(tags).toTemplate(user.signer)
|
||||||
const relays = router
|
const relays = router
|
||||||
.merge([router.Index(), router.FromRelays(getRelayTagValues(tags))])
|
.merge([router.Index(), router.FromRelays(getRelayTagValues(tags))])
|
||||||
.getUrls()
|
.getUrls()
|
||||||
|
|
||||||
return this.app.use(Thunks).publish({event, relays})
|
return this.app.use(Thunks).publish({event, relays})
|
||||||
}
|
}
|
||||||
|
|
||||||
setReadRelays = async (urls: string[]) => {
|
|
||||||
const user = User.require(this.app)
|
|
||||||
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: RELAYS})
|
|
||||||
const writeRelays = reject(nthEq(2, RelayMode.Read), getRelayTags(getListTags(list))).map(nth(1))
|
|
||||||
const writeTags = writeRelays.map(url => ["r", url, RelayMode.Write])
|
|
||||||
const readTags = urls.map(url => ["r", url, RelayMode.Read])
|
|
||||||
const tags = [...writeTags, ...readTags]
|
|
||||||
const event = {kind: list.kind, content: list.event?.content || "", tags}
|
|
||||||
|
|
||||||
return this.app.use(Thunks).publishToOutbox({event})
|
|
||||||
}
|
|
||||||
|
|
||||||
setWriteRelays = async (urls: string[]) => {
|
|
||||||
const user = User.require(this.app)
|
|
||||||
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: RELAYS})
|
|
||||||
const readRelays = reject(nthEq(2, RelayMode.Write), getRelayTags(getListTags(list))).map(nth(1))
|
|
||||||
const readTags = readRelays.map(url => ["r", url, RelayMode.Read])
|
|
||||||
const writeTags = urls.map(url => ["r", url, RelayMode.Write])
|
|
||||||
const tags = [...readTags, ...writeTags]
|
|
||||||
const event = {kind: list.kind, content: list.event?.content || "", tags}
|
|
||||||
|
|
||||||
return this.app.use(Thunks).publishToOutbox({event})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,30 @@
|
|||||||
import {
|
import {
|
||||||
makeRoomCreateEvent,
|
RoomCreateBuilder,
|
||||||
makeRoomDeleteEvent,
|
RoomDeleteBuilder,
|
||||||
makeRoomEditEvent,
|
RoomEditBuilder,
|
||||||
makeRoomJoinEvent,
|
RoomJoinBuilder,
|
||||||
makeRoomLeaveEvent,
|
RoomLeaveBuilder,
|
||||||
makeRoomAddMemberEvent,
|
RoomAddMemberBuilder,
|
||||||
makeRoomRemoveMemberEvent,
|
RoomRemoveMemberBuilder,
|
||||||
} from "@welshman/util"
|
} from "@welshman/domain"
|
||||||
import type {RoomMeta} from "@welshman/util"
|
|
||||||
import {Thunks} from "./thunk.js"
|
import {Thunks} from "./thunk.js"
|
||||||
import type {ThunkOptions} from "./thunk.js"
|
import type {ThunkOptions} from "./thunk.js"
|
||||||
import type {IApp} from "../app.js"
|
import type {IApp} from "../app.js"
|
||||||
|
|
||||||
|
// Room metadata used when publishing NIP-29 room events. `h` is the group id.
|
||||||
|
export type RoomMeta = {
|
||||||
|
h: string
|
||||||
|
name?: string
|
||||||
|
about?: string
|
||||||
|
picture?: string
|
||||||
|
pictureMeta?: string[]
|
||||||
|
isClosed?: boolean
|
||||||
|
isHidden?: boolean
|
||||||
|
isPrivate?: boolean
|
||||||
|
isRestricted?: boolean
|
||||||
|
livekit?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NIP-29 relay-based group (room) management. Each method publishes the relevant
|
* NIP-29 relay-based group (room) management. Each method publishes the relevant
|
||||||
* room event to the given relay as the app's user.
|
* room event to the given relay as the app's user.
|
||||||
@@ -22,19 +35,41 @@ export class Rooms {
|
|||||||
private publish = (url: string, event: ThunkOptions["event"]) =>
|
private publish = (url: string, event: ThunkOptions["event"]) =>
|
||||||
this.app.use(Thunks).publish({event, relays: [url]})
|
this.app.use(Thunks).publish({event, relays: [url]})
|
||||||
|
|
||||||
create = (url: string, room: RoomMeta) => this.publish(url, makeRoomCreateEvent(room))
|
create = async (url: string, room: RoomMeta) =>
|
||||||
|
this.publish(url, await new RoomCreateBuilder().setGroup(room.h).toTemplate())
|
||||||
|
|
||||||
delete = (url: string, room: RoomMeta) => this.publish(url, makeRoomDeleteEvent(room))
|
delete = async (url: string, room: RoomMeta) =>
|
||||||
|
this.publish(url, await new RoomDeleteBuilder().setGroup(room.h).toTemplate())
|
||||||
|
|
||||||
edit = (url: string, room: RoomMeta) => this.publish(url, makeRoomEditEvent(room))
|
edit = async (url: string, room: RoomMeta) => {
|
||||||
|
const builder = new RoomEditBuilder().setGroup(room.h)
|
||||||
|
|
||||||
join = (url: string, room: RoomMeta) => this.publish(url, makeRoomJoinEvent(room))
|
if (room.name) builder.setName(room.name)
|
||||||
|
if (room.about) builder.setAbout(room.about)
|
||||||
|
if (room.picture) builder.setPicture(room.picture, room.pictureMeta)
|
||||||
|
|
||||||
leave = (url: string, room: RoomMeta) => this.publish(url, makeRoomLeaveEvent(room))
|
builder
|
||||||
|
.setClosed(Boolean(room.isClosed))
|
||||||
|
.setHidden(Boolean(room.isHidden))
|
||||||
|
.setPrivate(Boolean(room.isPrivate))
|
||||||
|
.setRestricted(Boolean(room.isRestricted))
|
||||||
|
.setLivekit(Boolean(room.livekit))
|
||||||
|
|
||||||
addMember = (url: string, room: RoomMeta, pubkey: string) =>
|
return this.publish(url, await builder.toTemplate())
|
||||||
this.publish(url, makeRoomAddMemberEvent(room, pubkey))
|
}
|
||||||
|
|
||||||
removeMember = (url: string, room: RoomMeta, pubkey: string) =>
|
join = async (url: string, room: RoomMeta) =>
|
||||||
this.publish(url, makeRoomRemoveMemberEvent(room, pubkey))
|
this.publish(url, await new RoomJoinBuilder().setGroup(room.h).toTemplate())
|
||||||
|
|
||||||
|
leave = async (url: string, room: RoomMeta) =>
|
||||||
|
this.publish(url, await new RoomLeaveBuilder().setGroup(room.h).toTemplate())
|
||||||
|
|
||||||
|
addMember = async (url: string, room: RoomMeta, pubkey: string) =>
|
||||||
|
this.publish(url, await new RoomAddMemberBuilder().setGroup(room.h).addPubkey(pubkey).toTemplate())
|
||||||
|
|
||||||
|
removeMember = async (url: string, room: RoomMeta, pubkey: string) =>
|
||||||
|
this.publish(
|
||||||
|
url,
|
||||||
|
await new RoomRemoveMemberBuilder().setGroup(room.h).addPubkey(pubkey).toTemplate(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import {derived} from "svelte/store"
|
|||||||
import type {Readable} from "svelte/store"
|
import type {Readable} from "svelte/store"
|
||||||
import {dec, inc, sortBy} from "@welshman/lib"
|
import {dec, inc, sortBy} from "@welshman/lib"
|
||||||
import {PROFILE} from "@welshman/util"
|
import {PROFILE} from "@welshman/util"
|
||||||
import type {PublishedProfile, RelayProfile} from "@welshman/util"
|
import type {RelayProfile} from "@welshman/util"
|
||||||
|
import type {Profile} from "@welshman/domain"
|
||||||
import {throttled} from "@welshman/store"
|
import {throttled} from "@welshman/store"
|
||||||
import type {IApp} from "../app.js"
|
import type {IApp} from "../app.js"
|
||||||
import {Network} from "./network.js"
|
import {Network} from "./network.js"
|
||||||
@@ -63,26 +64,19 @@ export const createSearch = <V, T>(options: T[], opts: SearchOptions<V, T>): Sea
|
|||||||
* fires a debounced NIP-50 network search through the app's loader.
|
* fires a debounced NIP-50 network search through the app's loader.
|
||||||
*/
|
*/
|
||||||
export class Searches {
|
export class Searches {
|
||||||
profileSearch: Readable<Search<string, PublishedProfile>>
|
profileSearch: Readable<Search<string, Profile>>
|
||||||
topicSearch: Readable<Search<string, Topic>>
|
topicSearch: Readable<Search<string, Topic>>
|
||||||
relaySearch: Readable<Search<string, RelayProfile>>
|
relaySearch: Readable<Search<string, RelayProfile>>
|
||||||
|
|
||||||
constructor(readonly app: IApp) {
|
constructor(readonly app: IApp) {
|
||||||
this.profileSearch = derived(
|
this.profileSearch = derived(
|
||||||
[throttled(800, this.app.use(Profiles).all.$), throttled(800, this.app.use(Handles).index.$)],
|
[throttled(800, this.app.use(Profiles).all.$), throttled(800, this.app.use(Handles).index.$)],
|
||||||
([$profiles, $handlesByNip05]) => {
|
([$profiles, $handlesByNip05]) =>
|
||||||
// Remove invalid nip05's from profiles
|
createSearch($profiles, {
|
||||||
const options = $profiles.map(p => {
|
|
||||||
const isNip05Valid = !p.nip05 || $handlesByNip05.get(p.nip05)?.pubkey === p.event.pubkey
|
|
||||||
|
|
||||||
return isNip05Valid ? p : {...p, nip05: ""}
|
|
||||||
})
|
|
||||||
|
|
||||||
return createSearch(options, {
|
|
||||||
onSearch: this.searchProfiles,
|
onSearch: this.searchProfiles,
|
||||||
getValue: (profile: PublishedProfile) => profile.event.pubkey,
|
getValue: (profile: Profile) => profile.author(),
|
||||||
sortFn: ({score = 1, item}) => {
|
sortFn: ({score = 1, item}) => {
|
||||||
const wotScore = this.app.use(Wot).graph.get().get(item.event.pubkey) || 0
|
const wotScore = this.app.use(Wot).graph.get().get(item.author()) || 0
|
||||||
|
|
||||||
return dec(score) * inc(wotScore / (this.app.use(Wot).max.get() || 1))
|
return dec(score) * inc(wotScore / (this.app.use(Wot).max.get() || 1))
|
||||||
},
|
},
|
||||||
@@ -95,9 +89,23 @@ export class Searches {
|
|||||||
],
|
],
|
||||||
threshold: 0.3,
|
threshold: 0.3,
|
||||||
shouldSort: false,
|
shouldSort: false,
|
||||||
|
// Read fields off the domain reader's parsed `values`; only expose a
|
||||||
|
// nip05 that's verified against the loaded handle (anti-spoofing).
|
||||||
|
getFn: (profile: Profile, path) => {
|
||||||
|
const key = Array.isArray(path) ? path[0] : path
|
||||||
|
|
||||||
|
if (key === "nip05") {
|
||||||
|
const nip05 = profile.nip05()
|
||||||
|
|
||||||
|
return nip05 && $handlesByNip05.get(nip05)?.pubkey === profile.author()
|
||||||
|
? nip05
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return profile.values[key] ?? ""
|
||||||
},
|
},
|
||||||
})
|
|
||||||
},
|
},
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
this.topicSearch = derived(this.app.use(Topics).all, $topics =>
|
this.topicSearch = derived(this.app.use(Topics).all, $topics =>
|
||||||
|
|||||||
@@ -1,18 +1,8 @@
|
|||||||
import {
|
import {SEARCH_RELAYS} from "@welshman/util"
|
||||||
SEARCH_RELAYS,
|
import {SearchRelayList, SearchRelayListBuilder} from "@welshman/domain"
|
||||||
asDecryptedEvent,
|
|
||||||
readList,
|
|
||||||
getRelaysFromList,
|
|
||||||
makeList,
|
|
||||||
makeEvent,
|
|
||||||
addToListPublicly,
|
|
||||||
removeFromList,
|
|
||||||
} from "@welshman/util"
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
|
||||||
import {DerivedPlugin} from "./base.js"
|
import {DerivedPlugin} from "./base.js"
|
||||||
import type {Projection} from "./base.js"
|
import type {Projection} from "./base.js"
|
||||||
import {Network} from "./network.js"
|
import {Network} from "./network.js"
|
||||||
import {Router} from "./router.js"
|
|
||||||
import {User} from "../user.js"
|
import {User} from "../user.js"
|
||||||
import {Thunks} from "./thunk.js"
|
import {Thunks} from "./thunk.js"
|
||||||
import type {IApp} from "../app.js"
|
import type {IApp} from "../app.js"
|
||||||
@@ -22,12 +12,12 @@ import type {IApp} from "../app.js"
|
|||||||
* outbox model (the author's write relays), so it depends on the relay-list
|
* outbox model (the author's write relays), so it depends on the relay-list
|
||||||
* collection.
|
* collection.
|
||||||
*/
|
*/
|
||||||
export class SearchRelayLists extends DerivedPlugin<ReturnType<typeof readList>> {
|
export class SearchRelayLists extends DerivedPlugin<SearchRelayList> {
|
||||||
constructor(app: IApp) {
|
constructor(app: IApp) {
|
||||||
super(app, {
|
super(app, {
|
||||||
filters: [{kinds: [SEARCH_RELAYS]}],
|
filters: [{kinds: [SEARCH_RELAYS]}],
|
||||||
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
eventToItem: SearchRelayList.factory(app.user?.signer),
|
||||||
getKey: searchRelayList => searchRelayList.event.pubkey,
|
getKey: list => list.author(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,27 +26,22 @@ export class SearchRelayLists extends DerivedPlugin<ReturnType<typeof readList>>
|
|||||||
}
|
}
|
||||||
|
|
||||||
urls = (pubkey: string): Projection<string[]> =>
|
urls = (pubkey: string): Projection<string[]> =>
|
||||||
this.project(pubkey, list => getRelaysFromList(list))
|
this.project(pubkey, list => list?.urls() ?? [])
|
||||||
|
|
||||||
addRelay = async (url: string) => {
|
update = async (fn: (builder: SearchRelayListBuilder) => void) => {
|
||||||
const user = User.require(this.app)
|
const user = User.require(this.app)
|
||||||
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: SEARCH_RELAYS})
|
const builder = new SearchRelayListBuilder(await this.forceLoad(user.pubkey))
|
||||||
const event = await addToListPublicly(list, ["relay", url]).reconcile(user.nip44EncryptToSelf)
|
|
||||||
|
fn(builder)
|
||||||
|
|
||||||
|
const event = await builder.toTemplate(user.signer)
|
||||||
|
|
||||||
return this.app.use(Thunks).publishToOutbox({event})
|
return this.app.use(Thunks).publishToOutbox({event})
|
||||||
}
|
}
|
||||||
|
|
||||||
removeRelay = async (url: string) => {
|
addUrl = (url: string) => this.update(builder => builder.addUrl(url))
|
||||||
const user = User.require(this.app)
|
|
||||||
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: SEARCH_RELAYS})
|
|
||||||
const event = await removeFromList(list, url).reconcile(user.nip44EncryptToSelf)
|
|
||||||
|
|
||||||
return this.app.use(Thunks).publishToOutbox({event})
|
removeUrl = (url: string) => this.update(builder => builder.removeUrl(url))
|
||||||
}
|
|
||||||
|
|
||||||
setRelays = (urls: string[]) =>
|
setUrls = (urls: string[]) => this.update(builder => builder.setUrls(urls))
|
||||||
this.app.use(Thunks).publish({
|
|
||||||
event: makeEvent(SEARCH_RELAYS, {tags: urls.map(url => ["relay", url])}),
|
|
||||||
relays: this.app.use(Router).FromUser().getUrls(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import {readable, derived} from "svelte/store"
|
import {readable, derived} from "svelte/store"
|
||||||
import {max, throttle, addToMapKey, inc, dec} from "@welshman/lib"
|
import {max, throttle, addToMapKey, inc, dec} from "@welshman/lib"
|
||||||
import {getListTags, getPubkeyTagValues} from "@welshman/util"
|
import type {FollowList, MuteList} from "@welshman/domain"
|
||||||
import type {List} from "@welshman/util"
|
|
||||||
import type {IApp} from "../app.js"
|
import type {IApp} from "../app.js"
|
||||||
import {projection, projectFrom} from "./base.js"
|
import {projection, projectFrom} from "./base.js"
|
||||||
import type {Projection} from "./base.js"
|
import type {Projection} from "./base.js"
|
||||||
import {FollowLists} from "./follows.js"
|
import {FollowLists} from "./follows.js"
|
||||||
import {MuteLists} from "./mutes.js"
|
import {MuteLists} from "./mutes.js"
|
||||||
|
|
||||||
const listPubkeys = (list: List | undefined) => getPubkeyTagValues(getListTags(list))
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Web-of-trust scoring derived from follow and mute lists. The trust graph is
|
* Web-of-trust scoring derived from follow and mute lists. The trust graph is
|
||||||
* built from the perspective of the app's user (or, with no user, the union
|
* built from the perspective of the app's user (or, with no user, the union
|
||||||
@@ -32,8 +29,8 @@ export class Wot {
|
|||||||
const $followersByPubkey = new Map<string, Set<string>>()
|
const $followersByPubkey = new Map<string, Set<string>>()
|
||||||
|
|
||||||
for (const list of lists.values()) {
|
for (const list of lists.values()) {
|
||||||
for (const pubkey of getPubkeyTagValues(getListTags(list))) {
|
for (const pubkey of list?.pubkeys() ?? []) {
|
||||||
addToMapKey($followersByPubkey, pubkey, list.event.pubkey)
|
addToMapKey($followersByPubkey, pubkey, list.author())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,8 +45,8 @@ export class Wot {
|
|||||||
const $mutersByPubkey = new Map<string, Set<string>>()
|
const $mutersByPubkey = new Map<string, Set<string>>()
|
||||||
|
|
||||||
for (const list of lists.values()) {
|
for (const list of lists.values()) {
|
||||||
for (const pubkey of getPubkeyTagValues(getListTags(list))) {
|
for (const pubkey of list?.pubkeys() ?? []) {
|
||||||
addToMapKey($mutersByPubkey, pubkey, list.event.pubkey)
|
addToMapKey($mutersByPubkey, pubkey, list.author())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,14 +61,16 @@ export class Wot {
|
|||||||
const $muteLists = this.app.use(MuteLists).index.get()
|
const $muteLists = this.app.use(MuteLists).index.get()
|
||||||
const $pubkey = this.app.user?.pubkey
|
const $pubkey = this.app.user?.pubkey
|
||||||
const $graph = new Map<string, number>()
|
const $graph = new Map<string, number>()
|
||||||
const roots = $pubkey ? listPubkeys($followLists.get($pubkey)) : Array.from($followLists.keys())
|
const roots = $pubkey
|
||||||
|
? ($followLists.get($pubkey)?.pubkeys() ?? [])
|
||||||
|
: Array.from($followLists.keys())
|
||||||
|
|
||||||
for (const follow of roots) {
|
for (const follow of roots) {
|
||||||
for (const pubkey of listPubkeys($followLists.get(follow))) {
|
for (const pubkey of $followLists.get(follow)?.pubkeys() ?? []) {
|
||||||
$graph.set(pubkey, inc($graph.get(pubkey)))
|
$graph.set(pubkey, inc($graph.get(pubkey)))
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const pubkey of listPubkeys($muteLists.get(follow))) {
|
for (const pubkey of $muteLists.get(follow)?.pubkeys() ?? []) {
|
||||||
$graph.set(pubkey, dec($graph.get(pubkey)))
|
$graph.set(pubkey, dec($graph.get(pubkey)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,18 +95,18 @@ export class Wot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
follows = (pubkey: string): Projection<string[]> =>
|
follows = (pubkey: string): Projection<string[]> =>
|
||||||
projectFrom(this.app.use(FollowLists).index, $lists => listPubkeys($lists.get(pubkey)))
|
projectFrom(this.app.use(FollowLists).index, $lists => $lists.get(pubkey)?.pubkeys() ?? [])
|
||||||
|
|
||||||
mutes = (pubkey: string): Projection<string[]> =>
|
mutes = (pubkey: string): Projection<string[]> =>
|
||||||
projectFrom(this.app.use(MuteLists).index, $lists => listPubkeys($lists.get(pubkey)))
|
projectFrom(this.app.use(MuteLists).index, $lists => $lists.get(pubkey)?.pubkeys() ?? [])
|
||||||
|
|
||||||
network = (pubkey: string): Projection<string[]> =>
|
network = (pubkey: string): Projection<string[]> =>
|
||||||
projectFrom(this.app.use(FollowLists).index, $lists => {
|
projectFrom(this.app.use(FollowLists).index, $lists => {
|
||||||
const pubkeys = new Set(listPubkeys($lists.get(pubkey)))
|
const pubkeys = new Set($lists.get(pubkey)?.pubkeys() ?? [])
|
||||||
const network = new Set<string>()
|
const network = new Set<string>()
|
||||||
|
|
||||||
for (const follow of pubkeys) {
|
for (const follow of pubkeys) {
|
||||||
for (const tpk of listPubkeys($lists.get(follow))) {
|
for (const tpk of $lists.get(follow)?.pubkeys() ?? []) {
|
||||||
if (!pubkeys.has(tpk)) {
|
if (!pubkeys.has(tpk)) {
|
||||||
network.add(tpk)
|
network.add(tpk)
|
||||||
}
|
}
|
||||||
@@ -125,15 +124,18 @@ export class Wot {
|
|||||||
|
|
||||||
followsWhoFollow = (pubkey: string, target: string): Projection<string[]> =>
|
followsWhoFollow = (pubkey: string, target: string): Projection<string[]> =>
|
||||||
projectFrom(this.app.use(FollowLists).index, $lists =>
|
projectFrom(this.app.use(FollowLists).index, $lists =>
|
||||||
listPubkeys($lists.get(pubkey)).filter(other =>
|
($lists.get(pubkey)?.pubkeys() ?? []).filter(other =>
|
||||||
listPubkeys($lists.get(other)).includes(target),
|
($lists.get(other)?.pubkeys() ?? []).includes(target),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
followsWhoMute = (pubkey: string, target: string): Projection<string[]> => {
|
followsWhoMute = (pubkey: string, target: string): Projection<string[]> => {
|
||||||
const read = ($follows: ReadonlyMap<string, List>, $mutes: ReadonlyMap<string, List>) =>
|
const read = (
|
||||||
listPubkeys($follows.get(pubkey)).filter(other =>
|
$follows: ReadonlyMap<string, FollowList>,
|
||||||
listPubkeys($mutes.get(other)).includes(target),
|
$mutes: ReadonlyMap<string, MuteList>,
|
||||||
|
) =>
|
||||||
|
($follows.get(pubkey)?.pubkeys() ?? []).filter(other =>
|
||||||
|
($mutes.get(other)?.pubkeys() ?? []).includes(target),
|
||||||
)
|
)
|
||||||
|
|
||||||
return projection(
|
return projection(
|
||||||
@@ -147,8 +149,8 @@ export class Wot {
|
|||||||
|
|
||||||
wotScore = (pubkey: string, target: string): Projection<number> => {
|
wotScore = (pubkey: string, target: string): Projection<number> => {
|
||||||
const read = (
|
const read = (
|
||||||
$follows: ReadonlyMap<string, List>,
|
$follows: ReadonlyMap<string, FollowList>,
|
||||||
$mutes: ReadonlyMap<string, List>,
|
$mutes: ReadonlyMap<string, MuteList>,
|
||||||
$followers: ReadonlyMap<string, Set<string>>,
|
$followers: ReadonlyMap<string, Set<string>>,
|
||||||
$muters: ReadonlyMap<string, Set<string>>,
|
$muters: ReadonlyMap<string, Set<string>>,
|
||||||
) => {
|
) => {
|
||||||
@@ -156,10 +158,10 @@ export class Wot {
|
|||||||
let mutes: string[]
|
let mutes: string[]
|
||||||
|
|
||||||
if (pubkey) {
|
if (pubkey) {
|
||||||
const theirFollows = listPubkeys($follows.get(pubkey))
|
const theirFollows = $follows.get(pubkey)?.pubkeys() ?? []
|
||||||
|
|
||||||
follows = theirFollows.filter(other => listPubkeys($follows.get(other)).includes(target))
|
follows = theirFollows.filter(other => ($follows.get(other)?.pubkeys() ?? []).includes(target))
|
||||||
mutes = theirFollows.filter(other => listPubkeys($mutes.get(other)).includes(target))
|
mutes = theirFollows.filter(other => ($mutes.get(other)?.pubkeys() ?? []).includes(target))
|
||||||
} else {
|
} else {
|
||||||
follows = Array.from($followers.get(target) || [])
|
follows = Array.from($followers.get(target) || [])
|
||||||
mutes = Array.from($muters.get(target) || [])
|
mutes = Array.from($muters.get(target) || [])
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {get, writable} from "svelte/store"
|
import {get, writable} from "svelte/store"
|
||||||
import {TaskQueue, uniq, now} from "@welshman/lib"
|
import {TaskQueue, uniq, now} from "@welshman/lib"
|
||||||
import {getPubkeyTagValues, getRelaysFromList, prep} from "@welshman/util"
|
import {getPubkeyTagValues, prep} from "@welshman/util"
|
||||||
import type {TrustedEvent, SignedEvent, EventTemplate} from "@welshman/util"
|
import type {TrustedEvent, SignedEvent, EventTemplate} from "@welshman/util"
|
||||||
import {Nip59} from "@welshman/signer"
|
import {Nip59} from "@welshman/signer"
|
||||||
import {MergedThunk, Thunks} from "./thunk.js"
|
import {MergedThunk, Thunks} from "./thunk.js"
|
||||||
@@ -70,7 +70,7 @@ export class Wraps {
|
|||||||
return new MergedThunk(
|
return new MergedThunk(
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
uniq(recipients).map(async recipient => {
|
uniq(recipients).map(async recipient => {
|
||||||
const relays = getRelaysFromList(await this.app.use(MessagingRelayLists).load(recipient))
|
const relays = (await this.app.use(MessagingRelayLists).load(recipient))?.urls() ?? []
|
||||||
|
|
||||||
return this.app.use(Thunks).publish({event: stableEvent, relays, recipient, ...options})
|
return this.app.use(Thunks).publish({event: stableEvent, relays, recipient, ...options})
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import type {Maybe} from "@welshman/lib"
|
import type {Maybe} from "@welshman/lib"
|
||||||
import {getTagValue, getZapSplits, zapFromEvent} from "@welshman/util"
|
import {getTagValue, getZapSplits, zapFromEvent} from "@welshman/util"
|
||||||
import type {Zapper, Zap, TrustedEvent} from "@welshman/util"
|
import type {Zapper, Zap, TrustedEvent} from "@welshman/util"
|
||||||
|
import type {Profile} from "@welshman/domain"
|
||||||
import {deriveDeduplicated, deriveDeduplicatedByValue} from "@welshman/store"
|
import {deriveDeduplicated, deriveDeduplicatedByValue} from "@welshman/store"
|
||||||
import {LoadableMapPlugin, projection} from "./base.js"
|
import {LoadableMapPlugin, projection} from "./base.js"
|
||||||
import type {Projection} from "./base.js"
|
import type {Projection} from "./base.js"
|
||||||
@@ -66,14 +67,19 @@ export class Zappers extends LoadableMapPlugin<Zapper> {
|
|||||||
loadForPubkey = async (pubkey: string, relays: string[] = []) => {
|
loadForPubkey = async (pubkey: string, relays: string[] = []) => {
|
||||||
const $profile = await this.app.use(Profiles).load(pubkey, relays)
|
const $profile = await this.app.use(Profiles).load(pubkey, relays)
|
||||||
|
|
||||||
return $profile?.lnurl ? this.load($profile.lnurl) : undefined
|
const lnurl = $profile?.lnurl()
|
||||||
|
|
||||||
|
return lnurl ? this.load(lnurl) : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
forPubkey = (pubkey: string, relays: string[] = []): Projection<Maybe<Zapper>> => {
|
forPubkey = (pubkey: string, relays: string[] = []): Projection<Maybe<Zapper>> => {
|
||||||
this.loadForPubkey(pubkey, relays)
|
this.loadForPubkey(pubkey, relays)
|
||||||
|
|
||||||
const read = ([$zappersByLnurl, $profile]: [ReadonlyMap<string, Zapper>, Maybe<{lnurl?: string}>]) =>
|
const read = ([$zappersByLnurl, $profile]: [ReadonlyMap<string, Zapper>, Maybe<Profile>]) => {
|
||||||
$profile?.lnurl ? $zappersByLnurl.get($profile.lnurl) : undefined
|
const lnurl = $profile?.lnurl()
|
||||||
|
|
||||||
|
return lnurl ? $zappersByLnurl.get(lnurl) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
return projection(
|
return projection(
|
||||||
deriveDeduplicated([this.index.$, this.app.use(Profiles).one(pubkey, relays)], read),
|
deriveDeduplicated([this.index.$, this.app.use(Profiles).one(pubkey, relays)], read),
|
||||||
@@ -120,12 +126,12 @@ export class Zappers extends LoadableMapPlugin<Zapper> {
|
|||||||
|
|
||||||
const read = (values: any[]) => {
|
const read = (values: any[]) => {
|
||||||
const $zappersByLnurl = values[0] as Map<string, Zapper>
|
const $zappersByLnurl = values[0] as Map<string, Zapper>
|
||||||
const $profiles = values.slice(1) as Array<{lnurl?: string} | undefined>
|
const $profiles = values.slice(1) as Array<Profile | undefined>
|
||||||
|
|
||||||
const zapperByPubkey = new Map<string, Zapper>()
|
const zapperByPubkey = new Map<string, Zapper>()
|
||||||
|
|
||||||
splits.forEach((split, i) => {
|
splits.forEach((split, i) => {
|
||||||
const lnurl = $profiles[i]?.lnurl
|
const lnurl = $profiles[i]?.lnurl()
|
||||||
const zapper = lnurl ? $zappersByLnurl.get(lnurl) : undefined
|
const zapper = lnurl ? $zappersByLnurl.get(lnurl) : undefined
|
||||||
|
|
||||||
if (zapper) zapperByPubkey.set(split.pubkey, zapper)
|
if (zapper) zapperByPubkey.set(split.pubkey, zapper)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
"@welshman/domain": ["../domain/src/index.js"],
|
||||||
"@welshman/feeds": ["../feeds/src/index.js"],
|
"@welshman/feeds": ["../feeds/src/index.js"],
|
||||||
"@welshman/lib": ["../lib/src/index.js"],
|
"@welshman/lib": ["../lib/src/index.js"],
|
||||||
"@welshman/net": ["../net/src/index.js"],
|
"@welshman/net": ["../net/src/index.js"],
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ describe("BlockedRelayList", () => {
|
|||||||
|
|
||||||
it("builds from a fresh builder and normalizes urls", async () => {
|
it("builds from a fresh builder and normalizes urls", async () => {
|
||||||
const tmpl = await new BlockedRelayListBuilder()
|
const tmpl = await new BlockedRelayListBuilder()
|
||||||
.addRelay("wss://relay.one.example")
|
.addUrl("wss://relay.one.example")
|
||||||
.toTemplate(signer)
|
.toTemplate(signer)
|
||||||
|
|
||||||
expect(getTagValues("relay", tmpl.tags)).toEqual([normalizeRelayUrl("wss://relay.one.example")])
|
expect(getTagValues("relay", tmpl.tags)).toEqual([normalizeRelayUrl("wss://relay.one.example")])
|
||||||
@@ -69,14 +69,14 @@ describe("BlockedRelayList", () => {
|
|||||||
const event = makeEvent({tags: [["relay", r1]]})
|
const event = makeEvent({tags: [["relay", r1]]})
|
||||||
const list = await BlockedRelayList.fromEvent(event)
|
const list = await BlockedRelayList.fromEvent(event)
|
||||||
|
|
||||||
const tmpl = await list.builder().setRelays([r2, r3]).toTemplate(signer)
|
const tmpl = await list.builder().setUrls([r2, r3]).toTemplate(signer)
|
||||||
|
|
||||||
expect(getTagValues("relay", tmpl.tags).sort()).toEqual([r2, r3].sort())
|
expect(getTagValues("relay", tmpl.tags).sort()).toEqual([r2, r3].sort())
|
||||||
})
|
})
|
||||||
|
|
||||||
it("round-trips public and private entries through encryption", async () => {
|
it("round-trips public and private entries through encryption", async () => {
|
||||||
const event = await new BlockedRelayListBuilder()
|
const event = await new BlockedRelayListBuilder()
|
||||||
.addRelay(r1)
|
.addUrl(r1)
|
||||||
.addPrivate(["relay", r2])
|
.addPrivate(["relay", r2])
|
||||||
.toEvent(signer)
|
.toEvent(signer)
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ describe("BlossomServerList", () => {
|
|||||||
|
|
||||||
const list = await BlossomServerList.fromEvent(event)
|
const list = await BlossomServerList.fromEvent(event)
|
||||||
|
|
||||||
expect(list.servers().sort()).toEqual([norm(s1), norm(s2)].sort())
|
expect(list.urls().sort()).toEqual([norm(s1), norm(s2)].sort())
|
||||||
expect(list.includes(s1)).toBe(true)
|
expect(list.includes(s1)).toBe(true)
|
||||||
expect(list.includes(s3)).toBe(false)
|
expect(list.includes(s3)).toBe(false)
|
||||||
})
|
})
|
||||||
@@ -60,7 +60,7 @@ describe("BlossomServerList", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("builds from a fresh builder and normalizes urls", async () => {
|
it("builds from a fresh builder and normalizes urls", async () => {
|
||||||
const tmpl = await new BlossomServerListBuilder().addServer(s1).toTemplate(signer)
|
const tmpl = await new BlossomServerListBuilder().addUrl(s1).toTemplate(signer)
|
||||||
|
|
||||||
expect(getTagValues("server", tmpl.tags)).toEqual([norm(s1)])
|
expect(getTagValues("server", tmpl.tags)).toEqual([norm(s1)])
|
||||||
})
|
})
|
||||||
@@ -69,14 +69,14 @@ describe("BlossomServerList", () => {
|
|||||||
const event = makeEvent({tags: [["server", s1]]})
|
const event = makeEvent({tags: [["server", s1]]})
|
||||||
const list = await BlossomServerList.fromEvent(event)
|
const list = await BlossomServerList.fromEvent(event)
|
||||||
|
|
||||||
const tmpl = await list.builder().setServers([s2, s3]).toTemplate(signer)
|
const tmpl = await list.builder().setUrls([s2, s3]).toTemplate(signer)
|
||||||
|
|
||||||
expect(getTagValues("server", tmpl.tags).sort()).toEqual([norm(s2), norm(s3)].sort())
|
expect(getTagValues("server", tmpl.tags).sort()).toEqual([norm(s2), norm(s3)].sort())
|
||||||
})
|
})
|
||||||
|
|
||||||
it("round-trips public and private entries through encryption", async () => {
|
it("round-trips public and private entries through encryption", async () => {
|
||||||
const event = await new BlossomServerListBuilder()
|
const event = await new BlossomServerListBuilder()
|
||||||
.addServer(s1)
|
.addUrl(s1)
|
||||||
.addPrivate(["server", norm(s2)])
|
.addPrivate(["server", norm(s2)])
|
||||||
.toEvent(signer)
|
.toEvent(signer)
|
||||||
|
|
||||||
@@ -86,12 +86,12 @@ describe("BlossomServerList", () => {
|
|||||||
const decrypted = await BlossomServerList.fromEvent(event, signer)
|
const decrypted = await BlossomServerList.fromEvent(event, signer)
|
||||||
|
|
||||||
expect(decrypted.decrypted).toBe(true)
|
expect(decrypted.decrypted).toBe(true)
|
||||||
expect(decrypted.servers().sort()).toEqual([norm(s1), norm(s2)].sort())
|
expect(decrypted.urls().sort()).toEqual([norm(s1), norm(s2)].sort())
|
||||||
|
|
||||||
const publicOnly = await BlossomServerList.fromEvent(event)
|
const publicOnly = await BlossomServerList.fromEvent(event)
|
||||||
|
|
||||||
expect(publicOnly.decrypted).toBe(false)
|
expect(publicOnly.decrypted).toBe(false)
|
||||||
expect(publicOnly.servers()).toEqual([norm(s1)])
|
expect(publicOnly.urls()).toEqual([norm(s1)])
|
||||||
})
|
})
|
||||||
|
|
||||||
it("throws on the wrong kind", async () => {
|
it("throws on the wrong kind", async () => {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ describe("Classified", () => {
|
|||||||
expect(c.identifier()).toBe("abc")
|
expect(c.identifier()).toBe("abc")
|
||||||
expect(c.title()).toBe("Bike")
|
expect(c.title()).toBe("Bike")
|
||||||
expect(c.summary()).toBe("A good bike")
|
expect(c.summary()).toBe("A good bike")
|
||||||
expect(c.price()).toEqual({amount: 100, currency: "USD"})
|
expect(c.price()).toEqual({amount: 100, currency: "USD", frequency: ""})
|
||||||
expect(c.status()).toBe("active")
|
expect(c.status()).toBe("active")
|
||||||
expect(c.images()).toEqual(["https://example.com/a.jpg", "https://example.com/b.jpg"])
|
expect(c.images()).toEqual(["https://example.com/a.jpg", "https://example.com/b.jpg"])
|
||||||
expect(c.topics()).toEqual(["cycling"])
|
expect(c.topics()).toEqual(["cycling"])
|
||||||
@@ -51,7 +51,7 @@ describe("Classified", () => {
|
|||||||
it("defaults the price currency to SAT", async () => {
|
it("defaults the price currency to SAT", async () => {
|
||||||
const c = await Classified.fromEvent(makeEvent({tags: [["d", "x"], ["price", "50"]]}))
|
const c = await Classified.fromEvent(makeEvent({tags: [["d", "x"], ["price", "50"]]}))
|
||||||
|
|
||||||
expect(c.price()).toEqual({amount: 50, currency: "SAT"})
|
expect(c.price()).toEqual({amount: 50, currency: "SAT", frequency: ""})
|
||||||
})
|
})
|
||||||
|
|
||||||
it("round-trips with no duplicate represented tags", async () => {
|
it("round-trips with no duplicate represented tags", async () => {
|
||||||
@@ -84,8 +84,9 @@ describe("Classified", () => {
|
|||||||
expect(tmpl.content).toBe("for sale")
|
expect(tmpl.content).toBe("for sale")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("builds from a fresh builder with an auto-generated d", async () => {
|
it("builds from a fresh builder", async () => {
|
||||||
const tmpl = await new ClassifiedBuilder()
|
const tmpl = await new ClassifiedBuilder()
|
||||||
|
.setIdentifier("listing1")
|
||||||
.setTitle("Fresh")
|
.setTitle("Fresh")
|
||||||
.setContent("desc")
|
.setContent("desc")
|
||||||
.setPrice(25)
|
.setPrice(25)
|
||||||
@@ -94,7 +95,7 @@ describe("Classified", () => {
|
|||||||
.toTemplate(signer)
|
.toTemplate(signer)
|
||||||
|
|
||||||
expect(tmpl.kind).toBe(CLASSIFIED)
|
expect(tmpl.kind).toBe(CLASSIFIED)
|
||||||
expect(tmpl.tags.find(t => t[0] === "d")?.[1]).toBeTruthy()
|
expect(tmpl.tags).toContainEqual(["d", "listing1"])
|
||||||
expect(tmpl.tags).toContainEqual(["title", "Fresh"])
|
expect(tmpl.tags).toContainEqual(["title", "Fresh"])
|
||||||
expect(tmpl.tags).toContainEqual(["price", "25", "SAT"])
|
expect(tmpl.tags).toContainEqual(["price", "25", "SAT"])
|
||||||
expect(tmpl.tags).toContainEqual(["image", "https://example.com/c.jpg"])
|
expect(tmpl.tags).toContainEqual(["image", "https://example.com/c.jpg"])
|
||||||
|
|||||||
@@ -41,12 +41,6 @@ describe("Comment", () => {
|
|||||||
const comment = await Comment.fromEvent(event)
|
const comment = await Comment.fromEvent(event)
|
||||||
|
|
||||||
expect(comment.content()).toBe("nice thread")
|
expect(comment.content()).toBe("nice thread")
|
||||||
expect(comment.rootId()).toBe(rootId)
|
|
||||||
expect(comment.rootKind()).toBe("11")
|
|
||||||
expect(comment.rootPubkey()).toBe(rootPubkey)
|
|
||||||
expect(comment.parentId()).toBe(parentId)
|
|
||||||
expect(comment.parentKind()).toBe("1111")
|
|
||||||
expect(comment.parentPubkey()).toBe(parentPubkey)
|
|
||||||
expect(comment.root()).toEqual({id: rootId, address: undefined, kind: "11", pubkey: rootPubkey})
|
expect(comment.root()).toEqual({id: rootId, address: undefined, kind: "11", pubkey: rootPubkey})
|
||||||
expect(comment.parent()).toEqual({
|
expect(comment.parent()).toEqual({
|
||||||
id: parentId,
|
id: parentId,
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ describe("EmojiList", () => {
|
|||||||
|
|
||||||
const list = await EmojiList.fromEvent(event)
|
const list = await EmojiList.fromEvent(event)
|
||||||
|
|
||||||
expect(list.addresses()).toEqual([setAddress])
|
expect(list.emojiSets()).toEqual([setAddress])
|
||||||
expect(list.emojis()).toEqual([emojiTag])
|
expect(list.emojis()).toEqual([emojiTag])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -81,12 +81,12 @@ describe("EmojiList", () => {
|
|||||||
const decrypted = await EmojiList.fromEvent(event, signer)
|
const decrypted = await EmojiList.fromEvent(event, signer)
|
||||||
|
|
||||||
expect(decrypted.decrypted).toBe(true)
|
expect(decrypted.decrypted).toBe(true)
|
||||||
expect(decrypted.addresses().sort()).toEqual([setAddress, setAddress2].sort())
|
expect(decrypted.emojiSets().sort()).toEqual([setAddress, setAddress2].sort())
|
||||||
|
|
||||||
const publicOnly = await EmojiList.fromEvent(event)
|
const publicOnly = await EmojiList.fromEvent(event)
|
||||||
|
|
||||||
expect(publicOnly.decrypted).toBe(false)
|
expect(publicOnly.decrypted).toBe(false)
|
||||||
expect(publicOnly.addresses()).toEqual([setAddress])
|
expect(publicOnly.emojiSets()).toEqual([setAddress])
|
||||||
})
|
})
|
||||||
|
|
||||||
it("preserves undecrypted ciphertext on pass-through", async () => {
|
it("preserves undecrypted ciphertext on pass-through", async () => {
|
||||||
|
|||||||
@@ -48,38 +48,37 @@ describe("Feed", () => {
|
|||||||
["title", "My Feed"],
|
["title", "My Feed"],
|
||||||
["description", "all the things"],
|
["description", "all the things"],
|
||||||
["feed", JSON.stringify(definition)],
|
["feed", JSON.stringify(definition)],
|
||||||
|
// "alt" is consumed but not re-emitted, so it shouldn't survive.
|
||||||
["alt", "My Feed"],
|
["alt", "My Feed"],
|
||||||
// "alt" is a represented/derived tag (mirrors title), so use a distinct
|
|
||||||
// unknown key for the passthrough assertion.
|
|
||||||
["zzz", "x"],
|
["zzz", "x"],
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
const tmpl = await (await Feed.fromEvent(event)).builder().toTemplate(signer)
|
const tmpl = await (await Feed.fromEvent(event)).builder().toTemplate(signer)
|
||||||
|
|
||||||
for (const key of ["d", "title", "description", "feed", "alt"]) {
|
for (const key of ["d", "title", "description", "feed"]) {
|
||||||
expect(tmpl.tags.filter(t => t[0] === key).length).toBe(1)
|
expect(tmpl.tags.filter(t => t[0] === key).length).toBe(1)
|
||||||
}
|
}
|
||||||
expect(tmpl.tags).toContainEqual(["d", "abc"])
|
expect(tmpl.tags).toContainEqual(["d", "abc"])
|
||||||
expect(tmpl.tags).toContainEqual(["title", "My Feed"])
|
expect(tmpl.tags).toContainEqual(["title", "My Feed"])
|
||||||
expect(tmpl.tags).toContainEqual(["feed", JSON.stringify(definition)])
|
expect(tmpl.tags).toContainEqual(["feed", JSON.stringify(definition)])
|
||||||
// The derived "alt" tag mirrors the title.
|
// "alt" is consumed but not re-emitted.
|
||||||
expect(tmpl.tags).toContainEqual(["alt", "My Feed"])
|
expect(tmpl.tags.filter(t => t[0] === "alt")).toHaveLength(0)
|
||||||
// Unknown passthrough tag survives.
|
// Unknown passthrough tag survives.
|
||||||
expect(tmpl.tags).toContainEqual(["zzz", "x"])
|
expect(tmpl.tags).toContainEqual(["zzz", "x"])
|
||||||
})
|
})
|
||||||
|
|
||||||
it("builds from a fresh builder with an auto-generated d", async () => {
|
it("builds from a fresh builder", async () => {
|
||||||
const tmpl = await new FeedBuilder()
|
const tmpl = await new FeedBuilder()
|
||||||
|
.setIdentifier("feed1")
|
||||||
.setTitle("Fresh")
|
.setTitle("Fresh")
|
||||||
.setDescription("desc")
|
.setDescription("desc")
|
||||||
.setDefinition(definition)
|
.setDefinition(definition)
|
||||||
.toTemplate(signer)
|
.toTemplate(signer)
|
||||||
|
|
||||||
expect(tmpl.kind).toBe(FEED)
|
expect(tmpl.kind).toBe(FEED)
|
||||||
expect(tmpl.tags.find(t => t[0] === "d")?.[1]).toBeTruthy()
|
expect(tmpl.tags).toContainEqual(["d", "feed1"])
|
||||||
expect(tmpl.tags).toContainEqual(["title", "Fresh"])
|
expect(tmpl.tags).toContainEqual(["title", "Fresh"])
|
||||||
expect(tmpl.tags).toContainEqual(["alt", "Fresh"])
|
|
||||||
expect(tmpl.tags).toContainEqual(["description", "desc"])
|
expect(tmpl.tags).toContainEqual(["description", "desc"])
|
||||||
expect(tmpl.tags).toContainEqual(["feed", JSON.stringify(definition)])
|
expect(tmpl.tags).toContainEqual(["feed", JSON.stringify(definition)])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ describe("Handler", () => {
|
|||||||
content: JSON.stringify({
|
content: JSON.stringify({
|
||||||
name: "Coracle",
|
name: "Coracle",
|
||||||
about: "a client",
|
about: "a client",
|
||||||
image: "https://example.com/i.png",
|
picture: "https://example.com/i.png",
|
||||||
website: "https://example.com",
|
website: "https://example.com",
|
||||||
lud16: "a@example.com",
|
lud16: "a@example.com",
|
||||||
nip05: "a@example.com",
|
nip05: "a@example.com",
|
||||||
@@ -43,22 +43,36 @@ describe("Handler", () => {
|
|||||||
expect(handler.values.name).toBe("Coracle")
|
expect(handler.values.name).toBe("Coracle")
|
||||||
expect(handler.name()).toBe("Coracle")
|
expect(handler.name()).toBe("Coracle")
|
||||||
expect(handler.about()).toBe("a client")
|
expect(handler.about()).toBe("a client")
|
||||||
expect(handler.image()).toBe("https://example.com/i.png")
|
expect(handler.picture()).toBe("https://example.com/i.png")
|
||||||
expect(handler.website()).toBe("https://example.com")
|
expect(handler.website()).toBe("https://example.com")
|
||||||
expect(handler.lud16()).toBe("a@example.com")
|
expect(handler.lud16()).toBe("a@example.com")
|
||||||
expect(handler.nip05()).toBe("a@example.com")
|
expect(handler.nip05()).toBe("a@example.com")
|
||||||
expect(handler.kinds()).toEqual([1, 30023])
|
expect(handler.kinds()).toEqual([1, 30023])
|
||||||
})
|
})
|
||||||
|
|
||||||
it("maps display_name and picture aliases to canonical accessors", async () => {
|
it("preserves unknown content metadata on round-trip", async () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
content: JSON.stringify({name: "Coracle", custom_field: "keep me"}),
|
||||||
|
tags: [["d", "myhandler"]],
|
||||||
|
})
|
||||||
|
|
||||||
|
const tmpl = await (await Handler.fromEvent(event)).builder().toTemplate(signer)
|
||||||
|
const parsed = JSON.parse(tmpl.content)
|
||||||
|
|
||||||
|
expect(parsed.name).toBe("Coracle")
|
||||||
|
expect(parsed.custom_field).toBe("keep me")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("ignores non-spec aliases like display_name", async () => {
|
||||||
const handler = await Handler.fromEvent(
|
const handler = await Handler.fromEvent(
|
||||||
makeEvent({
|
makeEvent({
|
||||||
content: JSON.stringify({display_name: "Alias", picture: "https://example.com/p.png"}),
|
content: JSON.stringify({display_name: "Alias", picture: "https://example.com/p.png"}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(handler.name()).toBe("Alias")
|
// NIP-89 metadata follows NIP-01: only the canonical `name`/`picture` fields are read.
|
||||||
expect(handler.image()).toBe("https://example.com/p.png")
|
expect(handler.name()).toBeUndefined()
|
||||||
|
expect(handler.picture()).toBe("https://example.com/p.png")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("round-trips with no duplication", async () => {
|
it("round-trips with no duplication", async () => {
|
||||||
@@ -87,6 +101,7 @@ describe("Handler", () => {
|
|||||||
|
|
||||||
it("builds from a fresh builder", async () => {
|
it("builds from a fresh builder", async () => {
|
||||||
const tmpl = await new HandlerBuilder()
|
const tmpl = await new HandlerBuilder()
|
||||||
|
.setIdentifier("myhandler")
|
||||||
.setName("MyApp")
|
.setName("MyApp")
|
||||||
.setAbout("does things")
|
.setAbout("does things")
|
||||||
.setWebsite("https://my.app")
|
.setWebsite("https://my.app")
|
||||||
@@ -94,6 +109,7 @@ describe("Handler", () => {
|
|||||||
.toTemplate(signer)
|
.toTemplate(signer)
|
||||||
|
|
||||||
expect(tmpl.kind).toBe(HANDLER_INFORMATION)
|
expect(tmpl.kind).toBe(HANDLER_INFORMATION)
|
||||||
|
expect(tmpl.tags).toContainEqual(["d", "myhandler"])
|
||||||
const parsed = JSON.parse(tmpl.content)
|
const parsed = JSON.parse(tmpl.content)
|
||||||
expect(parsed.name).toBe("MyApp")
|
expect(parsed.name).toBe("MyApp")
|
||||||
expect(parsed.about).toBe("does things")
|
expect(parsed.about).toBe("does things")
|
||||||
|
|||||||
@@ -73,9 +73,8 @@ describe("HandlerRecommendation", () => {
|
|||||||
|
|
||||||
it("builds from a fresh builder", async () => {
|
it("builds from a fresh builder", async () => {
|
||||||
const builder = new HandlerRecommendationBuilder()
|
const builder = new HandlerRecommendationBuilder()
|
||||||
// The d identifier holds the recommended kind; it has no setter, so set it
|
// The d identifier holds the recommended kind.
|
||||||
// directly on the public field.
|
builder.setIdentifier("1")
|
||||||
builder.identifier = "1"
|
|
||||||
|
|
||||||
const tmpl = await builder
|
const tmpl = await builder
|
||||||
.addRecommendation(webAddress, "wss://relay.one", "web")
|
.addRecommendation(webAddress, "wss://relay.one", "web")
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ describe("MessagingRelayList", () => {
|
|||||||
|
|
||||||
it("builds from a fresh builder and normalizes urls", async () => {
|
it("builds from a fresh builder and normalizes urls", async () => {
|
||||||
const tmpl = await new MessagingRelayListBuilder()
|
const tmpl = await new MessagingRelayListBuilder()
|
||||||
.addRelay("wss://inbox.one.example")
|
.addUrl("wss://inbox.one.example")
|
||||||
.toTemplate(signer)
|
.toTemplate(signer)
|
||||||
|
|
||||||
expect(getTagValues("relay", tmpl.tags)).toEqual([normalizeRelayUrl("wss://inbox.one.example")])
|
expect(getTagValues("relay", tmpl.tags)).toEqual([normalizeRelayUrl("wss://inbox.one.example")])
|
||||||
@@ -67,14 +67,14 @@ describe("MessagingRelayList", () => {
|
|||||||
const event = makeEvent({tags: [["relay", r1]]})
|
const event = makeEvent({tags: [["relay", r1]]})
|
||||||
const list = await MessagingRelayList.fromEvent(event)
|
const list = await MessagingRelayList.fromEvent(event)
|
||||||
|
|
||||||
const tmpl = await list.builder().setRelays([r2, r3]).toTemplate(signer)
|
const tmpl = await list.builder().setUrls([r2, r3]).toTemplate(signer)
|
||||||
|
|
||||||
expect(getTagValues("relay", tmpl.tags).sort()).toEqual([r2, r3].sort())
|
expect(getTagValues("relay", tmpl.tags).sort()).toEqual([r2, r3].sort())
|
||||||
})
|
})
|
||||||
|
|
||||||
it("round-trips public and private entries through encryption", async () => {
|
it("round-trips public and private entries through encryption", async () => {
|
||||||
const event = await new MessagingRelayListBuilder()
|
const event = await new MessagingRelayListBuilder()
|
||||||
.addRelay(r1)
|
.addUrl(r1)
|
||||||
.addPrivate(["relay", r2])
|
.addPrivate(["relay", r2])
|
||||||
.toEvent(signer)
|
.toEvent(signer)
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ describe("Poll", () => {
|
|||||||
])
|
])
|
||||||
expect(poll.pollType()).toBe("multiplechoice")
|
expect(poll.pollType()).toBe("multiplechoice")
|
||||||
expect(poll.endsAt()).toBe(1234)
|
expect(poll.endsAt()).toBe(1234)
|
||||||
expect(poll.relays()).toEqual(["wss://relay.one", "wss://relay.two"])
|
expect(poll.urls()).toEqual(["wss://relay.one", "wss://relay.two"])
|
||||||
})
|
})
|
||||||
|
|
||||||
it("tallies results from response events", async () => {
|
it("tallies results from response events", async () => {
|
||||||
@@ -102,7 +102,7 @@ describe("Poll", () => {
|
|||||||
.addOption("Blue", "2")
|
.addOption("Blue", "2")
|
||||||
.setPollType("multiplechoice")
|
.setPollType("multiplechoice")
|
||||||
.setEndsAt(9999)
|
.setEndsAt(9999)
|
||||||
.setRelays(["wss://relay.one"])
|
.setUrls(["wss://relay.one"])
|
||||||
.toTemplate(signer)
|
.toTemplate(signer)
|
||||||
|
|
||||||
expect(tmpl.kind).toBe(POLL)
|
expect(tmpl.kind).toBe(POLL)
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ describe("Profile", () => {
|
|||||||
it("seeds the builder from the reader and edits values", async () => {
|
it("seeds the builder from the reader and edits values", async () => {
|
||||||
const profile = await Profile.fromEvent(makeEvent({content: JSON.stringify({name: "alice"})}))
|
const profile = await Profile.fromEvent(makeEvent({content: JSON.stringify({name: "alice"})}))
|
||||||
|
|
||||||
const event = await profile.builder().about("hello").toEvent(signer)
|
const event = await profile.builder().setAbout("hello").toEvent(signer)
|
||||||
const updated = await Profile.fromEvent(event)
|
const updated = await Profile.fromEvent(event)
|
||||||
|
|
||||||
expect(updated.name()).toBe("alice")
|
expect(updated.name()).toBe("alice")
|
||||||
|
|||||||
+3
-34
@@ -1,13 +1,8 @@
|
|||||||
import {describe, it, expect} from "vitest"
|
import {describe, it, expect} from "vitest"
|
||||||
import {makeSecret, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, NOTE} from "@welshman/util"
|
import {makeSecret, RELAY_ADD_MEMBER, NOTE} from "@welshman/util"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {Nip01Signer} from "@welshman/signer"
|
import {Nip01Signer} from "@welshman/signer"
|
||||||
import {
|
import {RelayAddMember, RelayAddMemberBuilder} from "../src/kinds/RelayAddMember"
|
||||||
RelayAddMember,
|
|
||||||
RelayAddMemberBuilder,
|
|
||||||
RelayRemoveMember,
|
|
||||||
RelayRemoveMemberBuilder,
|
|
||||||
} from "../src/kinds/RelayMembershipOp"
|
|
||||||
|
|
||||||
const signer = new Nip01Signer(makeSecret())
|
const signer = new Nip01Signer(makeSecret())
|
||||||
const pubkey = "ee".repeat(32)
|
const pubkey = "ee".repeat(32)
|
||||||
@@ -34,9 +29,7 @@ describe("RelayAddMember", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("round-trips with no duplicate p tags and passthrough", async () => {
|
it("round-trips with no duplicate p tags and passthrough", async () => {
|
||||||
const op = await RelayAddMember.fromEvent(
|
const op = await RelayAddMember.fromEvent(makeEvent({tags: [["p", a], ["p", b], ["alt", "x"]]}))
|
||||||
makeEvent({tags: [["p", a], ["p", b], ["alt", "x"]]}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const tmpl = await op.builder().toTemplate(signer)
|
const tmpl = await op.builder().toTemplate(signer)
|
||||||
|
|
||||||
@@ -58,27 +51,3 @@ describe("RelayAddMember", () => {
|
|||||||
await expect(RelayAddMember.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
|
await expect(RelayAddMember.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("RelayRemoveMember", () => {
|
|
||||||
it("reads affected pubkeys with the remove kind", async () => {
|
|
||||||
const op = await RelayRemoveMember.fromEvent(
|
|
||||||
makeEvent({kind: RELAY_REMOVE_MEMBER, tags: [["p", a]]}),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(op.kind).toBe(RELAY_REMOVE_MEMBER)
|
|
||||||
expect(op.pubkeys()).toEqual([a])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("builds fresh with the remove kind", async () => {
|
|
||||||
const tmpl = await new RelayRemoveMemberBuilder().addPubkey(a).toTemplate(signer)
|
|
||||||
|
|
||||||
expect(tmpl.kind).toBe(RELAY_REMOVE_MEMBER)
|
|
||||||
expect(tmpl.tags).toContainEqual(["p", a])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("throws when the add kind is read as a remove", async () => {
|
|
||||||
await expect(
|
|
||||||
RelayRemoveMember.fromEvent(makeEvent({kind: RELAY_ADD_MEMBER})),
|
|
||||||
).rejects.toThrow()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -35,7 +35,7 @@ describe("RelayLeave", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("sets the group via a fresh builder", async () => {
|
it("sets the group via a fresh builder", async () => {
|
||||||
const tmpl = await new RelayLeaveBuilder().group(group).toTemplate(signer)
|
const tmpl = await new RelayLeaveBuilder().setGroup(group).toTemplate(signer)
|
||||||
|
|
||||||
expect(tmpl.tags).toContainEqual(["h", group])
|
expect(tmpl.tags).toContainEqual(["h", group])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -62,10 +62,10 @@ describe("RelayList", () => {
|
|||||||
|
|
||||||
it("adds modeless and single-mode relays via a fresh builder", async () => {
|
it("adds modeless and single-mode relays via a fresh builder", async () => {
|
||||||
const tmpl = await new RelayListBuilder()
|
const tmpl = await new RelayListBuilder()
|
||||||
.addRelay(read, RelayMode.Read)
|
.addUrl(read, RelayMode.Read)
|
||||||
.addRelay(write, RelayMode.Write)
|
.addUrl(write, RelayMode.Write)
|
||||||
.addRelay(both, RelayMode.Read)
|
.addUrl(both, RelayMode.Read)
|
||||||
.addRelay(both, RelayMode.Write)
|
.addUrl(both, RelayMode.Write)
|
||||||
.toTemplate(signer)
|
.toTemplate(signer)
|
||||||
|
|
||||||
expect(tmpl.kind).toBe(RELAYS)
|
expect(tmpl.kind).toBe(RELAYS)
|
||||||
@@ -77,9 +77,9 @@ describe("RelayList", () => {
|
|||||||
|
|
||||||
it("downgrades a modeless relay when one mode is removed", async () => {
|
it("downgrades a modeless relay when one mode is removed", async () => {
|
||||||
const tmpl = await new RelayListBuilder()
|
const tmpl = await new RelayListBuilder()
|
||||||
.addRelay(both, RelayMode.Read)
|
.addUrl(both, RelayMode.Read)
|
||||||
.addRelay(both, RelayMode.Write)
|
.addUrl(both, RelayMode.Write)
|
||||||
.removeRelay(both, RelayMode.Read)
|
.removeUrl(both, RelayMode.Read)
|
||||||
.toTemplate(signer)
|
.toTemplate(signer)
|
||||||
|
|
||||||
expect(tmpl.tags).toContainEqual(["r", both, RelayMode.Write])
|
expect(tmpl.tags).toContainEqual(["r", both, RelayMode.Write])
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import {describe, it, expect} from "vitest"
|
||||||
|
import {makeSecret, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {Nip01Signer} from "@welshman/signer"
|
||||||
|
import {RelayRemoveMember, RelayRemoveMemberBuilder} from "../src/kinds/RelayRemoveMember"
|
||||||
|
|
||||||
|
const signer = new Nip01Signer(makeSecret())
|
||||||
|
const pubkey = "ee".repeat(32)
|
||||||
|
const a = "aa".repeat(32)
|
||||||
|
|
||||||
|
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
|
||||||
|
({
|
||||||
|
id: "ff".repeat(32),
|
||||||
|
pubkey,
|
||||||
|
created_at: 0,
|
||||||
|
kind: RELAY_REMOVE_MEMBER,
|
||||||
|
tags: [],
|
||||||
|
content: "",
|
||||||
|
sig: "00".repeat(64),
|
||||||
|
...overrides,
|
||||||
|
}) as TrustedEvent
|
||||||
|
|
||||||
|
describe("RelayRemoveMember", () => {
|
||||||
|
it("reads affected pubkeys with the remove kind", async () => {
|
||||||
|
const op = await RelayRemoveMember.fromEvent(makeEvent({tags: [["p", a]]}))
|
||||||
|
|
||||||
|
expect(op.kind).toBe(RELAY_REMOVE_MEMBER)
|
||||||
|
expect(op.pubkeys()).toEqual([a])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("builds fresh with the remove kind", async () => {
|
||||||
|
const tmpl = await new RelayRemoveMemberBuilder().addPubkey(a).toTemplate(signer)
|
||||||
|
|
||||||
|
expect(tmpl.kind).toBe(RELAY_REMOVE_MEMBER)
|
||||||
|
expect(tmpl.tags).toContainEqual(["p", a])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws when the add kind is read as a remove", async () => {
|
||||||
|
await expect(RelayRemoveMember.fromEvent(makeEvent({kind: RELAY_ADD_MEMBER}))).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -74,15 +74,15 @@ describe("RelaySet", () => {
|
|||||||
expect(tmpl.tags).toContainEqual(["alt", "x"])
|
expect(tmpl.tags).toContainEqual(["alt", "x"])
|
||||||
})
|
})
|
||||||
|
|
||||||
it("auto-generates a d identifier on a fresh builder", async () => {
|
it("builds from a fresh builder", async () => {
|
||||||
const builder = new RelaySetBuilder()
|
const tmpl = await new RelaySetBuilder()
|
||||||
|
.setIdentifier("my-set")
|
||||||
expect(builder.identifier).toBeTruthy()
|
.setTitle("Fresh")
|
||||||
|
.addUrl(relayA)
|
||||||
const tmpl = await builder.setTitle("Fresh").addRelay(relayA).toTemplate(signer)
|
.toTemplate(signer)
|
||||||
|
|
||||||
expect(tmpl.kind).toBe(NAMED_RELAYS)
|
expect(tmpl.kind).toBe(NAMED_RELAYS)
|
||||||
expect(tmpl.tags.filter(t => t[0] === "d").length).toBe(1)
|
expect(tmpl.tags).toContainEqual(["d", "my-set"])
|
||||||
expect(tmpl.tags).toContainEqual(["title", "Fresh"])
|
expect(tmpl.tags).toContainEqual(["title", "Fresh"])
|
||||||
expect(tmpl.tags).toContainEqual(["relay", normalizeRelayUrl(relayA)])
|
expect(tmpl.tags).toContainEqual(["relay", normalizeRelayUrl(relayA)])
|
||||||
})
|
})
|
||||||
@@ -92,7 +92,7 @@ describe("RelaySet", () => {
|
|||||||
makeEvent({tags: [["d", "my-set"], ["title", "My Set"], ["relay", relayA]]}),
|
makeEvent({tags: [["d", "my-set"], ["title", "My Set"], ["relay", relayA]]}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const tmpl = await reader.builder().setRelays([relayB]).toTemplate(signer)
|
const tmpl = await reader.builder().setUrls([relayB]).toTemplate(signer)
|
||||||
|
|
||||||
expect(tmpl.tags).toContainEqual(["relay", normalizeRelayUrl(relayB)])
|
expect(tmpl.tags).toContainEqual(["relay", normalizeRelayUrl(relayB)])
|
||||||
expect(tmpl.tags.some(t => t[0] === "relay" && t[1] === normalizeRelayUrl(relayA))).toBe(false)
|
expect(tmpl.tags.some(t => t[0] === "relay" && t[1] === normalizeRelayUrl(relayA))).toBe(false)
|
||||||
|
|||||||
+3
-46
@@ -1,13 +1,8 @@
|
|||||||
import {describe, it, expect} from "vitest"
|
import {describe, it, expect} from "vitest"
|
||||||
import {makeSecret, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER, NOTE, getTagValue} from "@welshman/util"
|
import {makeSecret, ROOM_ADD_MEMBER, NOTE, getTagValue} from "@welshman/util"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {Nip01Signer} from "@welshman/signer"
|
import {Nip01Signer} from "@welshman/signer"
|
||||||
import {
|
import {RoomAddMember, RoomAddMemberBuilder} from "../src/kinds/RoomAddMember"
|
||||||
RoomAddMember,
|
|
||||||
RoomAddMemberBuilder,
|
|
||||||
RoomRemoveMember,
|
|
||||||
RoomRemoveMemberBuilder,
|
|
||||||
} from "../src/kinds/RoomMembershipOp"
|
|
||||||
|
|
||||||
const signer = new Nip01Signer(makeSecret())
|
const signer = new Nip01Signer(makeSecret())
|
||||||
const pubkey = "ee".repeat(32)
|
const pubkey = "ee".repeat(32)
|
||||||
@@ -57,7 +52,7 @@ describe("RoomAddMember", () => {
|
|||||||
|
|
||||||
it("builds from a fresh builder", async () => {
|
it("builds from a fresh builder", async () => {
|
||||||
const tmpl = await new RoomAddMemberBuilder()
|
const tmpl = await new RoomAddMemberBuilder()
|
||||||
.group("room2")
|
.setGroup("room2")
|
||||||
.addPubkey(a)
|
.addPubkey(a)
|
||||||
.addPubkey(a) // dedup
|
.addPubkey(a) // dedup
|
||||||
.addPubkey(b)
|
.addPubkey(b)
|
||||||
@@ -74,41 +69,3 @@ describe("RoomAddMember", () => {
|
|||||||
await expect(RoomAddMember.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
|
await expect(RoomAddMember.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("RoomRemoveMember", () => {
|
|
||||||
it("uses the remove kind and reads pubkeys", async () => {
|
|
||||||
const op = await RoomRemoveMember.fromEvent(
|
|
||||||
makeEvent({kind: ROOM_REMOVE_MEMBER, tags: [["h", "room1"], ["p", a]]}),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(op.kind).toBe(ROOM_REMOVE_MEMBER)
|
|
||||||
expect(op.pubkeys()).toEqual([a])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("round-trips through the remove builder", async () => {
|
|
||||||
const op = await RoomRemoveMember.fromEvent(
|
|
||||||
makeEvent({kind: ROOM_REMOVE_MEMBER, tags: [["h", "room1"], ["p", a], ["p", b]]}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const tmpl = await op.builder().toTemplate(signer)
|
|
||||||
|
|
||||||
expect(tmpl.kind).toBe(ROOM_REMOVE_MEMBER)
|
|
||||||
expect(tmpl.tags.filter(t => t[0] === "h").length).toBe(1)
|
|
||||||
expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(2)
|
|
||||||
expect(getTagValue("h", tmpl.tags)).toBe("room1")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("builds from a fresh remove builder", async () => {
|
|
||||||
const tmpl = await new RoomRemoveMemberBuilder().group("room2").addPubkey(a).toTemplate(signer)
|
|
||||||
|
|
||||||
expect(tmpl.kind).toBe(ROOM_REMOVE_MEMBER)
|
|
||||||
expect(getTagValue("h", tmpl.tags)).toBe("room2")
|
|
||||||
expect(tmpl.tags).toContainEqual(["p", a])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("throws on the wrong kind", async () => {
|
|
||||||
await expect(
|
|
||||||
RoomRemoveMember.fromEvent(makeEvent({kind: ROOM_ADD_MEMBER, tags: [["p", a]]})),
|
|
||||||
).rejects.toThrow()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -27,7 +27,6 @@ describe("RoomAdmins", () => {
|
|||||||
makeEvent({tags: [["d", "room1"], ["p", a], ["p", b], ["alt", "x"]]}),
|
makeEvent({tags: [["d", "room1"], ["p", a], ["p", b], ["alt", "x"]]}),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(room.h()).toBe("room1")
|
|
||||||
expect(room.identifier()).toBe("room1")
|
expect(room.identifier()).toBe("room1")
|
||||||
expect(room.pubkeys()).toEqual([a, b])
|
expect(room.pubkeys()).toEqual([a, b])
|
||||||
})
|
})
|
||||||
@@ -51,10 +50,10 @@ describe("RoomAdmins", () => {
|
|||||||
|
|
||||||
it("builds from a fresh builder", async () => {
|
it("builds from a fresh builder", async () => {
|
||||||
const tmpl = await new RoomAdminsBuilder()
|
const tmpl = await new RoomAdminsBuilder()
|
||||||
.setH("room2")
|
.setIdentifier("room2")
|
||||||
.addPubkey(a)
|
.addAdmin(a)
|
||||||
.addPubkey(a) // dedup
|
.addAdmin(a) // dedup
|
||||||
.addPubkey(b)
|
.addAdmin(b)
|
||||||
.toTemplate(signer)
|
.toTemplate(signer)
|
||||||
|
|
||||||
expect(tmpl.tags).toContainEqual(["d", "room2"])
|
expect(tmpl.tags).toContainEqual(["d", "room2"])
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ describe("RoomCreate", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("sets the group via a fresh builder", async () => {
|
it("sets the group via a fresh builder", async () => {
|
||||||
const tmpl = await new RoomCreateBuilder().group(group).toTemplate(signer)
|
const tmpl = await new RoomCreateBuilder().setGroup(group).toTemplate(signer)
|
||||||
|
|
||||||
expect(tmpl.tags).toContainEqual(["h", group])
|
expect(tmpl.tags).toContainEqual(["h", group])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {RoomDelete, RoomDeleteBuilder} from "../src/kinds/RoomDelete"
|
|||||||
|
|
||||||
const signer = new Nip01Signer(makeSecret())
|
const signer = new Nip01Signer(makeSecret())
|
||||||
const pubkey = "ee".repeat(32)
|
const pubkey = "ee".repeat(32)
|
||||||
|
const group = "room1"
|
||||||
|
|
||||||
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
|
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
|
||||||
({
|
({
|
||||||
@@ -20,46 +21,30 @@ const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
|
|||||||
}) as TrustedEvent
|
}) as TrustedEvent
|
||||||
|
|
||||||
describe("RoomDelete", () => {
|
describe("RoomDelete", () => {
|
||||||
it("reads multiple repeatable h tags", async () => {
|
it("reads the target room via group()", async () => {
|
||||||
const del = await RoomDelete.fromEvent(
|
const del = await RoomDelete.fromEvent(makeEvent({tags: [["h", group]]}))
|
||||||
makeEvent({tags: [["h", "room1"], ["h", "room2"], ["h", "room3"], ["alt", "x"]]}),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(del.hs()).toEqual(["room1", "room2", "room3"])
|
expect(del.group()).toBe(group)
|
||||||
expect(del.h()).toBe("room1")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("round-trips multiple rooms, emitting one h each with no duplication", async () => {
|
it("round-trips the group behavior tag without duplication", async () => {
|
||||||
const del = await RoomDelete.fromEvent(
|
const del = await RoomDelete.fromEvent(makeEvent({tags: [["h", group], ["alt", "x"]]}))
|
||||||
makeEvent({tags: [["h", "room1"], ["h", "room2"], ["h", "room3"], ["alt", "x"]]}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const tmpl = await del.builder().toTemplate(signer)
|
const tmpl = await del.builder().toTemplate(signer)
|
||||||
|
|
||||||
expect(tmpl.kind).toBe(ROOM_DELETE)
|
expect(tmpl.kind).toBe(ROOM_DELETE)
|
||||||
// Three rooms in, three h tags out — exactly one per room, no duplicates.
|
expect(tmpl.tags.filter(t => t[0] === "h").length).toBe(1)
|
||||||
expect(tmpl.tags.filter(t => t[0] === "h").length).toBe(3)
|
expect(tmpl.tags).toContainEqual(["h", group])
|
||||||
expect(tmpl.tags).toContainEqual(["h", "room1"])
|
|
||||||
expect(tmpl.tags).toContainEqual(["h", "room2"])
|
|
||||||
expect(tmpl.tags).toContainEqual(["h", "room3"])
|
|
||||||
// Unknown passthrough tag survives.
|
|
||||||
expect(tmpl.tags).toContainEqual(["alt", "x"])
|
expect(tmpl.tags).toContainEqual(["alt", "x"])
|
||||||
})
|
})
|
||||||
|
|
||||||
it("builds from a fresh builder and edits the room set", async () => {
|
it("sets the target room via a fresh builder", async () => {
|
||||||
const tmpl = await new RoomDeleteBuilder()
|
const tmpl = await new RoomDeleteBuilder().setGroup(group).toTemplate(signer)
|
||||||
.addRoom("room1")
|
|
||||||
.addRoom("room1") // dedup
|
|
||||||
.addRoom("room2")
|
|
||||||
.removeRoom("room1")
|
|
||||||
.toTemplate(signer)
|
|
||||||
|
|
||||||
expect(tmpl.tags.filter(t => t[0] === "h").length).toBe(1)
|
expect(tmpl.tags).toContainEqual(["h", group])
|
||||||
expect(tmpl.tags).toContainEqual(["h", "room2"])
|
|
||||||
expect(tmpl.tags).not.toContainEqual(["h", "room1"])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("requires at least one h tag", async () => {
|
it("requires an h group", async () => {
|
||||||
await expect(new RoomDeleteBuilder().toTemplate(signer)).rejects.toThrow()
|
await expect(new RoomDeleteBuilder().toTemplate(signer)).rejects.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ describe("RoomJoin", () => {
|
|||||||
|
|
||||||
it("builds from a fresh builder", async () => {
|
it("builds from a fresh builder", async () => {
|
||||||
const tmpl = await new RoomJoinBuilder()
|
const tmpl = await new RoomJoinBuilder()
|
||||||
.group("room2")
|
.setGroup("room2")
|
||||||
.setCode("xyz")
|
.setCode("xyz")
|
||||||
.setReason("hi there")
|
.setReason("hi there")
|
||||||
.toTemplate(signer)
|
.toTemplate(signer)
|
||||||
|
|||||||
@@ -21,11 +21,10 @@ const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
|
|||||||
}) as TrustedEvent
|
}) as TrustedEvent
|
||||||
|
|
||||||
describe("RoomLeave", () => {
|
describe("RoomLeave", () => {
|
||||||
it("reads the group via group() and h()", async () => {
|
it("reads the group via group()", async () => {
|
||||||
const leave = await RoomLeave.fromEvent(makeEvent({tags: [["h", group]]}))
|
const leave = await RoomLeave.fromEvent(makeEvent({tags: [["h", group]]}))
|
||||||
|
|
||||||
expect(leave.group()).toBe(group)
|
expect(leave.group()).toBe(group)
|
||||||
expect(leave.h()).toBe(group)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("round-trips the group behavior tag without duplication", async () => {
|
it("round-trips the group behavior tag without duplication", async () => {
|
||||||
@@ -40,7 +39,7 @@ describe("RoomLeave", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("sets the group via a fresh builder", async () => {
|
it("sets the group via a fresh builder", async () => {
|
||||||
const tmpl = await new RoomLeaveBuilder().group(group).toTemplate(signer)
|
const tmpl = await new RoomLeaveBuilder().setGroup(group).toTemplate(signer)
|
||||||
|
|
||||||
expect(tmpl.tags).toContainEqual(["h", group])
|
expect(tmpl.tags).toContainEqual(["h", group])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ describe("RoomMembers", () => {
|
|||||||
makeEvent({tags: [["d", "room1"], ["p", a], ["p", b], ["alt", "x"]]}),
|
makeEvent({tags: [["d", "room1"], ["p", a], ["p", b], ["alt", "x"]]}),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(room.h()).toBe("room1")
|
expect(room.identifier()).toBe("room1")
|
||||||
expect(room.members()).toEqual([a, b])
|
expect(room.members()).toEqual([a, b])
|
||||||
expect(room.isMember(a)).toBe(true)
|
expect(room.isMember(a)).toBe(true)
|
||||||
expect(room.isMember(c)).toBe(false)
|
expect(room.isMember(c)).toBe(false)
|
||||||
@@ -51,13 +51,9 @@ describe("RoomMembers", () => {
|
|||||||
expect(tmpl.tags).toContainEqual(["alt", "x"])
|
expect(tmpl.tags).toContainEqual(["alt", "x"])
|
||||||
})
|
})
|
||||||
|
|
||||||
it("requires an h identifier on a fresh builder", async () => {
|
|
||||||
await expect(new RoomMembersBuilder().addMember(a).toTemplate(signer)).rejects.toThrow()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("builds from a fresh builder and edits membership", async () => {
|
it("builds from a fresh builder and edits membership", async () => {
|
||||||
const builder = new RoomMembersBuilder()
|
const builder = new RoomMembersBuilder()
|
||||||
builder.h = "room2"
|
builder.setIdentifier("room2")
|
||||||
|
|
||||||
const tmpl = await builder
|
const tmpl = await builder
|
||||||
.addMember(a)
|
.addMember(a)
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ describe("RoomMeta", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(room.h()).toBe("room1")
|
|
||||||
expect(room.identifier()).toBe("room1")
|
expect(room.identifier()).toBe("room1")
|
||||||
expect(room.name()).toBe("My Room")
|
expect(room.name()).toBe("My Room")
|
||||||
expect(room.about()).toBe("a place")
|
expect(room.about()).toBe("a place")
|
||||||
@@ -46,7 +45,7 @@ describe("RoomMeta", () => {
|
|||||||
expect(room.isHidden()).toBe(false)
|
expect(room.isHidden()).toBe(false)
|
||||||
expect(room.isPrivate()).toBe(true)
|
expect(room.isPrivate()).toBe(true)
|
||||||
expect(room.isRestricted()).toBe(false)
|
expect(room.isRestricted()).toBe(false)
|
||||||
expect(room.livekit()).toBe(true)
|
expect(room.hasLivekit()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("round-trips with no duplicated tags", async () => {
|
it("round-trips with no duplicated tags", async () => {
|
||||||
@@ -82,6 +81,7 @@ describe("RoomMeta", () => {
|
|||||||
|
|
||||||
it("builds from a fresh builder", async () => {
|
it("builds from a fresh builder", async () => {
|
||||||
const tmpl = await new RoomMetaBuilder()
|
const tmpl = await new RoomMetaBuilder()
|
||||||
|
.setIdentifier("room2")
|
||||||
.setName("Fresh")
|
.setName("Fresh")
|
||||||
.setAbout("desc")
|
.setAbout("desc")
|
||||||
.setPicture("https://pic", ["100x100"])
|
.setPicture("https://pic", ["100x100"])
|
||||||
@@ -90,8 +90,7 @@ describe("RoomMeta", () => {
|
|||||||
expect(tmpl.tags).toContainEqual(["name", "Fresh"])
|
expect(tmpl.tags).toContainEqual(["name", "Fresh"])
|
||||||
expect(tmpl.tags).toContainEqual(["about", "desc"])
|
expect(tmpl.tags).toContainEqual(["about", "desc"])
|
||||||
expect(tmpl.tags).toContainEqual(["picture", "https://pic", "100x100"])
|
expect(tmpl.tags).toContainEqual(["picture", "https://pic", "100x100"])
|
||||||
// A d-identifier is auto-generated.
|
expect(tmpl.tags).toContainEqual(["d", "room2"])
|
||||||
expect(tmpl.tags.find(t => t[0] === "d")?.[1]).toBeTruthy()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("throws on the wrong kind", async () => {
|
it("throws on the wrong kind", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import {describe, it, expect} from "vitest"
|
||||||
|
import {makeSecret, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER, getTagValue} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {Nip01Signer} from "@welshman/signer"
|
||||||
|
import {RoomRemoveMember, RoomRemoveMemberBuilder} from "../src/kinds/RoomRemoveMember"
|
||||||
|
|
||||||
|
const signer = new Nip01Signer(makeSecret())
|
||||||
|
const pubkey = "ee".repeat(32)
|
||||||
|
const a = "aa".repeat(32)
|
||||||
|
const b = "bb".repeat(32)
|
||||||
|
|
||||||
|
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
|
||||||
|
({
|
||||||
|
id: "ff".repeat(32),
|
||||||
|
pubkey,
|
||||||
|
created_at: 0,
|
||||||
|
kind: ROOM_REMOVE_MEMBER,
|
||||||
|
tags: [],
|
||||||
|
content: "",
|
||||||
|
sig: "00".repeat(64),
|
||||||
|
...overrides,
|
||||||
|
}) as TrustedEvent
|
||||||
|
|
||||||
|
describe("RoomRemoveMember", () => {
|
||||||
|
it("uses the remove kind and reads pubkeys", async () => {
|
||||||
|
const op = await RoomRemoveMember.fromEvent(makeEvent({tags: [["h", "room1"], ["p", a]]}))
|
||||||
|
|
||||||
|
expect(op.kind).toBe(ROOM_REMOVE_MEMBER)
|
||||||
|
expect(op.pubkeys()).toEqual([a])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("round-trips through the remove builder", async () => {
|
||||||
|
const op = await RoomRemoveMember.fromEvent(
|
||||||
|
makeEvent({tags: [["h", "room1"], ["p", a], ["p", b]]}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tmpl = await op.builder().toTemplate(signer)
|
||||||
|
|
||||||
|
expect(tmpl.kind).toBe(ROOM_REMOVE_MEMBER)
|
||||||
|
expect(tmpl.tags.filter(t => t[0] === "h").length).toBe(1)
|
||||||
|
expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(2)
|
||||||
|
expect(getTagValue("h", tmpl.tags)).toBe("room1")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("builds from a fresh remove builder", async () => {
|
||||||
|
const tmpl = await new RoomRemoveMemberBuilder().setGroup("room2").addPubkey(a).toTemplate(signer)
|
||||||
|
|
||||||
|
expect(tmpl.kind).toBe(ROOM_REMOVE_MEMBER)
|
||||||
|
expect(getTagValue("h", tmpl.tags)).toBe("room2")
|
||||||
|
expect(tmpl.tags).toContainEqual(["p", a])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws on the wrong kind", async () => {
|
||||||
|
await expect(
|
||||||
|
RoomRemoveMember.fromEvent(makeEvent({kind: ROOM_ADD_MEMBER, tags: [["p", a]]})),
|
||||||
|
).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -46,9 +46,9 @@ describe("SearchRelayList", () => {
|
|||||||
|
|
||||||
it("adds and removes relays via a fresh builder", async () => {
|
it("adds and removes relays via a fresh builder", async () => {
|
||||||
const tmpl = await new SearchRelayListBuilder()
|
const tmpl = await new SearchRelayListBuilder()
|
||||||
.addRelay(relayA)
|
.addUrl(relayA)
|
||||||
.addRelay(relayB)
|
.addUrl(relayB)
|
||||||
.removeRelay(relayA)
|
.removeUrl(relayA)
|
||||||
.toTemplate(signer)
|
.toTemplate(signer)
|
||||||
|
|
||||||
expect(tmpl.kind).toBe(SEARCH_RELAYS)
|
expect(tmpl.kind).toBe(SEARCH_RELAYS)
|
||||||
@@ -58,7 +58,7 @@ describe("SearchRelayList", () => {
|
|||||||
|
|
||||||
it("round-trips public and private relays through encryption", async () => {
|
it("round-trips public and private relays through encryption", async () => {
|
||||||
const event = await new SearchRelayListBuilder()
|
const event = await new SearchRelayListBuilder()
|
||||||
.addRelay(relayA)
|
.addUrl(relayA)
|
||||||
.addPrivate(["relay", relayB])
|
.addPrivate(["relay", relayB])
|
||||||
.toEvent(signer)
|
.toEvent(signer)
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ describe("Thread", () => {
|
|||||||
const tmpl = await new ThreadBuilder()
|
const tmpl = await new ThreadBuilder()
|
||||||
.setTitle("New thread")
|
.setTitle("New thread")
|
||||||
.setContent("body")
|
.setContent("body")
|
||||||
.group("room")
|
.setGroup("room")
|
||||||
.toTemplate(signer)
|
.toTemplate(signer)
|
||||||
|
|
||||||
expect(tmpl.kind).toBe(THREAD)
|
expect(tmpl.kind).toBe(THREAD)
|
||||||
|
|||||||
@@ -48,13 +48,6 @@ describe("TimeEvent", () => {
|
|||||||
expect(time.content()).toBe("meetup")
|
expect(time.content()).toBe("meetup")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("falls back to the legacy name tag for title", async () => {
|
|
||||||
const event = makeEvent({tags: [["name", "Legacy"]]})
|
|
||||||
const time = await TimeEvent.fromEvent(event)
|
|
||||||
|
|
||||||
expect(time.title()).toBe("Legacy")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("round-trips with no duplicate represented tags", async () => {
|
it("round-trips with no duplicate represented tags", async () => {
|
||||||
const event = makeEvent({
|
const event = makeEvent({
|
||||||
content: "meetup",
|
content: "meetup",
|
||||||
@@ -101,15 +94,16 @@ describe("TimeEvent", () => {
|
|||||||
expect(tmpl.tags).not.toContainEqual(["D", "999999"])
|
expect(tmpl.tags).not.toContainEqual(["D", "999999"])
|
||||||
})
|
})
|
||||||
|
|
||||||
it("builds from a fresh builder with an auto-generated d", async () => {
|
it("builds from a fresh builder", async () => {
|
||||||
const tmpl = await new TimeEventBuilder()
|
const tmpl = await new TimeEventBuilder()
|
||||||
|
.setIdentifier("event1")
|
||||||
.setTitle("Fresh")
|
.setTitle("Fresh")
|
||||||
.setStart(start)
|
.setStart(start)
|
||||||
.setEnd(end)
|
.setEnd(end)
|
||||||
.toTemplate(signer)
|
.toTemplate(signer)
|
||||||
|
|
||||||
expect(tmpl.kind).toBe(EVENT_TIME)
|
expect(tmpl.kind).toBe(EVENT_TIME)
|
||||||
expect(tmpl.tags.find(t => t[0] === "d")?.[1]).toBeTruthy()
|
expect(tmpl.tags).toContainEqual(["d", "event1"])
|
||||||
expect(tmpl.tags).toContainEqual(["title", "Fresh"])
|
expect(tmpl.tags).toContainEqual(["title", "Fresh"])
|
||||||
expect(tmpl.tags).toContainEqual(["start", String(start)])
|
expect(tmpl.tags).toContainEqual(["start", String(start)])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ describe("ZapGoal", () => {
|
|||||||
expect(goal.title()).toBe("New server fund")
|
expect(goal.title()).toBe("New server fund")
|
||||||
expect(goal.summary()).toBe("help us buy a server")
|
expect(goal.summary()).toBe("help us buy a server")
|
||||||
expect(goal.amount()).toBe(500000)
|
expect(goal.amount()).toBe(500000)
|
||||||
expect(goal.relays()).toEqual(["wss://relay.one", "wss://relay.two"])
|
expect(goal.urls()).toEqual(["wss://relay.one", "wss://relay.two"])
|
||||||
})
|
})
|
||||||
|
|
||||||
it("defaults amount to 0 when missing or unparseable", async () => {
|
it("defaults amount to 0 when missing or unparseable", async () => {
|
||||||
@@ -71,7 +71,7 @@ describe("ZapGoal", () => {
|
|||||||
.setTitle("Goal")
|
.setTitle("Goal")
|
||||||
.setSummary("a summary")
|
.setSummary("a summary")
|
||||||
.setAmount(1000)
|
.setAmount(1000)
|
||||||
.setRelays(["wss://relay.one"])
|
.setUrls(["wss://relay.one"])
|
||||||
.toTemplate(signer)
|
.toTemplate(signer)
|
||||||
|
|
||||||
expect(tmpl.kind).toBe(ZAP_GOAL)
|
expect(tmpl.kind).toBe(ZAP_GOAL)
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ describe("ZapRequest", () => {
|
|||||||
["p", recipient],
|
["p", recipient],
|
||||||
["e", eventId],
|
["e", eventId],
|
||||||
["relays", "wss://relay.one", "wss://relay.two"],
|
["relays", "wss://relay.one", "wss://relay.two"],
|
||||||
["anon"],
|
|
||||||
["alt", "x"],
|
["alt", "x"],
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -42,8 +41,7 @@ describe("ZapRequest", () => {
|
|||||||
expect(req.lnurl()).toBe("lnurl1xyz")
|
expect(req.lnurl()).toBe("lnurl1xyz")
|
||||||
expect(req.recipient()).toBe(recipient)
|
expect(req.recipient()).toBe(recipient)
|
||||||
expect(req.eventId()).toBe(eventId)
|
expect(req.eventId()).toBe(eventId)
|
||||||
expect(req.relays()).toEqual(["wss://relay.one", "wss://relay.two"])
|
expect(req.urls()).toEqual(["wss://relay.one", "wss://relay.two"])
|
||||||
expect(req.isAnonymous()).toBe(true)
|
|
||||||
expect(req.comment()).toBe("thanks!")
|
expect(req.comment()).toBe("thanks!")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -56,7 +54,6 @@ describe("ZapRequest", () => {
|
|||||||
["p", recipient],
|
["p", recipient],
|
||||||
["e", eventId],
|
["e", eventId],
|
||||||
["relays", "wss://relay.one"],
|
["relays", "wss://relay.one"],
|
||||||
["anon"],
|
|
||||||
["alt", "x"],
|
["alt", "x"],
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -69,7 +66,6 @@ describe("ZapRequest", () => {
|
|||||||
expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(1)
|
expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(1)
|
||||||
expect(tmpl.tags.filter(t => t[0] === "e").length).toBe(1)
|
expect(tmpl.tags.filter(t => t[0] === "e").length).toBe(1)
|
||||||
expect(tmpl.tags.filter(t => t[0] === "relays").length).toBe(1)
|
expect(tmpl.tags.filter(t => t[0] === "relays").length).toBe(1)
|
||||||
expect(tmpl.tags.filter(t => t[0] === "anon").length).toBe(1)
|
|
||||||
expect(tmpl.tags).toContainEqual(["alt", "x"])
|
expect(tmpl.tags).toContainEqual(["alt", "x"])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -79,8 +75,7 @@ describe("ZapRequest", () => {
|
|||||||
.setLnurl("lnurl1abc")
|
.setLnurl("lnurl1abc")
|
||||||
.setRecipient(recipient)
|
.setRecipient(recipient)
|
||||||
.setEventId(eventId)
|
.setEventId(eventId)
|
||||||
.setRelays(["wss://relay.one"])
|
.setUrls(["wss://relay.one"])
|
||||||
.setAnonymous()
|
|
||||||
.setComment("hi")
|
.setComment("hi")
|
||||||
.toTemplate(signer)
|
.toTemplate(signer)
|
||||||
|
|
||||||
@@ -91,7 +86,6 @@ describe("ZapRequest", () => {
|
|||||||
expect(tmpl.tags).toContainEqual(["p", recipient])
|
expect(tmpl.tags).toContainEqual(["p", recipient])
|
||||||
expect(tmpl.tags).toContainEqual(["e", eventId])
|
expect(tmpl.tags).toContainEqual(["e", eventId])
|
||||||
expect(tmpl.tags).toContainEqual(["relays", "wss://relay.one"])
|
expect(tmpl.tags).toContainEqual(["relays", "wss://relay.one"])
|
||||||
expect(tmpl.tags).toContainEqual(["anon"])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("throws on the wrong kind", async () => {
|
it("throws on the wrong kind", async () => {
|
||||||
|
|||||||
@@ -20,12 +20,14 @@
|
|||||||
"prepublishOnly": "pnpm run build"
|
"prepublishOnly": "pnpm run build"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
"@welshman/feeds": "workspace:*",
|
||||||
"@welshman/lib": "workspace:*",
|
"@welshman/lib": "workspace:*",
|
||||||
"@welshman/signer": "workspace:*",
|
"@welshman/signer": "workspace:*",
|
||||||
"@welshman/util": "workspace:*",
|
"@welshman/util": "workspace:*",
|
||||||
"nostr-tools": "^2.19.4"
|
"nostr-tools": "^2.19.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@welshman/feeds": "workspace:*",
|
||||||
"@welshman/lib": "workspace:*",
|
"@welshman/lib": "workspace:*",
|
||||||
"@welshman/signer": "workspace:*",
|
"@welshman/signer": "workspace:*",
|
||||||
"@welshman/util": "workspace:*",
|
"@welshman/util": "workspace:*",
|
||||||
|
|||||||
@@ -1,22 +1,26 @@
|
|||||||
import {first, partition, spec} from "@welshman/lib"
|
import {first, partition, randomId, spec} from "@welshman/lib"
|
||||||
import type {Maybe, MaybeAsync} from "@welshman/lib"
|
import type {Maybe, MaybeAsync} from "@welshman/lib"
|
||||||
import {stamp, prep} from "@welshman/util"
|
import {stamp, prep, isParameterizedReplaceableKind} from "@welshman/util"
|
||||||
import type {EventTemplate, SignedEvent, HashedEvent} from "@welshman/util"
|
import type {EventTemplate, SignedEvent, HashedEvent} from "@welshman/util"
|
||||||
import type {ISigner} from "@welshman/signer"
|
import type {ISigner} from "@welshman/signer"
|
||||||
import type {EventReader} from "./EventReader.js"
|
import type {EventReader} from "./EventReader.js"
|
||||||
|
|
||||||
export abstract class EventBuilder<Reader extends EventReader> {
|
export abstract class EventBuilder<Reader extends EventReader> {
|
||||||
abstract readonly kind: number
|
abstract readonly kind: number
|
||||||
|
content: string
|
||||||
groupTag?: string[]
|
groupTag?: string[]
|
||||||
protectTag?: string[]
|
protectTag?: string[]
|
||||||
expiresTag?: string[]
|
expirationTag?: string[]
|
||||||
|
identifierTag?: string[]
|
||||||
extraTags: string[][] = []
|
extraTags: string[][] = []
|
||||||
|
|
||||||
constructor(readonly reader?: Reader) {
|
constructor(readonly reader?: Reader) {
|
||||||
|
this.content = reader?.event.content ?? ""
|
||||||
this.extraTags = reader?.event.tags ?? []
|
this.extraTags = reader?.event.tags ?? []
|
||||||
this.groupTag = first(this.consumeTags("h"))
|
this.groupTag = first(this.consumeTags("h"))
|
||||||
this.protectTag = first(this.consumeTags("-"))
|
this.protectTag = first(this.consumeTags("-"))
|
||||||
this.expiresTag = first(this.consumeTags("expiration"))
|
this.expirationTag = first(this.consumeTags("expiration"))
|
||||||
|
this.identifierTag = first(this.consumeTags("d"))
|
||||||
}
|
}
|
||||||
|
|
||||||
protected consumeTags(key: string): string[][] {
|
protected consumeTags(key: string): string[][] {
|
||||||
@@ -27,20 +31,50 @@ export abstract class EventBuilder<Reader extends EventReader> {
|
|||||||
return consumed
|
return consumed
|
||||||
}
|
}
|
||||||
|
|
||||||
group(group: Maybe<string>) {
|
setContent(content: string) {
|
||||||
this.groupTag = group ? ["h", group] : undefined
|
this.content = content
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
protect(protect: boolean) {
|
setGroup(group: string) {
|
||||||
|
this.groupTag = ["h", group]
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
clearGroup() {
|
||||||
|
this.groupTag = undefined
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setProtected(protect: boolean) {
|
||||||
this.protectTag = protect ? ["-"] : undefined
|
this.protectTag = protect ? ["-"] : undefined
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
expires(expires: Maybe<number>) {
|
setExpiration(expiration: number) {
|
||||||
this.expiresTag = expires ? ["expiration", String(expires)] : undefined
|
this.expirationTag = ["expiration", String(expiration)]
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
clearExpiration() {
|
||||||
|
this.expirationTag = undefined
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setIdentifier(identifier = randomId()) {
|
||||||
|
this.identifierTag = ["d", identifier]
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
clearIdentifier() {
|
||||||
|
this.identifierTag = undefined
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
@@ -50,17 +84,22 @@ export abstract class EventBuilder<Reader extends EventReader> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected buildContent(signer?: ISigner): MaybeAsync<string> {
|
protected buildContent(signer?: ISigner): MaybeAsync<string> {
|
||||||
return ""
|
return this.content
|
||||||
}
|
}
|
||||||
|
|
||||||
protected validate(): void {}
|
protected validate(): void {
|
||||||
|
if (isParameterizedReplaceableKind(this.kind) && !this.identifierTag) {
|
||||||
|
throw new Error(`A d tag is required for kind ${this.kind}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private behaviorTags(): string[][] {
|
private behaviorTags(): string[][] {
|
||||||
const tags: string[][] = []
|
const tags: string[][] = []
|
||||||
|
|
||||||
if (this.groupTag) tags.push(this.groupTag)
|
if (this.groupTag) tags.push(this.groupTag)
|
||||||
if (this.protectTag) tags.push(this.protectTag)
|
if (this.protectTag) tags.push(this.protectTag)
|
||||||
if (this.expiresTag) tags.push(this.expiresTag)
|
if (this.expirationTag) tags.push(this.expirationTag)
|
||||||
|
if (this.identifierTag) tags.push(this.identifierTag)
|
||||||
|
|
||||||
return tags
|
return tags
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,30 @@ export abstract class EventReader {
|
|||||||
|
|
||||||
constructor(readonly event: TrustedEvent) {}
|
constructor(readonly event: TrustedEvent) {}
|
||||||
|
|
||||||
|
// Returns a reusable, class-bound reader factory over a fixed signer. Unlike a
|
||||||
|
// detached `fromEvent` (which would lose its binding, since it does
|
||||||
|
// `new this(event)`), this is invoked on the class up front, so it's safe
|
||||||
|
// point-free — e.g. `eventToItem: Profile.factory(signer)`. Pass the signer
|
||||||
|
// whenever you have one; the reader decides whether it needs it, so callers
|
||||||
|
// stay decoupled from which kinds carry encrypted content.
|
||||||
|
static factory<T extends EventReader>(this: new (event: TrustedEvent) => T, signer?: ISigner) {
|
||||||
|
const Reader = this
|
||||||
|
|
||||||
|
return async (event: TrustedEvent): Promise<T> => {
|
||||||
|
const reader = new Reader(event)
|
||||||
|
|
||||||
|
if (event.kind !== reader.kind) {
|
||||||
|
throw new Error(`Expected a kind ${reader.kind} event, got kind ${event.kind}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await reader.parse(signer)
|
||||||
|
|
||||||
|
return reader
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static async fromEvent<T extends EventReader>(
|
static async fromEvent<T extends EventReader>(
|
||||||
this: (new (event: TrustedEvent) => T),
|
this: new (event: TrustedEvent) => T,
|
||||||
event: TrustedEvent,
|
event: TrustedEvent,
|
||||||
signer?: ISigner,
|
signer?: ISigner,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
@@ -31,10 +53,18 @@ export abstract class EventReader {
|
|||||||
return this.event.id
|
return this.event.id
|
||||||
}
|
}
|
||||||
|
|
||||||
pubkey() {
|
author() {
|
||||||
return this.event.pubkey
|
return this.event.pubkey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
content() {
|
||||||
|
return this.event.content
|
||||||
|
}
|
||||||
|
|
||||||
|
tags() {
|
||||||
|
return this.event.tags
|
||||||
|
}
|
||||||
|
|
||||||
createdAt() {
|
createdAt() {
|
||||||
return this.event.created_at
|
return this.event.created_at
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export abstract class ListReader extends EventReader {
|
|||||||
|
|
||||||
if (!this.event.content) {
|
if (!this.event.content) {
|
||||||
this.decrypted = true
|
this.decrypted = true
|
||||||
} else if (signer) {
|
} else if (signer && (await signer.getPubkey()) === this.event.pubkey) {
|
||||||
try {
|
try {
|
||||||
const plaintext = await decrypt(signer, this.event.pubkey, this.event.content)
|
const plaintext = await decrypt(signer, this.event.pubkey, this.event.content)
|
||||||
|
|
||||||
|
|||||||
@@ -20,23 +20,26 @@ export * from "./kinds/PinList.js"
|
|||||||
export * from "./kinds/Poll.js"
|
export * from "./kinds/Poll.js"
|
||||||
export * from "./kinds/PollResponse.js"
|
export * from "./kinds/PollResponse.js"
|
||||||
export * from "./kinds/Profile.js"
|
export * from "./kinds/Profile.js"
|
||||||
|
export * from "./kinds/RelayAddMember.js"
|
||||||
export * from "./kinds/RelayInvite.js"
|
export * from "./kinds/RelayInvite.js"
|
||||||
export * from "./kinds/RelayJoin.js"
|
export * from "./kinds/RelayJoin.js"
|
||||||
export * from "./kinds/RelayLeave.js"
|
export * from "./kinds/RelayLeave.js"
|
||||||
export * from "./kinds/RelayList.js"
|
export * from "./kinds/RelayList.js"
|
||||||
export * from "./kinds/RelayMembers.js"
|
export * from "./kinds/RelayMembers.js"
|
||||||
export * from "./kinds/RelayMembershipOp.js"
|
export * from "./kinds/RelayRemoveMember.js"
|
||||||
export * from "./kinds/RelaySet.js"
|
export * from "./kinds/RelaySet.js"
|
||||||
export * from "./kinds/Report.js"
|
export * from "./kinds/Report.js"
|
||||||
|
export * from "./kinds/RoomAddMember.js"
|
||||||
export * from "./kinds/RoomAdmins.js"
|
export * from "./kinds/RoomAdmins.js"
|
||||||
export * from "./kinds/RoomCreate.js"
|
export * from "./kinds/RoomCreate.js"
|
||||||
export * from "./kinds/RoomCreatePermission.js"
|
export * from "./kinds/RoomCreatePermission.js"
|
||||||
export * from "./kinds/RoomDelete.js"
|
export * from "./kinds/RoomDelete.js"
|
||||||
|
export * from "./kinds/RoomEdit.js"
|
||||||
export * from "./kinds/RoomJoin.js"
|
export * from "./kinds/RoomJoin.js"
|
||||||
export * from "./kinds/RoomLeave.js"
|
export * from "./kinds/RoomLeave.js"
|
||||||
export * from "./kinds/RoomList.js"
|
export * from "./kinds/RoomList.js"
|
||||||
export * from "./kinds/RoomMembers.js"
|
export * from "./kinds/RoomMembers.js"
|
||||||
export * from "./kinds/RoomMembershipOp.js"
|
export * from "./kinds/RoomRemoveMember.js"
|
||||||
export * from "./kinds/RoomMeta.js"
|
export * from "./kinds/RoomMeta.js"
|
||||||
export * from "./kinds/SearchRelayList.js"
|
export * from "./kinds/SearchRelayList.js"
|
||||||
export * from "./kinds/Thread.js"
|
export * from "./kinds/Thread.js"
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ import {BLOCKED_RELAYS, getTagValues, normalizeRelayUrl} from "@welshman/util"
|
|||||||
import {ListReader} from "../ListReader.js"
|
import {ListReader} from "../ListReader.js"
|
||||||
import {ListBuilder} from "../ListBuilder.js"
|
import {ListBuilder} from "../ListBuilder.js"
|
||||||
|
|
||||||
// NIP-51 kind-10006 blocked relays. Entries are marker-less ['relay', url] tags
|
// NIP-51 kind-10006 blocked relays list.
|
||||||
// (NOT NIP-65 'r' tags with read/write markers). `urls()` gates AUTH (never auth
|
|
||||||
// to a blocked relay) and relay selection, so it stays a flat, normalized set.
|
|
||||||
export class BlockedRelayList extends ListReader {
|
export class BlockedRelayList extends ListReader {
|
||||||
readonly kind = BLOCKED_RELAYS
|
readonly kind = BLOCKED_RELAYS
|
||||||
|
|
||||||
@@ -25,15 +23,15 @@ export class BlockedRelayList extends ListReader {
|
|||||||
export class BlockedRelayListBuilder extends ListBuilder<BlockedRelayList> {
|
export class BlockedRelayListBuilder extends ListBuilder<BlockedRelayList> {
|
||||||
readonly kind = BLOCKED_RELAYS
|
readonly kind = BLOCKED_RELAYS
|
||||||
|
|
||||||
addRelay(url: string) {
|
addUrl(url: string) {
|
||||||
return this.addPublic(["relay", normalizeRelayUrl(url)])
|
return this.addPublic(["relay", normalizeRelayUrl(url)])
|
||||||
}
|
}
|
||||||
|
|
||||||
removeRelay(url: string) {
|
removeUrl(url: string) {
|
||||||
return this.drop(nthEq(1, normalizeRelayUrl(url)))
|
return this.drop(nthEq(1, normalizeRelayUrl(url)))
|
||||||
}
|
}
|
||||||
|
|
||||||
setRelays(urls: string[]) {
|
setUrls(urls: string[]) {
|
||||||
this.clear()
|
this.clear()
|
||||||
|
|
||||||
return this.addPublic(...urls.map(url => ["relay", normalizeRelayUrl(url)]))
|
return this.addPublic(...urls.map(url => ["relay", normalizeRelayUrl(url)]))
|
||||||
|
|||||||
@@ -3,18 +3,16 @@ import {BLOSSOM_SERVERS, getTagValues, normalizeRelayUrl} from "@welshman/util"
|
|||||||
import {ListReader} from "../ListReader.js"
|
import {ListReader} from "../ListReader.js"
|
||||||
import {ListBuilder} from "../ListBuilder.js"
|
import {ListBuilder} from "../ListBuilder.js"
|
||||||
|
|
||||||
// Blossom BUD-03 user server list (kind 10063). Server endpoints are stored as
|
// Blossom BUD-03 kind-10063 user server list.
|
||||||
// `["server", url]` tags (NOT the `r`/`relay` tags used by relay lists), so the
|
|
||||||
// generic relay-tag helpers would miss them. Effectively public-only.
|
|
||||||
export class BlossomServerList extends ListReader {
|
export class BlossomServerList extends ListReader {
|
||||||
readonly kind = BLOSSOM_SERVERS
|
readonly kind = BLOSSOM_SERVERS
|
||||||
|
|
||||||
servers() {
|
urls() {
|
||||||
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(normalizeRelayUrl(url))
|
return this.urls().includes(normalizeRelayUrl(url))
|
||||||
}
|
}
|
||||||
|
|
||||||
builder() {
|
builder() {
|
||||||
@@ -25,15 +23,15 @@ export class BlossomServerList extends ListReader {
|
|||||||
export class BlossomServerListBuilder extends ListBuilder<BlossomServerList> {
|
export class BlossomServerListBuilder extends ListBuilder<BlossomServerList> {
|
||||||
readonly kind = BLOSSOM_SERVERS
|
readonly kind = BLOSSOM_SERVERS
|
||||||
|
|
||||||
addServer(url: string) {
|
addUrl(url: string) {
|
||||||
return this.addPublic(["server", normalizeRelayUrl(url)])
|
return this.addPublic(["server", normalizeRelayUrl(url)])
|
||||||
}
|
}
|
||||||
|
|
||||||
removeServer(url: string) {
|
removeUrl(url: string) {
|
||||||
return this.drop(nthEq(1, normalizeRelayUrl(url)))
|
return this.drop(nthEq(1, normalizeRelayUrl(url)))
|
||||||
}
|
}
|
||||||
|
|
||||||
setServers(urls: string[]) {
|
setUrls(urls: string[]) {
|
||||||
this.clear()
|
this.clear()
|
||||||
|
|
||||||
return this.addPublic(...urls.map(url => ["server", normalizeRelayUrl(url)]))
|
return this.addPublic(...urls.map(url => ["server", normalizeRelayUrl(url)]))
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ import {
|
|||||||
import {ListReader} from "../ListReader.js"
|
import {ListReader} from "../ListReader.js"
|
||||||
import {ListBuilder} from "../ListBuilder.js"
|
import {ListBuilder} from "../ListBuilder.js"
|
||||||
|
|
||||||
// NIP-51 kind-10003 bookmark list. Mixed entries (notes via 'e', articles via
|
// NIP-51 kind-10003 bookmark list.
|
||||||
// 'a', hashtags via 't', urls via 'r') can be bookmarked publicly (tags) or
|
|
||||||
// privately (encrypted content); accessors treat both as one merged set.
|
|
||||||
export class BookmarkList extends ListReader {
|
export class BookmarkList extends ListReader {
|
||||||
readonly kind = BOOKMARKS
|
readonly kind = BOOKMARKS
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {first, randomId} from "@welshman/lib"
|
import {first} from "@welshman/lib"
|
||||||
import {CLASSIFIED, getTag, getTagValue, getTagValues, getTopicTagValues} from "@welshman/util"
|
import {CLASSIFIED, getTag, getTagValue, getTagValues, getTopicTagValues} from "@welshman/util"
|
||||||
import type {ISigner} from "@welshman/signer"
|
import type {ISigner} from "@welshman/signer"
|
||||||
import {EventReader} from "../EventReader.js"
|
import {EventReader} from "../EventReader.js"
|
||||||
@@ -7,14 +7,18 @@ import {EventBuilder} from "../EventBuilder.js"
|
|||||||
export type ClassifiedPrice = {
|
export type ClassifiedPrice = {
|
||||||
amount: number
|
amount: number
|
||||||
currency: string
|
currency: string
|
||||||
|
frequency: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NIP-99 kind-30402 addressable classified listing. Addressable via the "d" tag;
|
const parsePrice = ([, amount = "0", currency = "SAT", frequency = ""]: string[]): ClassifiedPrice | undefined => {
|
||||||
// the listing description lives in `content` as plain text (not JSON). The price
|
const value = parseFloat(amount)
|
||||||
// is carried in a ["price", amount, currency] tag with the currency defaulting to
|
|
||||||
// "SAT", images in repeated "image" tags, and topics in "t" tags; room scoping is
|
if (!isNaN(value)) {
|
||||||
// handled by the base `group` behavior tag. Plain-text content, so it extends
|
return {amount: value, currency, frequency}
|
||||||
// EventReader/EventBuilder directly.
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NIP-99 kind-30402 classified listing.
|
||||||
export class Classified extends EventReader {
|
export class Classified extends EventReader {
|
||||||
readonly kind = CLASSIFIED
|
readonly kind = CLASSIFIED
|
||||||
|
|
||||||
@@ -26,14 +30,12 @@ export class Classified extends EventReader {
|
|||||||
return getTagValue("summary", this.event.tags)
|
return getTagValue("summary", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
content() {
|
|
||||||
return this.event.content
|
|
||||||
}
|
|
||||||
|
|
||||||
price(): ClassifiedPrice | undefined {
|
price(): ClassifiedPrice | undefined {
|
||||||
const tag = getTag("price", this.event.tags)
|
const tag = getTag("price", this.event.tags)
|
||||||
|
|
||||||
return tag ? {amount: parseFloat(tag[1]) || 0, currency: tag[2] || "SAT"} : undefined
|
if (tag) {
|
||||||
|
return parsePrice(tag)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
status() {
|
status() {
|
||||||
@@ -56,101 +58,70 @@ export class Classified extends EventReader {
|
|||||||
export class ClassifiedBuilder extends EventBuilder<Classified> {
|
export class ClassifiedBuilder extends EventBuilder<Classified> {
|
||||||
readonly kind = CLASSIFIED
|
readonly kind = CLASSIFIED
|
||||||
|
|
||||||
identifier = randomId()
|
titleTag?: string[]
|
||||||
title?: string
|
summaryTag?: string[]
|
||||||
summary?: string
|
priceTag?: string[]
|
||||||
content = ""
|
statusTag?: string[]
|
||||||
price?: ClassifiedPrice
|
imageTags: string[][] = []
|
||||||
status?: string
|
topicTags: string[][] = []
|
||||||
images: string[] = []
|
|
||||||
topics: string[] = []
|
|
||||||
|
|
||||||
constructor(readonly reader?: Classified) {
|
constructor(readonly reader?: Classified) {
|
||||||
super(reader)
|
super(reader)
|
||||||
|
|
||||||
// Consume the represented tags out of the carried-over extraTags so they
|
this.titleTag = first(this.consumeTags("title"))
|
||||||
// round-trip through the structured fields below rather than being emitted
|
this.summaryTag = first(this.consumeTags("summary"))
|
||||||
// twice (once from buildTags, once from the base's extraTags pass-through).
|
this.priceTag = first(this.consumeTags("price"))
|
||||||
const d = first(this.consumeTags("d"))
|
this.statusTag = first(this.consumeTags("status"))
|
||||||
const price = first(this.consumeTags("price"))
|
this.imageTags = this.consumeTags("image")
|
||||||
|
this.topicTags = this.consumeTags("t")
|
||||||
this.identifier = d?.[1] || randomId()
|
|
||||||
this.title = first(this.consumeTags("title"))?.[1]
|
|
||||||
this.summary = first(this.consumeTags("summary"))?.[1]
|
|
||||||
this.content = reader?.event.content ?? ""
|
|
||||||
this.price = price ? {amount: parseFloat(price[1]) || 0, currency: price[2] || "SAT"} : undefined
|
|
||||||
this.status = first(this.consumeTags("status"))?.[1]
|
|
||||||
this.images = this.consumeTags("image").map(t => t[1])
|
|
||||||
this.topics = this.consumeTags("t").map(t => t[1])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setTitle(title: string) {
|
setTitle(title: string) {
|
||||||
this.title = title
|
this.titleTag = ["title", title]
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
setSummary(summary: string) {
|
setSummary(summary: string) {
|
||||||
this.summary = summary
|
this.summaryTag = ["summary", summary]
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
setContent(content: string) {
|
setPrice(amount: number, currency = "SAT", frequency = "") {
|
||||||
this.content = content
|
this.priceTag = ["price", String(amount), currency, ...(frequency ? [frequency] : [])]
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
setPrice(amount: number, currency = "SAT") {
|
|
||||||
this.price = {amount, currency}
|
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus(status: string) {
|
setStatus(status: string) {
|
||||||
this.status = status
|
this.statusTag = ["status", status]
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
setImages(images: string[]) {
|
setImages(images: string[]) {
|
||||||
this.images = images
|
this.imageTags = images.map(image => ["image", image])
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
setTopics(topics: string[]) {
|
setTopics(topics: string[]) {
|
||||||
this.topics = topics
|
this.topicTags = topics.map(topic => ["t", topic])
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
protected validate() {
|
|
||||||
if (!this.identifier) {
|
|
||||||
throw new Error("Classified requires a d identifier")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected buildContent(_signer?: ISigner) {
|
|
||||||
return this.content
|
|
||||||
}
|
|
||||||
|
|
||||||
protected buildTags() {
|
protected buildTags() {
|
||||||
const tags: string[][] = [["d", this.identifier]]
|
const tags: string[][] = []
|
||||||
|
|
||||||
if (this.title) tags.push(["title", this.title])
|
if (this.titleTag) tags.push(this.titleTag)
|
||||||
if (this.summary) tags.push(["summary", this.summary])
|
if (this.summaryTag) tags.push(this.summaryTag)
|
||||||
if (this.price) tags.push(["price", String(this.price.amount), this.price.currency])
|
if (this.priceTag) tags.push(this.priceTag)
|
||||||
if (this.status) tags.push(["status", this.status])
|
if (this.statusTag) tags.push(this.statusTag)
|
||||||
|
|
||||||
for (const topic of this.topics) {
|
tags.push(...this.topicTags)
|
||||||
tags.push(["t", topic])
|
tags.push(...this.imageTags)
|
||||||
}
|
|
||||||
|
|
||||||
for (const image of this.images) {
|
|
||||||
tags.push(["image", image])
|
|
||||||
}
|
|
||||||
|
|
||||||
return tags
|
return tags
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import type {ISigner} from "@welshman/signer"
|
|||||||
import {EventReader} from "../EventReader.js"
|
import {EventReader} from "../EventReader.js"
|
||||||
import {EventBuilder} from "../EventBuilder.js"
|
import {EventBuilder} from "../EventBuilder.js"
|
||||||
|
|
||||||
// A NIP-22 reference to another event: its id, address (for addressable roots),
|
|
||||||
// kind, and pubkey. All optional since a comment may reference any subset.
|
|
||||||
export type CommentRef = {
|
export type CommentRef = {
|
||||||
id?: string
|
id?: string
|
||||||
address?: string
|
address?: string
|
||||||
@@ -14,91 +12,25 @@ export type CommentRef = {
|
|||||||
pubkey?: string
|
pubkey?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a reference from a full event, deriving the address only when the event
|
// NIP-22 kind-1111 comment (uppercase E/A/K/P tags = thread root, lowercase = immediate parent).
|
||||||
// is addressable/replaceable.
|
|
||||||
const refFromEvent = (event: TrustedEvent): CommentRef => ({
|
|
||||||
id: event.id,
|
|
||||||
pubkey: event.pubkey,
|
|
||||||
kind: String(event.kind),
|
|
||||||
address: isReplaceableKind(event.kind) ? getAddress(event) : undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Build the NIP-22 reference tags for one struct: pass uppercase keys for the
|
|
||||||
// root, lowercase for the parent.
|
|
||||||
const refTags = (ref: CommentRef, [idKey, addressKey, kindKey, pubkeyKey]: string[]) => {
|
|
||||||
const tags: string[][] = []
|
|
||||||
|
|
||||||
if (ref.id) tags.push([idKey, ref.id])
|
|
||||||
if (ref.address) tags.push([addressKey, ref.address])
|
|
||||||
if (ref.kind) tags.push([kindKey, ref.kind])
|
|
||||||
if (ref.pubkey) tags.push([pubkeyKey, ref.pubkey])
|
|
||||||
|
|
||||||
return tags
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read side of NIP-22 kind-1111 generic comment, flotilla's universal reply
|
|
||||||
// primitive: threads, goals, and polls reference their root event via uppercase
|
|
||||||
// E/A/K/P tags, while classifieds and calendar events reference addressable
|
|
||||||
// roots via #A. Uppercase tags (E/A/K/P) name the root of the thread; lowercase
|
|
||||||
// tags (e/a/k/p) name the immediate parent. The comment body is plain text in
|
|
||||||
// the event content (not JSON).
|
|
||||||
//
|
|
||||||
// The reference tags are read lazily into the root/parent accessors; any other
|
|
||||||
// tags round-trip via the base extraTags.
|
|
||||||
export class Comment extends EventReader {
|
export class Comment extends EventReader {
|
||||||
readonly kind = COMMENT
|
readonly kind = COMMENT
|
||||||
|
|
||||||
content() {
|
|
||||||
return this.event.content || ""
|
|
||||||
}
|
|
||||||
|
|
||||||
rootId() {
|
|
||||||
return getTagValue("E", this.event.tags)
|
|
||||||
}
|
|
||||||
|
|
||||||
rootAddress() {
|
|
||||||
return getTagValue("A", this.event.tags)
|
|
||||||
}
|
|
||||||
|
|
||||||
rootKind() {
|
|
||||||
return getTagValue("K", this.event.tags)
|
|
||||||
}
|
|
||||||
|
|
||||||
rootPubkey() {
|
|
||||||
return getTagValue("P", this.event.tags)
|
|
||||||
}
|
|
||||||
|
|
||||||
parentId() {
|
|
||||||
return getTagValue("e", this.event.tags)
|
|
||||||
}
|
|
||||||
|
|
||||||
parentAddress() {
|
|
||||||
return getTagValue("a", this.event.tags)
|
|
||||||
}
|
|
||||||
|
|
||||||
parentKind() {
|
|
||||||
return getTagValue("k", this.event.tags)
|
|
||||||
}
|
|
||||||
|
|
||||||
parentPubkey() {
|
|
||||||
return getTagValue("p", this.event.tags)
|
|
||||||
}
|
|
||||||
|
|
||||||
root(): CommentRef {
|
root(): CommentRef {
|
||||||
return {
|
return {
|
||||||
id: this.rootId(),
|
id: getTagValue("E", this.event.tags),
|
||||||
address: this.rootAddress(),
|
address: getTagValue("A", this.event.tags),
|
||||||
kind: this.rootKind(),
|
kind: getTagValue("K", this.event.tags),
|
||||||
pubkey: this.rootPubkey(),
|
pubkey: getTagValue("P", this.event.tags),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
parent(): CommentRef {
|
parent(): CommentRef {
|
||||||
return {
|
return {
|
||||||
id: this.parentId(),
|
id: getTagValue("e", this.event.tags),
|
||||||
address: this.parentAddress(),
|
address: getTagValue("a", this.event.tags),
|
||||||
kind: this.parentKind(),
|
kind: getTagValue("k", this.event.tags),
|
||||||
pubkey: this.parentPubkey(),
|
pubkey: getTagValue("p", this.event.tags),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,100 +39,64 @@ export class Comment extends EventReader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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<Comment> {
|
export class CommentBuilder extends EventBuilder<Comment> {
|
||||||
readonly kind = COMMENT
|
readonly kind = COMMENT
|
||||||
|
|
||||||
content = ""
|
rootTags: string[][] = []
|
||||||
root: CommentRef = {}
|
parentTags: string[][] = []
|
||||||
parent: CommentRef = {}
|
|
||||||
|
|
||||||
constructor(readonly reader?: Comment) {
|
constructor(readonly reader?: Comment) {
|
||||||
super(reader)
|
super(reader)
|
||||||
|
|
||||||
// Consume the represented reference tags out of the carried-over extraTags so
|
this.rootTags = this.consumeRefTags("E", "A", "K", "P")
|
||||||
// they round-trip through the root/parent structs below rather than being
|
this.parentTags = this.consumeRefTags("e", "a", "k", "p")
|
||||||
// emitted twice (once from buildTags, once from the base's extraTags
|
|
||||||
// pass-through). Uppercase keys name the root, lowercase the parent.
|
|
||||||
const rootId = first(this.consumeTags("E"))
|
|
||||||
const rootAddress = first(this.consumeTags("A"))
|
|
||||||
const rootKind = first(this.consumeTags("K"))
|
|
||||||
const rootPubkey = first(this.consumeTags("P"))
|
|
||||||
const parentId = first(this.consumeTags("e"))
|
|
||||||
const parentAddress = first(this.consumeTags("a"))
|
|
||||||
const parentKind = first(this.consumeTags("k"))
|
|
||||||
const parentPubkey = first(this.consumeTags("p"))
|
|
||||||
|
|
||||||
this.content = reader?.event.content ?? ""
|
|
||||||
this.root = {
|
|
||||||
id: rootId?.[1],
|
|
||||||
address: rootAddress?.[1],
|
|
||||||
kind: rootKind?.[1],
|
|
||||||
pubkey: rootPubkey?.[1],
|
|
||||||
}
|
|
||||||
this.parent = {
|
|
||||||
id: parentId?.[1],
|
|
||||||
address: parentAddress?.[1],
|
|
||||||
kind: parentKind?.[1],
|
|
||||||
pubkey: parentPubkey?.[1],
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setContent(content: string) {
|
private consumeRefTags(...keys: string[]) {
|
||||||
this.content = content
|
const tags: string[][] = []
|
||||||
|
|
||||||
return this
|
for (const key of keys) {
|
||||||
|
const tag = first(this.consumeTags(key))
|
||||||
|
|
||||||
|
if (tag) tags.push(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the thread root reference, deriving the address from kind/pubkey/identifier
|
|
||||||
// when the referenced event is addressable.
|
|
||||||
setRoot(kind: number, id: string, pubkey: string, identifier?: string) {
|
setRoot(kind: number, id: string, pubkey: string, identifier?: string) {
|
||||||
this.root = {
|
this.rootTags = [["K", String(kind)], ["E", id], ["P", pubkey]]
|
||||||
id,
|
|
||||||
pubkey,
|
if (identifier) {
|
||||||
kind: String(kind),
|
this.rootTags.push(["A", id])
|
||||||
address: identifier == null ? undefined : new Address(kind, pubkey, identifier).toString(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.parent = {
|
this.parentTags = [["k", String(kind)], ["e", id], ["p", pubkey]]
|
||||||
id,
|
|
||||||
pubkey,
|
if (identifier) {
|
||||||
kind: String(kind),
|
this.parentTags.push(["a", id])
|
||||||
address: identifier == null ? undefined : new Address(kind, pubkey, identifier).toString(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the thread root reference from a full event.
|
|
||||||
setRootFromEvent(event: TrustedEvent) {
|
setRootFromEvent(event: TrustedEvent) {
|
||||||
this.root = refFromEvent(event)
|
this.setRoot(event.kind, event.id, event.pubkey, getTagValue("d", event.tags))
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the immediate parent reference from a full event.
|
|
||||||
setParentFromEvent(event: TrustedEvent) {
|
setParentFromEvent(event: TrustedEvent) {
|
||||||
this.parent = refFromEvent(event)
|
this.setParent(event.kind, event.id, event.pubkey, getTagValue("d", event.tags))
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
protected buildContent(_signer?: ISigner) {
|
|
||||||
return this.content
|
|
||||||
}
|
|
||||||
|
|
||||||
protected buildTags() {
|
protected buildTags() {
|
||||||
return [
|
return [...this.rootTags, ...this.parentTags]
|
||||||
...refTags(this.root, ["E", "A", "K", "P"]),
|
|
||||||
...refTags(this.parent, ["e", "a", "k", "p"]),
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,21 +3,18 @@ import {EMOJIS, getAddressTagValues} from "@welshman/util"
|
|||||||
import {ListReader} from "../ListReader.js"
|
import {ListReader} from "../ListReader.js"
|
||||||
import {ListBuilder} from "../ListBuilder.js"
|
import {ListBuilder} from "../ListBuilder.js"
|
||||||
|
|
||||||
// NIP-51 / NIP-30 kind-10030 user emoji list. Holds references to kind 30030
|
// NIP-51 kind-10030 user emoji list.
|
||||||
// emoji sets via `a` tags, plus inline `["emoji", shortcode, url]` tags.
|
|
||||||
export class EmojiList extends ListReader {
|
export class EmojiList extends ListReader {
|
||||||
readonly kind = EMOJIS
|
readonly kind = EMOJIS
|
||||||
|
|
||||||
// Addresses of referenced emoji sets (kind 30030).
|
|
||||||
addresses() {
|
|
||||||
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"]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emojiSets() {
|
||||||
|
return uniq(getAddressTagValues(this.tags()))
|
||||||
|
}
|
||||||
|
|
||||||
builder() {
|
builder() {
|
||||||
return new EmojiListBuilder(this)
|
return new EmojiListBuilder(this)
|
||||||
}
|
}
|
||||||
@@ -30,11 +27,15 @@ export class EmojiListBuilder extends ListBuilder<EmojiList> {
|
|||||||
return this.addPublic(["emoji", shortcode, url])
|
return this.addPublic(["emoji", shortcode, url])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removeEmoji(value: string) {
|
||||||
|
return this.drop(nthEq(1, value))
|
||||||
|
}
|
||||||
|
|
||||||
addEmojiSet(address: string) {
|
addEmojiSet(address: string) {
|
||||||
return this.addPublic(["a", address])
|
return this.addPublic(["a", address])
|
||||||
}
|
}
|
||||||
|
|
||||||
removeEmoji(value: string) {
|
removeEmojiSet(value: string) {
|
||||||
return this.drop(nthEq(1, value))
|
return this.drop(nthEq(1, value))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
import {first, randomId, parseJson} from "@welshman/lib"
|
import {first, parseJson} from "@welshman/lib"
|
||||||
import {FEED, getTagValue} from "@welshman/util"
|
import {FEED, getTagValue} from "@welshman/util"
|
||||||
|
import {makeUnionFeed} from "@welshman/feeds"
|
||||||
|
import type {Feed as FeedDefinition} from "@welshman/feeds"
|
||||||
|
|
||||||
import {EventReader} from "../EventReader.js"
|
import {EventReader} from "../EventReader.js"
|
||||||
import {EventBuilder} from "../EventBuilder.js"
|
import {EventBuilder} from "../EventBuilder.js"
|
||||||
|
|
||||||
// NIP-51 kind-31890 saved-feed DEFINITION event. Addressable via the "d" tag.
|
// NIP-51 kind-31890 saved-feed definition.
|
||||||
// The feed definition is a @welshman/feeds `IFeed` AST, JSON-encoded in a "feed"
|
|
||||||
// tag. Content is empty (tags-only, no encryption). This is distinct from the
|
|
||||||
// kind-10014 FEEDS favorites list (FeedList.ts) which references these by
|
|
||||||
// address. Flotilla's isTopicFeed/isMentionFeed/isAddressFeed/isContextFeed/
|
|
||||||
// isPeopleFeed are pure functions over the IFeed AST and stay in flotilla's lib,
|
|
||||||
// not on this class. Tags-only, so it extends EventReader directly.
|
|
||||||
export class Feed extends EventReader {
|
export class Feed extends EventReader {
|
||||||
readonly kind = FEED
|
readonly kind = FEED
|
||||||
|
|
||||||
@@ -22,9 +18,7 @@ export class Feed extends EventReader {
|
|||||||
return getTagValue("description", this.event.tags) || ""
|
return getTagValue("description", this.event.tags) || ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// The feed definition is a @welshman/feeds `IFeed` AST. That package is not a
|
definition(): FeedDefinition | undefined {
|
||||||
// dependency of @welshman/domain, so it is typed as `unknown` here.
|
|
||||||
definition(): unknown {
|
|
||||||
return parseJson(getTagValue("feed", this.event.tags))
|
return parseJson(getTagValue("feed", this.event.tags))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,71 +30,44 @@ export class Feed extends EventReader {
|
|||||||
export class FeedBuilder extends EventBuilder<Feed> {
|
export class FeedBuilder extends EventBuilder<Feed> {
|
||||||
readonly kind = FEED
|
readonly kind = FEED
|
||||||
|
|
||||||
identifier = randomId()
|
titleTag?: string[]
|
||||||
title = ""
|
descriptionTag?: string[]
|
||||||
description = ""
|
definition: FeedDefinition = makeUnionFeed()
|
||||||
// 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"]
|
|
||||||
|
|
||||||
constructor(readonly reader?: Feed) {
|
constructor(readonly reader?: Feed) {
|
||||||
super(reader)
|
super(reader)
|
||||||
|
|
||||||
// Consume the represented tags out of the carried-over extraTags so they
|
this.titleTag = first(this.consumeTags("title"))
|
||||||
// round-trip through the structured fields below rather than being emitted
|
this.descriptionTag = first(this.consumeTags("description"))
|
||||||
// twice (once from buildTags, once from the base's extraTags pass-through).
|
this.definition = parseJson(first(this.consumeTags("feed"))?.[1]) ?? makeUnionFeed()
|
||||||
// The "alt" tag is a copy of title rebuilt in buildTags, so it's consumed and
|
|
||||||
// discarded here.
|
|
||||||
const d = first(this.consumeTags("d"))
|
|
||||||
const title = first(this.consumeTags("title"))
|
|
||||||
const description = first(this.consumeTags("description"))
|
|
||||||
const feed = first(this.consumeTags("feed"))
|
|
||||||
|
|
||||||
this.consumeTags("alt")
|
this.consumeTags("alt")
|
||||||
|
|
||||||
this.identifier = d?.[1] || randomId()
|
|
||||||
this.title = title?.[1] || ""
|
|
||||||
this.description = description?.[1] || ""
|
|
||||||
this.definition = feed ? parseJson(feed[1]) : ["union"]
|
|
||||||
}
|
|
||||||
|
|
||||||
setIdentifier(identifier: string) {
|
|
||||||
this.identifier = identifier
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setTitle(title: string) {
|
setTitle(title: string) {
|
||||||
this.title = title
|
this.titleTag = ["title", title]
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
setDescription(description: string) {
|
setDescription(description: string) {
|
||||||
this.description = description
|
this.descriptionTag = ["description", description]
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
setDefinition(definition: unknown) {
|
setDefinition(definition: FeedDefinition) {
|
||||||
this.definition = definition
|
this.definition = definition
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
protected validate() {
|
|
||||||
if (!this.identifier) {
|
|
||||||
throw new Error("Feed requires a d identifier")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected buildTags() {
|
protected buildTags() {
|
||||||
return [
|
const tags = [["feed", JSON.stringify(this.definition)]]
|
||||||
["d", this.identifier],
|
|
||||||
["alt", this.title],
|
if (this.titleTag) tags.push(this.titleTag)
|
||||||
["title", this.title],
|
if (this.descriptionTag) tags.push(this.descriptionTag)
|
||||||
["description", this.description],
|
|
||||||
["feed", JSON.stringify(this.definition)],
|
return tags
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ import {FEEDS, getAddressTagValues} from "@welshman/util"
|
|||||||
import {ListReader} from "../ListReader.js"
|
import {ListReader} from "../ListReader.js"
|
||||||
import {ListBuilder} from "../ListBuilder.js"
|
import {ListBuilder} from "../ListBuilder.js"
|
||||||
|
|
||||||
// NIP-51 kind-10014 saved feeds list. Entries are `a` tags pointing at kind 31890
|
// NIP-51 kind-10014 saved feeds list.
|
||||||
// FEED definitions, stored publicly (tags) or privately (encrypted content); the
|
|
||||||
// reader treats both as one merged set.
|
|
||||||
export class FeedList extends ListReader {
|
export class FeedList extends ListReader {
|
||||||
readonly kind = FEEDS
|
readonly kind = FEEDS
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,7 @@ import {FOLLOWS, getPubkeyTagValues} from "@welshman/util"
|
|||||||
import {ListReader} from "../ListReader.js"
|
import {ListReader} from "../ListReader.js"
|
||||||
import {ListBuilder} from "../ListBuilder.js"
|
import {ListBuilder} from "../ListBuilder.js"
|
||||||
|
|
||||||
// NIP-02 kind-3 follow list. Structurally a 'p'-tag list; follows are public in
|
// NIP-02 kind-3 follow list.
|
||||||
// practice, but the encryptable-list machinery is inherited unchanged (private
|
|
||||||
// tags simply go unused). Follow targets may also be non-pubkey tags (e.g. 't'
|
|
||||||
// hashtags), so `addFollow` accepts a full tag and `removeFollow` removes by value.
|
|
||||||
export class FollowList extends ListReader {
|
export class FollowList extends ListReader {
|
||||||
readonly kind = FOLLOWS
|
readonly kind = FOLLOWS
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ import {COMMUNITIES, getAddressTagValues} from "@welshman/util"
|
|||||||
import {ListReader} from "../ListReader.js"
|
import {ListReader} from "../ListReader.js"
|
||||||
import {ListBuilder} from "../ListBuilder.js"
|
import {ListBuilder} from "../ListBuilder.js"
|
||||||
|
|
||||||
// NIP-51 kind-10004 group (community) membership list. Entries are `a` tags
|
// NIP-51 kind-10004 group/community list.
|
||||||
// pointing at kind-34550 community definitions, merged across public tags and
|
|
||||||
// decrypted private content.
|
|
||||||
export class GroupList extends ListReader {
|
export class GroupList extends ListReader {
|
||||||
readonly kind = COMMUNITIES
|
readonly kind = COMMUNITIES
|
||||||
|
|
||||||
|
|||||||
@@ -4,21 +4,16 @@ import type {ISigner} from "@welshman/signer"
|
|||||||
import {EventReader} from "../EventReader.js"
|
import {EventReader} from "../EventReader.js"
|
||||||
import {EventBuilder} from "../EventBuilder.js"
|
import {EventBuilder} from "../EventBuilder.js"
|
||||||
|
|
||||||
// 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 = {
|
export type HandlerMeta = {
|
||||||
name?: string
|
name?: string
|
||||||
about?: string
|
about?: string
|
||||||
image?: string
|
picture?: string
|
||||||
website?: string
|
website?: string
|
||||||
lud16?: string
|
lud16?: string
|
||||||
nip05?: string
|
nip05?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NIP-89 kind-31990 handler information. Addressable (has a `d` tag); content is a
|
// NIP-89 kind-31990 handler information.
|
||||||
// JSON metadata blob like a profile, and the handled `kinds` are stored as `k`
|
|
||||||
// tags. `values` is the parsed metadata object.
|
|
||||||
export class Handler extends EventReader {
|
export class Handler extends EventReader {
|
||||||
readonly kind = HANDLER_INFORMATION
|
readonly kind = HANDLER_INFORMATION
|
||||||
readonly values: HandlerMeta = {}
|
readonly values: HandlerMeta = {}
|
||||||
@@ -32,15 +27,15 @@ export class Handler extends EventReader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
name() {
|
name() {
|
||||||
return this.values.name || (this.values as {display_name?: string}).display_name
|
return this.values.name
|
||||||
}
|
}
|
||||||
|
|
||||||
about() {
|
about() {
|
||||||
return this.values.about
|
return this.values.about
|
||||||
}
|
}
|
||||||
|
|
||||||
image() {
|
picture() {
|
||||||
return this.values.image || (this.values as {picture?: string}).picture
|
return this.values.picture
|
||||||
}
|
}
|
||||||
|
|
||||||
website() {
|
website() {
|
||||||
@@ -67,75 +62,63 @@ export class Handler extends EventReader {
|
|||||||
export class HandlerBuilder extends EventBuilder<Handler> {
|
export class HandlerBuilder extends EventBuilder<Handler> {
|
||||||
readonly kind = HANDLER_INFORMATION
|
readonly kind = HANDLER_INFORMATION
|
||||||
|
|
||||||
name?: string
|
values: HandlerMeta = {}
|
||||||
about?: string
|
kindTags: string[][] = []
|
||||||
image?: string
|
|
||||||
website?: string
|
|
||||||
lud16?: string
|
|
||||||
nip05?: string
|
|
||||||
kinds: number[] = []
|
|
||||||
|
|
||||||
constructor(readonly reader?: Handler) {
|
constructor(readonly reader?: Handler) {
|
||||||
super(reader)
|
super(reader)
|
||||||
|
|
||||||
this.name = reader?.name()
|
this.values = {...(reader?.values ?? {})}
|
||||||
this.about = reader?.about()
|
this.kindTags = this.consumeTags("k")
|
||||||
this.image = reader?.image()
|
|
||||||
this.website = reader?.website()
|
|
||||||
this.lud16 = reader?.lud16()
|
|
||||||
this.nip05 = reader?.nip05()
|
|
||||||
this.kinds = this.consumeTags("k").map(t => Number(t[1]))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setName(name: string) {
|
setName(name: string) {
|
||||||
this.name = name
|
this.values.name = name
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
setAbout(about: string) {
|
setAbout(about: string) {
|
||||||
this.about = about
|
this.values.about = about
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
setImage(image: string) {
|
setPicture(picture: string) {
|
||||||
this.image = image
|
this.values.picture = picture
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
setWebsite(website: string) {
|
setWebsite(website: string) {
|
||||||
this.website = website
|
this.values.website = website
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
setLud16(lud16: string) {
|
setLud16(lud16: string) {
|
||||||
this.lud16 = lud16
|
this.values.lud16 = lud16
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
setNip05(nip05: string) {
|
setNip05(nip05: string) {
|
||||||
this.nip05 = nip05
|
this.values.nip05 = nip05
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
setKinds(kinds: number[]) {
|
setKinds(kinds: number[]) {
|
||||||
this.kinds = kinds
|
this.kindTags = kinds.map(kind => ["k", String(kind)])
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
protected buildContent() {
|
protected buildContent() {
|
||||||
const {name, about, image, website, lud16, nip05} = this
|
return JSON.stringify(this.values)
|
||||||
|
|
||||||
return JSON.stringify({name, about, image, website, lud16, nip05})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected buildTags() {
|
protected buildTags() {
|
||||||
return this.kinds.map(kind => ["k", String(kind)])
|
return this.kindTags
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
import {first, last} from "@welshman/lib"
|
import {nthEq, last} from "@welshman/lib"
|
||||||
import {HANDLER_RECOMMENDATION, getAddressTags, getAddressTagValues} from "@welshman/util"
|
import {HANDLER_RECOMMENDATION, getAddressTags, getAddressTagValues} from "@welshman/util"
|
||||||
import {EventReader} from "../EventReader.js"
|
import {EventReader} from "../EventReader.js"
|
||||||
import {EventBuilder} from "../EventBuilder.js"
|
import {EventBuilder} from "../EventBuilder.js"
|
||||||
|
|
||||||
// NIP-89 kind-31989 handler recommendation. Addressable (the `d` tag holds the
|
// NIP-89 kind-31989 handler recommendation.
|
||||||
// recommended kind), tags-only with empty content. Each entry is a raw `a` tag
|
|
||||||
// pointing at a kind-31990 handler, optionally carrying a relay hint and a
|
|
||||||
// trailing platform marker (e.g. "web").
|
|
||||||
export class HandlerRecommendation extends EventReader {
|
export class HandlerRecommendation extends EventReader {
|
||||||
readonly kind = HANDLER_RECOMMENDATION
|
readonly kind = HANDLER_RECOMMENDATION
|
||||||
|
|
||||||
// Raw `a` tags: ["a", address, relay?, platform?].
|
|
||||||
addressTags() {
|
addressTags() {
|
||||||
return getAddressTags(this.event.tags)
|
return getAddressTags(this.event.tags)
|
||||||
}
|
}
|
||||||
@@ -19,8 +15,6 @@ export class HandlerRecommendation extends EventReader {
|
|||||||
return getAddressTagValues(this.event.tags)
|
return getAddressTagValues(this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer the recommendation marked as a "web" handler, otherwise fall back to
|
|
||||||
// the first recommendation.
|
|
||||||
handlerAddress() {
|
handlerAddress() {
|
||||||
const tags = this.addressTags()
|
const tags = this.addressTags()
|
||||||
const tag = tags.find(t => last(t) === "web") || tags[0]
|
const tag = tags.find(t => last(t) === "web") || tags[0]
|
||||||
@@ -36,15 +30,11 @@ export class HandlerRecommendation extends EventReader {
|
|||||||
export class HandlerRecommendationBuilder extends EventBuilder<HandlerRecommendation> {
|
export class HandlerRecommendationBuilder extends EventBuilder<HandlerRecommendation> {
|
||||||
readonly kind = HANDLER_RECOMMENDATION
|
readonly kind = HANDLER_RECOMMENDATION
|
||||||
|
|
||||||
identifier = ""
|
|
||||||
|
|
||||||
// Raw `a` tags: ["a", address, relay?, platform?].
|
|
||||||
addressTags: string[][] = []
|
addressTags: string[][] = []
|
||||||
|
|
||||||
constructor(readonly reader?: HandlerRecommendation) {
|
constructor(readonly reader?: HandlerRecommendation) {
|
||||||
super(reader)
|
super(reader)
|
||||||
|
|
||||||
this.identifier = first(this.consumeTags("d"))?.[1] || ""
|
|
||||||
this.addressTags = this.consumeTags("a")
|
this.addressTags = this.consumeTags("a")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,18 +47,12 @@ export class HandlerRecommendationBuilder extends EventBuilder<HandlerRecommenda
|
|||||||
}
|
}
|
||||||
|
|
||||||
removeRecommendation(address: string) {
|
removeRecommendation(address: string) {
|
||||||
this.addressTags = this.addressTags.filter(t => t[1] !== address)
|
this.addressTags = this.addressTags.filter(nthEq(1, address))
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
protected validate() {
|
|
||||||
if (!this.identifier) {
|
|
||||||
throw new Error("HandlerRecommendation requires a d identifier")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected buildTags() {
|
protected buildTags() {
|
||||||
return [["d", this.identifier], ...this.addressTags]
|
return [...this.addressTags]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,7 @@ import {MESSAGING_RELAYS, getTagValues, normalizeRelayUrl} from "@welshman/util"
|
|||||||
import {ListReader} from "../ListReader.js"
|
import {ListReader} from "../ListReader.js"
|
||||||
import {ListBuilder} from "../ListBuilder.js"
|
import {ListBuilder} from "../ListBuilder.js"
|
||||||
|
|
||||||
// NIP-17 kind-10050 messaging/inbox relays. Entries are marker-less
|
// NIP-17 kind-10050 messaging/inbox relays list.
|
||||||
// ['relay', url] tags (NOT NIP-65 'r' tags with read/write markers, and the
|
|
||||||
// RelayMode.Messaging marker is not used per-tag here). `urls()` drives where
|
|
||||||
// encrypted DM gift-wraps are sent and fetched, so it stays a flat, normalized
|
|
||||||
// set. Identical structure to BlockedRelayList/SearchRelayList.
|
|
||||||
export class MessagingRelayList extends ListReader {
|
export class MessagingRelayList extends ListReader {
|
||||||
readonly kind = MESSAGING_RELAYS
|
readonly kind = MESSAGING_RELAYS
|
||||||
|
|
||||||
@@ -23,15 +19,15 @@ export class MessagingRelayList extends ListReader {
|
|||||||
export class MessagingRelayListBuilder extends ListBuilder<MessagingRelayList> {
|
export class MessagingRelayListBuilder extends ListBuilder<MessagingRelayList> {
|
||||||
readonly kind = MESSAGING_RELAYS
|
readonly kind = MESSAGING_RELAYS
|
||||||
|
|
||||||
addRelay(url: string) {
|
addUrl(url: string) {
|
||||||
return this.addPublic(["relay", normalizeRelayUrl(url)])
|
return this.addPublic(["relay", normalizeRelayUrl(url)])
|
||||||
}
|
}
|
||||||
|
|
||||||
removeRelay(url: string) {
|
removeUrl(url: string) {
|
||||||
return this.drop(nthEq(1, normalizeRelayUrl(url)))
|
return this.drop(nthEq(1, normalizeRelayUrl(url)))
|
||||||
}
|
}
|
||||||
|
|
||||||
setRelays(urls: string[]) {
|
setUrls(urls: string[]) {
|
||||||
this.clear()
|
this.clear()
|
||||||
|
|
||||||
return this.addPublic(...urls.map(url => ["relay", normalizeRelayUrl(url)]))
|
return this.addPublic(...urls.map(url => ["relay", normalizeRelayUrl(url)]))
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import {MUTES, getPubkeyTagValues} from "@welshman/util"
|
|||||||
import {ListReader} from "../ListReader.js"
|
import {ListReader} from "../ListReader.js"
|
||||||
import {ListBuilder} from "../ListBuilder.js"
|
import {ListBuilder} from "../ListBuilder.js"
|
||||||
|
|
||||||
// NIP-51 kind-10000 mute list. Pubkeys can be muted publicly (tags) or privately
|
// NIP-51 kind-10000 mute list.
|
||||||
// (encrypted content); the reader treats both as one merged set.
|
|
||||||
export class MuteList extends ListReader {
|
export class MuteList extends ListReader {
|
||||||
readonly kind = MUTES
|
readonly kind = MUTES
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,7 @@ import {PINS, getEventTagValues, getAddressTagValues} from "@welshman/util"
|
|||||||
import {ListReader} from "../ListReader.js"
|
import {ListReader} from "../ListReader.js"
|
||||||
import {ListBuilder} from "../ListBuilder.js"
|
import {ListBuilder} from "../ListBuilder.js"
|
||||||
|
|
||||||
// NIP-51 kind-10001 pin list. Pinned items are heterogeneous tags (typically
|
// NIP-51 kind-10001 pin list.
|
||||||
// 'e' events and optionally 'a' addresses), so they are exposed through
|
|
||||||
// type-specific accessors rather than a single id-only set. Items can be pinned
|
|
||||||
// publicly (tags) or privately (encrypted content); the reader merges both.
|
|
||||||
export class PinList extends ListReader {
|
export class PinList extends ListReader {
|
||||||
readonly kind = PINS
|
readonly kind = PINS
|
||||||
|
|
||||||
@@ -26,12 +23,10 @@ export class PinList extends ListReader {
|
|||||||
export class PinListBuilder extends ListBuilder<PinList> {
|
export class PinListBuilder extends ListBuilder<PinList> {
|
||||||
readonly kind = PINS
|
readonly kind = PINS
|
||||||
|
|
||||||
// Pin a full tag (e.g. ["e", id, ...] or ["a", address, ...]) publicly.
|
|
||||||
pinPublicly(tag: string[]) {
|
pinPublicly(tag: string[]) {
|
||||||
return this.addPublic(tag)
|
return this.addPublic(tag)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pin a full tag (e.g. ["e", id, ...] or ["a", address, ...]) privately.
|
|
||||||
pinPrivately(tag: string[]) {
|
pinPrivately(tag: string[]) {
|
||||||
return this.addPrivate(tag)
|
return this.addPrivate(tag)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,13 +17,10 @@ export type PollResult = {
|
|||||||
voters: number
|
voters: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// NIP-88 kind-1068 poll. The poll title/question lives in `content` as plain
|
// NIP-88 kind-1068 poll.
|
||||||
// text (not JSON), options come from "option" tags, and the response tally is
|
|
||||||
// computed from sibling kind-1018 response events passed into `results`.
|
|
||||||
export class Poll extends EventReader {
|
export class Poll extends EventReader {
|
||||||
readonly kind = POLL
|
readonly kind = POLL
|
||||||
|
|
||||||
// The poll title/question is plain-text content.
|
|
||||||
title() {
|
title() {
|
||||||
return this.event.content || ""
|
return this.event.content || ""
|
||||||
}
|
}
|
||||||
@@ -31,7 +28,7 @@ export class Poll extends EventReader {
|
|||||||
options(): PollOption[] {
|
options(): PollOption[] {
|
||||||
return this.event.tags
|
return this.event.tags
|
||||||
.filter(t => t[0] === "option")
|
.filter(t => t[0] === "option")
|
||||||
.map(t => ({id: t[1], label: t[2] || t[1]}))
|
.map(([, id, label = id]) => ({id, label}))
|
||||||
}
|
}
|
||||||
|
|
||||||
pollType(): PollType {
|
pollType(): PollType {
|
||||||
@@ -47,16 +44,13 @@ export class Poll extends EventReader {
|
|||||||
isClosed() {
|
isClosed() {
|
||||||
const endsAt = this.endsAt()
|
const endsAt = this.endsAt()
|
||||||
|
|
||||||
return endsAt != null && endsAt <= now()
|
return endsAt !== undefined && endsAt <= now()
|
||||||
}
|
}
|
||||||
|
|
||||||
relays() {
|
urls() {
|
||||||
return getTagValues("relay", this.event.tags)
|
return getTagValues("relay", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tally the latest response per pubkey across the poll options. Each response
|
|
||||||
// is a kind-1018 event whose "response" tags name selected option ids;
|
|
||||||
// single-choice polls only honor the first selection.
|
|
||||||
results(responses: TrustedEvent[]): PollResult {
|
results(responses: TrustedEvent[]): PollResult {
|
||||||
const options = this.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]))
|
||||||
@@ -96,24 +90,19 @@ export class PollBuilder extends EventBuilder<Poll> {
|
|||||||
readonly kind = POLL
|
readonly kind = POLL
|
||||||
|
|
||||||
title = ""
|
title = ""
|
||||||
options: PollOption[] = []
|
optionTags: string[][] = []
|
||||||
pollType: PollType = "singlechoice"
|
pollTypeTag?: string[]
|
||||||
endsAt?: number
|
endsAtTag?: string[]
|
||||||
relays: string[] = []
|
urlTags: string[][] = []
|
||||||
|
|
||||||
constructor(readonly reader?: Poll) {
|
constructor(readonly reader?: Poll) {
|
||||||
super(reader)
|
super(reader)
|
||||||
|
|
||||||
// Consume the represented tags out of the carried-over extraTags so they
|
|
||||||
// round-trip through the structured fields below rather than being emitted
|
|
||||||
// twice (once from buildTags, once from the base's extraTags pass-through).
|
|
||||||
this.title = reader?.title() ?? ""
|
this.title = reader?.title() ?? ""
|
||||||
this.options = this.consumeTags("option").map(t => ({id: t[1], label: t[2] || t[1]}))
|
this.optionTags = this.consumeTags("option")
|
||||||
this.pollType = (first(this.consumeTags("polltype"))?.[1] as PollType) || "singlechoice"
|
this.pollTypeTag = first(this.consumeTags("polltype"))
|
||||||
this.endsAt = reader?.endsAt()
|
this.endsAtTag = first(this.consumeTags("endsAt"))
|
||||||
this.relays = this.consumeTags("relay").map(t => t[1])
|
this.urlTags = this.consumeTags("relay")
|
||||||
|
|
||||||
this.consumeTags("endsAt")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setTitle(title: string) {
|
setTitle(title: string) {
|
||||||
@@ -123,31 +112,33 @@ export class PollBuilder extends EventBuilder<Poll> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
addOption(label: string, id = randomId()) {
|
addOption(label: string, id = randomId()) {
|
||||||
this.options = [...this.options, {id, label}]
|
this.optionTags = [...this.optionTags, ["option", id, label]]
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
setPollType(pollType: PollType) {
|
setPollType(pollType: PollType) {
|
||||||
this.pollType = pollType
|
this.pollTypeTag = ["polltype", pollType]
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
setEndsAt(endsAt: number) {
|
setEndsAt(endsAt: number) {
|
||||||
this.endsAt = endsAt
|
this.endsAtTag = ["endsAt", String(endsAt)]
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
setRelays(relays: string[]) {
|
setUrls(urls: string[]) {
|
||||||
this.relays = relays
|
this.urlTags = urls.map(url => ["relay", url])
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
protected validate() {
|
protected validate() {
|
||||||
if (this.options.length === 0) {
|
super.validate()
|
||||||
|
|
||||||
|
if (this.optionTags.length === 0) {
|
||||||
throw new Error("Poll requires at least one option")
|
throw new Error("Poll requires at least one option")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -158,17 +149,13 @@ export class PollBuilder extends EventBuilder<Poll> {
|
|||||||
|
|
||||||
protected buildTags() {
|
protected buildTags() {
|
||||||
const tags: string[][] = [
|
const tags: string[][] = [
|
||||||
...this.options.map(o => ["option", o.id, o.label]),
|
...this.optionTags,
|
||||||
["polltype", this.pollType],
|
this.pollTypeTag ?? ["polltype", "singlechoice"],
|
||||||
]
|
]
|
||||||
|
|
||||||
if (this.endsAt != null) {
|
if (this.endsAtTag) tags.push(this.endsAtTag)
|
||||||
tags.push(["endsAt", String(this.endsAt)])
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const relay of this.relays) {
|
tags.push(...this.urlTags)
|
||||||
tags.push(["relay", relay])
|
|
||||||
}
|
|
||||||
|
|
||||||
return tags
|
return tags
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import {uniq, first} from "@welshman/lib"
|
import {uniq, uniqBy, first} from "@welshman/lib"
|
||||||
import {POLL_RESPONSE, getTagValue, getTagValues} from "@welshman/util"
|
import {POLL_RESPONSE, getTagValue, getTagValues} from "@welshman/util"
|
||||||
import {EventReader} from "../EventReader.js"
|
import {EventReader} from "../EventReader.js"
|
||||||
import {EventBuilder} from "../EventBuilder.js"
|
import {EventBuilder} from "../EventBuilder.js"
|
||||||
|
|
||||||
// NIP-88 kind-1018 poll vote. Empty content; the target poll is referenced via
|
// NIP-88 kind-1018 poll response.
|
||||||
// an "e" tag and each chosen option id lives in its own "response" tag. Tags-only
|
|
||||||
// content, so it extends EventReader/EventBuilder directly.
|
|
||||||
export class PollResponse extends EventReader {
|
export class PollResponse extends EventReader {
|
||||||
readonly kind = POLL_RESPONSE
|
readonly kind = POLL_RESPONSE
|
||||||
|
|
||||||
@@ -25,38 +23,41 @@ export class PollResponse extends EventReader {
|
|||||||
export class PollResponseBuilder extends EventBuilder<PollResponse> {
|
export class PollResponseBuilder extends EventBuilder<PollResponse> {
|
||||||
readonly kind = POLL_RESPONSE
|
readonly kind = POLL_RESPONSE
|
||||||
|
|
||||||
pollId = ""
|
pollIdTag?: string[]
|
||||||
selections: string[] = []
|
selectionTags: string[][] = []
|
||||||
|
|
||||||
constructor(readonly reader?: PollResponse) {
|
constructor(readonly reader?: PollResponse) {
|
||||||
super(reader)
|
super(reader)
|
||||||
|
|
||||||
// Consume the represented tags out of the carried-over extraTags so they
|
this.pollIdTag = first(this.consumeTags("e"))
|
||||||
// round-trip through the structured fields below rather than being emitted
|
this.selectionTags = uniqBy(t => t[1], this.consumeTags("response"))
|
||||||
// twice (once from buildTags, once from the base's extraTags pass-through).
|
|
||||||
this.pollId = first(this.consumeTags("e"))?.[1] ?? ""
|
|
||||||
this.selections = uniq(this.consumeTags("response").map(t => t[1]))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setPollId(pollId: string) {
|
setPollId(pollId: string) {
|
||||||
this.pollId = pollId
|
this.pollIdTag = ["e", pollId]
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
addSelection(id: string) {
|
addSelection(id: string) {
|
||||||
this.selections = uniq([...this.selections, id])
|
this.selectionTags = uniqBy(t => t[1], [...this.selectionTags, ["response", id]])
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
protected validate() {
|
protected validate() {
|
||||||
if (!this.pollId) {
|
super.validate()
|
||||||
|
|
||||||
|
if (!this.pollIdTag) {
|
||||||
throw new Error("PollResponse requires a pollId")
|
throw new Error("PollResponse requires a pollId")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected buildTags() {
|
protected buildTags() {
|
||||||
return [["e", this.pollId], ...this.selections.map(id => ["response", id])]
|
const tags: string[][] = []
|
||||||
|
|
||||||
|
if (this.pollIdTag) tags.push(this.pollIdTag)
|
||||||
|
|
||||||
|
return [...tags, ...this.selectionTags]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,7 @@ export const displayPubkey = (pubkey: string) => {
|
|||||||
return d.slice(0, 8) + "…" + d.slice(-5)
|
return d.slice(0, 8) + "…" + d.slice(-5)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read side for a NIP-01 kind-0 profile. The metadata lives in the JSON content,
|
// NIP-01 kind-0 profile metadata.
|
||||||
// parsed once into `values` (with `lnurl` derived from lud06/lud16). Accessors
|
|
||||||
// read `this.values`; there are no represented tags.
|
|
||||||
export class Profile extends EventReader {
|
export class Profile extends EventReader {
|
||||||
readonly kind = PROFILE
|
readonly kind = PROFILE
|
||||||
readonly values: Record<string, any> = {}
|
readonly values: Record<string, any> = {}
|
||||||
@@ -40,7 +38,11 @@ export class Profile extends EventReader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
name(): Maybe<string> {
|
name(): Maybe<string> {
|
||||||
return this.values.name || this.values.display_name
|
return this.values.name
|
||||||
|
}
|
||||||
|
|
||||||
|
displayName(): Maybe<string> {
|
||||||
|
return this.values.display_name
|
||||||
}
|
}
|
||||||
|
|
||||||
nip05(): Maybe<string> {
|
nip05(): Maybe<string> {
|
||||||
@@ -72,6 +74,10 @@ export class Profile extends EventReader {
|
|||||||
|
|
||||||
if (name) return ellipsize(name, 60).trim()
|
if (name) return ellipsize(name, 60).trim()
|
||||||
|
|
||||||
|
const displayName= this.displayName()
|
||||||
|
|
||||||
|
if (displayName) return ellipsize(displayName, 60).trim()
|
||||||
|
|
||||||
return displayPubkey(this.event.pubkey).trim() || fallback.trim()
|
return displayPubkey(this.event.pubkey).trim() || fallback.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,37 +95,49 @@ export class ProfileBuilder extends EventBuilder<Profile> {
|
|||||||
this.values = {...(reader?.values ?? {})}
|
this.values = {...(reader?.values ?? {})}
|
||||||
}
|
}
|
||||||
|
|
||||||
name(name: string) {
|
update(values: Record<string, any>) {
|
||||||
|
Object.assign(this.values, values)
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setName(name: string) {
|
||||||
this.values.name = name
|
this.values.name = name
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
nip05(nip05: string) {
|
setDisplayName(displayName: string) {
|
||||||
|
this.values.displayName = displayName
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setNip05(nip05: string) {
|
||||||
this.values.nip05 = nip05
|
this.values.nip05 = nip05
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
about(about: string) {
|
setAbout(about: string) {
|
||||||
this.values.about = about
|
this.values.about = about
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
banner(banner: string) {
|
setBanner(banner: string) {
|
||||||
this.values.banner = banner
|
this.values.banner = banner
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
picture(picture: string) {
|
setPicture(picture: string) {
|
||||||
this.values.picture = picture
|
this.values.picture = picture
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
website(website: string) {
|
setWebsite(website: string) {
|
||||||
this.values.website = website
|
this.values.website = website
|
||||||
|
|
||||||
return this
|
return this
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import {uniq, nth, uniqBy} from "@welshman/lib"
|
||||||
|
import {RELAY_ADD_MEMBER, getPubkeyTagValues} from "@welshman/util"
|
||||||
|
import {EventReader} from "../EventReader.js"
|
||||||
|
import {EventBuilder} from "../EventBuilder.js"
|
||||||
|
|
||||||
|
// Flotilla relay/space add-member op (kind 8000).
|
||||||
|
export class RelayAddMember extends EventReader {
|
||||||
|
readonly kind = RELAY_ADD_MEMBER
|
||||||
|
|
||||||
|
pubkeys() {
|
||||||
|
return uniq(getPubkeyTagValues(this.event.tags))
|
||||||
|
}
|
||||||
|
|
||||||
|
builder() {
|
||||||
|
return new RelayAddMemberBuilder(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RelayAddMemberBuilder extends EventBuilder<RelayAddMember> {
|
||||||
|
readonly kind = RELAY_ADD_MEMBER
|
||||||
|
|
||||||
|
pubkeyTags: string[][] = []
|
||||||
|
|
||||||
|
constructor(readonly reader?: RelayAddMember) {
|
||||||
|
super(reader)
|
||||||
|
|
||||||
|
this.pubkeyTags = uniqBy(nth(1), this.consumeTags("p"))
|
||||||
|
}
|
||||||
|
|
||||||
|
addPubkey(pubkey: string) {
|
||||||
|
this.pubkeyTags = uniqBy(nth(1), [...this.pubkeyTags, ["p", pubkey]])
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildTags() {
|
||||||
|
return this.pubkeyTags
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,7 @@ import {RELAY_INVITE, getTagValue} from "@welshman/util"
|
|||||||
import {EventReader} from "../EventReader.js"
|
import {EventReader} from "../EventReader.js"
|
||||||
import {EventBuilder} from "../EventBuilder.js"
|
import {EventBuilder} from "../EventBuilder.js"
|
||||||
|
|
||||||
// NIP-29 kind-28935 ephemeral relay invite event. Its "claim" tag carries the
|
// NIP-29 kind-28935 relay invite.
|
||||||
// invite code, which flotilla turns into a /join?r=&c= link. Flotilla only reads
|
|
||||||
// this event (see app/relays.ts requestRelayClaim), so `claim` is the sole field.
|
|
||||||
// Tags-only content, so it extends EventReader/EventBuilder directly.
|
|
||||||
export class RelayInvite extends EventReader {
|
export class RelayInvite extends EventReader {
|
||||||
readonly kind = RELAY_INVITE
|
readonly kind = RELAY_INVITE
|
||||||
|
|
||||||
@@ -22,18 +19,16 @@ export class RelayInvite extends EventReader {
|
|||||||
export class RelayInviteBuilder extends EventBuilder<RelayInvite> {
|
export class RelayInviteBuilder extends EventBuilder<RelayInvite> {
|
||||||
readonly kind = RELAY_INVITE
|
readonly kind = RELAY_INVITE
|
||||||
|
|
||||||
claim?: string
|
claimTag?: string[]
|
||||||
|
|
||||||
constructor(readonly reader?: RelayInvite) {
|
constructor(readonly reader?: RelayInvite) {
|
||||||
super(reader)
|
super(reader)
|
||||||
|
|
||||||
const claim = first(this.consumeTags("claim"))
|
this.claimTag = first(this.consumeTags("claim"))
|
||||||
|
|
||||||
this.claim = claim?.[1]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setClaim(claim: string) {
|
setClaim(claim: string) {
|
||||||
this.claim = claim
|
this.claimTag = ["claim", claim]
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
@@ -41,7 +36,7 @@ export class RelayInviteBuilder extends EventBuilder<RelayInvite> {
|
|||||||
protected buildTags() {
|
protected buildTags() {
|
||||||
const tags: string[][] = []
|
const tags: string[][] = []
|
||||||
|
|
||||||
if (this.claim) tags.push(["claim", this.claim])
|
if (this.claimTag) tags.push(this.claimTag)
|
||||||
|
|
||||||
return tags
|
return tags
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ import type {ISigner} from "@welshman/signer"
|
|||||||
import {EventReader} from "../EventReader.js"
|
import {EventReader} from "../EventReader.js"
|
||||||
import {EventBuilder} from "../EventBuilder.js"
|
import {EventBuilder} from "../EventBuilder.js"
|
||||||
|
|
||||||
// Ephemeral kind-28934 relay/space join request. Both written (the join flow)
|
// Ephemeral kind-28934 relay/space join request.
|
||||||
// and read (membership status): it carries an optional invite "claim" tag and a
|
|
||||||
// free-text reason in the event content, driving the space membership state
|
|
||||||
// machine (RELAY_JOIN -> Pending/Granted). The content is the plain free-text
|
|
||||||
// reason.
|
|
||||||
export class RelayJoin extends EventReader {
|
export class RelayJoin extends EventReader {
|
||||||
readonly kind = RELAY_JOIN
|
readonly kind = RELAY_JOIN
|
||||||
|
|
||||||
@@ -28,20 +24,18 @@ export class RelayJoin extends EventReader {
|
|||||||
export class RelayJoinBuilder extends EventBuilder<RelayJoin> {
|
export class RelayJoinBuilder extends EventBuilder<RelayJoin> {
|
||||||
readonly kind = RELAY_JOIN
|
readonly kind = RELAY_JOIN
|
||||||
|
|
||||||
claim?: string
|
claimTag?: string[]
|
||||||
reason?: string
|
reason?: string
|
||||||
|
|
||||||
constructor(readonly reader?: RelayJoin) {
|
constructor(readonly reader?: RelayJoin) {
|
||||||
super(reader)
|
super(reader)
|
||||||
|
|
||||||
const claim = first(this.consumeTags("claim"))
|
this.claimTag = first(this.consumeTags("claim"))
|
||||||
|
|
||||||
this.claim = claim?.[1]
|
|
||||||
this.reason = reader?.event.content || undefined
|
this.reason = reader?.event.content || undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
setClaim(claim: string) {
|
setClaim(claim: string) {
|
||||||
this.claim = claim
|
this.claimTag = ["claim", claim]
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
@@ -55,7 +49,7 @@ export class RelayJoinBuilder extends EventBuilder<RelayJoin> {
|
|||||||
protected buildTags() {
|
protected buildTags() {
|
||||||
const tags: string[][] = []
|
const tags: string[][] = []
|
||||||
|
|
||||||
if (this.claim) tags.push(["claim", this.claim])
|
if (this.claimTag) tags.push(this.claimTag)
|
||||||
|
|
||||||
return tags
|
return tags
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ import {RELAY_LEAVE} from "@welshman/util"
|
|||||||
import {EventReader} from "../EventReader.js"
|
import {EventReader} from "../EventReader.js"
|
||||||
import {EventBuilder} from "../EventBuilder.js"
|
import {EventBuilder} from "../EventBuilder.js"
|
||||||
|
|
||||||
// Ephemeral kind-28936 relay/space leave marker, the counterpart to RelayJoin.
|
// Ephemeral kind-28936 relay/space leave marker.
|
||||||
// Carries no tags and no content; flotilla both emits it (the leave flow) and
|
|
||||||
// consumes it to reset the space membership state machine (RELAY_LEAVE ->
|
|
||||||
// Initial). Tags-only (in fact tag-free) content.
|
|
||||||
export class RelayLeave extends EventReader {
|
export class RelayLeave extends EventReader {
|
||||||
readonly kind = RELAY_LEAVE
|
readonly kind = RELAY_LEAVE
|
||||||
|
|
||||||
|
|||||||
@@ -3,19 +3,14 @@ import {RELAYS, RelayMode, getRelayTags, getRelayTagValues, normalizeRelayUrl} f
|
|||||||
import {ListReader} from "../ListReader.js"
|
import {ListReader} from "../ListReader.js"
|
||||||
import {ListBuilder} from "../ListBuilder.js"
|
import {ListBuilder} from "../ListBuilder.js"
|
||||||
|
|
||||||
// NIP-65 kind-10002 relay list (the outbox-model routing substrate). Entries are
|
// NIP-65 kind-10002 relay list.
|
||||||
// `["r", url, mode?]` tags where `mode` is RelayMode.Read or RelayMode.Write; a
|
|
||||||
// missing marker means the relay is used for both read and write. NIP-65 entries
|
|
||||||
// are public in practice, so mutations target the public tag set.
|
|
||||||
export class RelayList extends ListReader {
|
export class RelayList extends ListReader {
|
||||||
readonly kind = RELAYS
|
readonly kind = RELAYS
|
||||||
|
|
||||||
// All relay urls, deduped by normalized url.
|
|
||||||
urls() {
|
urls() {
|
||||||
return uniqBy(normalizeRelayUrl, getRelayTagValues(this.tags()))
|
return uniqBy(normalizeRelayUrl, getRelayTagValues(this.tags()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Relays usable for reading: includes modeless (both) entries.
|
|
||||||
readUrls() {
|
readUrls() {
|
||||||
return uniqBy(
|
return uniqBy(
|
||||||
normalizeRelayUrl,
|
normalizeRelayUrl,
|
||||||
@@ -25,7 +20,6 @@ export class RelayList extends ListReader {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Relays usable for writing: includes modeless (both) entries.
|
|
||||||
writeUrls() {
|
writeUrls() {
|
||||||
return uniqBy(
|
return uniqBy(
|
||||||
normalizeRelayUrl,
|
normalizeRelayUrl,
|
||||||
@@ -43,7 +37,6 @@ export class RelayList extends ListReader {
|
|||||||
export class RelayListBuilder extends ListBuilder<RelayList> {
|
export class RelayListBuilder extends ListBuilder<RelayList> {
|
||||||
readonly kind = RELAYS
|
readonly kind = RELAYS
|
||||||
|
|
||||||
// Relays usable for reading: includes modeless (both) entries.
|
|
||||||
readUrls() {
|
readUrls() {
|
||||||
return uniqBy(
|
return uniqBy(
|
||||||
normalizeRelayUrl,
|
normalizeRelayUrl,
|
||||||
@@ -53,7 +46,6 @@ export class RelayListBuilder extends ListBuilder<RelayList> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Relays usable for writing: includes modeless (both) entries.
|
|
||||||
writeUrls() {
|
writeUrls() {
|
||||||
return uniqBy(
|
return uniqBy(
|
||||||
normalizeRelayUrl,
|
normalizeRelayUrl,
|
||||||
@@ -63,16 +55,12 @@ export class RelayListBuilder extends ListBuilder<RelayList> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert a relay for a given mode. If an existing entry already covered the
|
addUrl(url: string, mode: RelayMode) {
|
||||||
// complementary mode (or was modeless), collapse to a modeless ["r", url] tag;
|
|
||||||
// otherwise store ["r", url, mode].
|
|
||||||
addRelay(url: string, mode: RelayMode) {
|
|
||||||
const normalized = normalizeRelayUrl(url)
|
const normalized = normalizeRelayUrl(url)
|
||||||
const existing = getRelayTags(this.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).
|
|
||||||
const priorModes = new Set<RelayMode | undefined>(
|
const priorModes = new Set<RelayMode | undefined>(
|
||||||
existing.map(t => t[2] as RelayMode | undefined),
|
existing.map(t => t[2] as RelayMode | undefined),
|
||||||
)
|
)
|
||||||
@@ -89,10 +77,7 @@ export class RelayListBuilder extends ListBuilder<RelayList> {
|
|||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove a relay for a given mode while preserving the alternate. A
|
removeUrl(url: string, mode: RelayMode) {
|
||||||
// modeless/both entry is downgraded to the alternate mode; an entry that only
|
|
||||||
// covered `mode` is fully removed.
|
|
||||||
removeRelay(url: string, mode: RelayMode) {
|
|
||||||
const normalized = normalizeRelayUrl(url)
|
const normalized = normalizeRelayUrl(url)
|
||||||
const existing = getRelayTags(this.publicTags).filter(
|
const existing = getRelayTags(this.publicTags).filter(
|
||||||
t => normalizeRelayUrl(t[1]) === normalized,
|
t => normalizeRelayUrl(t[1]) === normalized,
|
||||||
@@ -100,7 +85,6 @@ export class RelayListBuilder extends ListBuilder<RelayList> {
|
|||||||
|
|
||||||
const alt = mode === RelayMode.Read ? RelayMode.Write : RelayMode.Read
|
const alt = mode === RelayMode.Read ? RelayMode.Write : RelayMode.Read
|
||||||
|
|
||||||
// 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.publicTags = this.publicTags.filter(
|
this.publicTags = this.publicTags.filter(
|
||||||
@@ -114,22 +98,15 @@ export class RelayListBuilder extends ListBuilder<RelayList> {
|
|||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace the read set while PRESERVING every relay's write capability: a
|
setReadUrls(urls: string[]) {
|
||||||
// relay that was write-capable (write-marked or modeless) stays writable, and
|
return this.setUrlsForModes(urls, this.writeUrls())
|
||||||
// collapses back to a modeless ["r", url] tag if it's also in the new read set.
|
|
||||||
setReadRelays(urls: string[]) {
|
|
||||||
return this.setRelaysForModes(urls, this.writeUrls())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace the write set while PRESERVING every relay's read capability.
|
setWriteUrls(urls: string[]) {
|
||||||
setWriteRelays(urls: string[]) {
|
return this.setUrlsForModes(this.readUrls(), urls)
|
||||||
return this.setRelaysForModes(this.readUrls(), urls)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rebuild the public 'r' tag set from explicit read/write membership. A relay
|
private setUrlsForModes(readUrls: string[], writeUrls: string[]) {
|
||||||
// in both sets is emitted modeless (both); otherwise it carries its single
|
|
||||||
// mode marker. Non-'r' public tags are preserved.
|
|
||||||
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.publicTags.filter(t => t[0] !== "r")
|
const otherTags = this.publicTags.filter(t => t[0] !== "r")
|
||||||
@@ -146,8 +123,7 @@ export class RelayListBuilder extends ListBuilder<RelayList> {
|
|||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace the entire public tag set.
|
setTags(tags: string[][]) {
|
||||||
setRelays(tags: string[][]) {
|
|
||||||
this.publicTags = tags
|
this.publicTags = tags
|
||||||
|
|
||||||
return this
|
return this
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import {uniq} from "@welshman/lib"
|
import {uniq, nth, nthNe, uniqBy} from "@welshman/lib"
|
||||||
import {RELAY_MEMBERS, getPubkeyTagValues} from "@welshman/util"
|
import {RELAY_MEMBERS, getPubkeyTagValues} from "@welshman/util"
|
||||||
import {EventReader} from "../EventReader.js"
|
import {EventReader} from "../EventReader.js"
|
||||||
import {EventBuilder} from "../EventBuilder.js"
|
import {EventBuilder} from "../EventBuilder.js"
|
||||||
|
|
||||||
// Flotilla relay-wide (space) member-list snapshot, replaceable kind 13534.
|
// Flotilla kind-13534 relay/space member-list snapshot.
|
||||||
// Members are stored as "p" tags. Not addressable (no "d" tag); tags-only
|
|
||||||
// content, so it extends EventReader/EventBuilder directly.
|
|
||||||
export class RelayMembers extends EventReader {
|
export class RelayMembers extends EventReader {
|
||||||
readonly kind = RELAY_MEMBERS
|
readonly kind = RELAY_MEMBERS
|
||||||
|
|
||||||
@@ -25,27 +23,27 @@ export class RelayMembers extends EventReader {
|
|||||||
export class RelayMembersBuilder extends EventBuilder<RelayMembers> {
|
export class RelayMembersBuilder extends EventBuilder<RelayMembers> {
|
||||||
readonly kind = RELAY_MEMBERS
|
readonly kind = RELAY_MEMBERS
|
||||||
|
|
||||||
pubkeys: string[] = []
|
pubkeyTags: string[][] = []
|
||||||
|
|
||||||
constructor(readonly reader?: RelayMembers) {
|
constructor(readonly reader?: RelayMembers) {
|
||||||
super(reader)
|
super(reader)
|
||||||
|
|
||||||
this.pubkeys = uniq(this.consumeTags("p").map(t => t[1]))
|
this.pubkeyTags = uniqBy(nth(1), this.consumeTags("p"))
|
||||||
}
|
}
|
||||||
|
|
||||||
addPubkey(pubkey: string) {
|
addPubkey(pubkey: string) {
|
||||||
this.pubkeys = uniq([...this.pubkeys, pubkey])
|
this.pubkeyTags = uniqBy(nth(1), [...this.pubkeyTags, ["p", pubkey]])
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
removePubkey(pubkey: string) {
|
removePubkey(pubkey: string) {
|
||||||
this.pubkeys = this.pubkeys.filter(pk => pk !== pubkey)
|
this.pubkeyTags = this.pubkeyTags.filter(nthNe(1, pubkey))
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
protected buildTags() {
|
protected buildTags() {
|
||||||
return this.pubkeys.map(pk => ["p", pk])
|
return this.pubkeyTags
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
import {uniq} from "@welshman/lib"
|
|
||||||
import {RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, getPubkeyTagValues} from "@welshman/util"
|
|
||||||
import {EventReader} from "../EventReader.js"
|
|
||||||
import {EventBuilder} from "../EventBuilder.js"
|
|
||||||
|
|
||||||
// Relay/space-level moderation op for adding (kind 8000) or removing (kind 8001)
|
|
||||||
// members. Regular (non-addressable) events carrying the affected pubkeys in "p"
|
|
||||||
// tags. Unlike RoomMembershipOp these are relay-scoped, not room-scoped, so there
|
|
||||||
// is no group ("h") tag — just the "p" tags. Add and remove share this shape;
|
|
||||||
// each is its own concrete reader/builder fixing the kind via a static field.
|
|
||||||
//
|
|
||||||
// Flotilla's deriveUserSpaceMembershipStatus replays this history (RelayAddMember
|
|
||||||
// => isMember true, RelayRemoveMember => isMember false) when no RELAY_MEMBERS
|
|
||||||
// snapshot is available.
|
|
||||||
export abstract class RelayMembershipOp extends EventReader {
|
|
||||||
// The affected pubkeys, deduped.
|
|
||||||
pubkeys() {
|
|
||||||
return uniq(getPubkeyTagValues(this.event.tags))
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract builder(): RelayMembershipOpBuilder
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared write side: collect pubkeys, emit them as "p" tags.
|
|
||||||
export abstract class RelayMembershipOpBuilder extends EventBuilder<RelayMembershipOp> {
|
|
||||||
pubkeys: string[] = []
|
|
||||||
|
|
||||||
constructor(readonly reader?: RelayMembershipOp) {
|
|
||||||
super(reader)
|
|
||||||
|
|
||||||
this.pubkeys = uniq(this.consumeTags("p").map(t => t[1]))
|
|
||||||
}
|
|
||||||
|
|
||||||
addPubkey(pubkey: string) {
|
|
||||||
this.pubkeys = uniq([...this.pubkeys, pubkey])
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
protected buildTags() {
|
|
||||||
return this.pubkeys.map(pk => ["p", pk])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RelayAddMember extends RelayMembershipOp {
|
|
||||||
readonly kind = RELAY_ADD_MEMBER
|
|
||||||
|
|
||||||
builder() {
|
|
||||||
return new RelayAddMemberBuilder(this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RelayAddMemberBuilder extends RelayMembershipOpBuilder {
|
|
||||||
readonly kind = RELAY_ADD_MEMBER
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RelayRemoveMember extends RelayMembershipOp {
|
|
||||||
readonly kind = RELAY_REMOVE_MEMBER
|
|
||||||
|
|
||||||
builder() {
|
|
||||||
return new RelayRemoveMemberBuilder(this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RelayRemoveMemberBuilder extends RelayMembershipOpBuilder {
|
|
||||||
readonly kind = RELAY_REMOVE_MEMBER
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import {uniq, nth, uniqBy} from "@welshman/lib"
|
||||||
|
import {RELAY_REMOVE_MEMBER, getPubkeyTagValues} from "@welshman/util"
|
||||||
|
import {EventReader} from "../EventReader.js"
|
||||||
|
import {EventBuilder} from "../EventBuilder.js"
|
||||||
|
|
||||||
|
// Flotilla relay/space remove-member op (kind 8001).
|
||||||
|
export class RelayRemoveMember extends EventReader {
|
||||||
|
readonly kind = RELAY_REMOVE_MEMBER
|
||||||
|
|
||||||
|
pubkeys() {
|
||||||
|
return uniq(getPubkeyTagValues(this.event.tags))
|
||||||
|
}
|
||||||
|
|
||||||
|
builder() {
|
||||||
|
return new RelayRemoveMemberBuilder(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RelayRemoveMemberBuilder extends EventBuilder<RelayRemoveMember> {
|
||||||
|
readonly kind = RELAY_REMOVE_MEMBER
|
||||||
|
|
||||||
|
pubkeyTags: string[][] = []
|
||||||
|
|
||||||
|
constructor(readonly reader?: RelayRemoveMember) {
|
||||||
|
super(reader)
|
||||||
|
|
||||||
|
this.pubkeyTags = uniqBy(nth(1), this.consumeTags("p"))
|
||||||
|
}
|
||||||
|
|
||||||
|
addPubkey(pubkey: string) {
|
||||||
|
this.pubkeyTags = uniqBy(nth(1), [...this.pubkeyTags, ["p", pubkey]])
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildTags() {
|
||||||
|
return this.pubkeyTags
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,11 @@
|
|||||||
import {randomId, uniqBy} from "@welshman/lib"
|
import {uniqBy} from "@welshman/lib"
|
||||||
import {NAMED_RELAYS, getTagValue, getTagValues, normalizeRelayUrl} from "@welshman/util"
|
import {NAMED_RELAYS, getTagValue, getTagValues, normalizeRelayUrl} from "@welshman/util"
|
||||||
import {ListReader} from "../ListReader.js"
|
import {ListReader} from "../ListReader.js"
|
||||||
import {ListBuilder} from "../ListBuilder.js"
|
import {ListBuilder} from "../ListBuilder.js"
|
||||||
|
|
||||||
// Metadata tag keys re-emitted from the builder's dedicated fields; the d tag is
|
const META_TAG_KEYS = ["title", "description", "image"]
|
||||||
// the addressable identifier and is handled separately.
|
|
||||||
const META_TAG_KEYS = ["d", "title", "description", "image"]
|
|
||||||
|
|
||||||
// NIP-51 kind-30002 relay set: an addressable, named collection of relays
|
// NIP-51 kind-30002 relay set.
|
||||||
// identified by its `d` tag. Entries are marker-less ['relay', url] tags (like
|
|
||||||
// the other NIP-51 relay lists, NOT NIP-65 'r' tags with read/write markers).
|
|
||||||
// It also carries optional set metadata (title/description/image) used to label
|
|
||||||
// the set in UIs.
|
|
||||||
export class RelaySet extends ListReader {
|
export class RelaySet extends ListReader {
|
||||||
readonly kind = NAMED_RELAYS
|
readonly kind = NAMED_RELAYS
|
||||||
|
|
||||||
@@ -39,7 +33,6 @@ export class RelaySet extends ListReader {
|
|||||||
export class RelaySetBuilder extends ListBuilder<RelaySet> {
|
export class RelaySetBuilder extends ListBuilder<RelaySet> {
|
||||||
readonly kind = NAMED_RELAYS
|
readonly kind = NAMED_RELAYS
|
||||||
|
|
||||||
identifier = randomId()
|
|
||||||
title?: string
|
title?: string
|
||||||
description?: string
|
description?: string
|
||||||
image?: string
|
image?: string
|
||||||
@@ -47,22 +40,12 @@ export class RelaySetBuilder extends ListBuilder<RelaySet> {
|
|||||||
constructor(readonly reader?: RelaySet) {
|
constructor(readonly reader?: RelaySet) {
|
||||||
super(reader)
|
super(reader)
|
||||||
|
|
||||||
// The list base splices every non-behavior tag into publicTags. Pull the
|
|
||||||
// d/title/description/image metadata into dedicated fields and drop them from
|
|
||||||
// publicTags so the marker-less relay entries are all that remain there.
|
|
||||||
this.identifier = getTagValue("d", this.publicTags) || randomId()
|
|
||||||
this.title = getTagValue("title", this.publicTags)
|
this.title = getTagValue("title", this.publicTags)
|
||||||
this.description = getTagValue("description", this.publicTags)
|
this.description = getTagValue("description", this.publicTags)
|
||||||
this.image = getTagValue("image", this.publicTags)
|
this.image = getTagValue("image", this.publicTags)
|
||||||
this.publicTags = this.publicTags.filter(t => !META_TAG_KEYS.includes(t[0]))
|
this.publicTags = this.publicTags.filter(t => !META_TAG_KEYS.includes(t[0]))
|
||||||
}
|
}
|
||||||
|
|
||||||
setIdentifier(identifier: string) {
|
|
||||||
this.identifier = identifier
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
setTitle(title: string) {
|
setTitle(title: string) {
|
||||||
this.title = title
|
this.title = title
|
||||||
|
|
||||||
@@ -81,36 +64,27 @@ export class RelaySetBuilder extends ListBuilder<RelaySet> {
|
|||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
addRelay(url: string) {
|
addUrl(url: string) {
|
||||||
return this.addPublic(["relay", normalizeRelayUrl(url)])
|
return this.addPublic(["relay", normalizeRelayUrl(url)])
|
||||||
}
|
}
|
||||||
|
|
||||||
removeRelay(url: string) {
|
removeUrl(url: string) {
|
||||||
return this.drop(t => t[1] === url)
|
return this.drop(t => t[1] === url)
|
||||||
}
|
}
|
||||||
|
|
||||||
setRelays(urls: string[]) {
|
setUrls(urls: string[]) {
|
||||||
// Replace only the relay entries; preserve the set's metadata fields.
|
|
||||||
this.dropPublic(t => t[0] === "relay")
|
this.dropPublic(t => t[0] === "relay")
|
||||||
|
|
||||||
return this.addPublic(...urls.map(url => ["relay", normalizeRelayUrl(url)]))
|
return this.addPublic(...urls.map(url => ["relay", normalizeRelayUrl(url)]))
|
||||||
}
|
}
|
||||||
|
|
||||||
protected validate() {
|
|
||||||
if (!this.identifier) {
|
|
||||||
throw new Error("RelaySet requires a d identifier")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected buildTags() {
|
protected buildTags() {
|
||||||
const tags: string[][] = [["d", this.identifier]]
|
const tags: string[][] = []
|
||||||
|
|
||||||
if (this.title) tags.push(["title", this.title])
|
if (this.title) tags.push(["title", this.title])
|
||||||
if (this.description) tags.push(["description", this.description])
|
if (this.description) tags.push(["description", this.description])
|
||||||
if (this.image) tags.push(["image", this.image])
|
if (this.image) tags.push(["image", this.image])
|
||||||
|
|
||||||
// Append the public list entries (relay tags); the base re-encrypts the
|
|
||||||
// private tags into content separately.
|
|
||||||
return [...tags, ...this.publicTags]
|
return [...tags, ...this.publicTags]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,36 +3,22 @@ import {REPORT, getTag, getTagValue} from "@welshman/util"
|
|||||||
import {EventReader} from "../EventReader.js"
|
import {EventReader} from "../EventReader.js"
|
||||||
import {EventBuilder} from "../EventBuilder.js"
|
import {EventBuilder} from "../EventBuilder.js"
|
||||||
|
|
||||||
// NIP-56 kind-1984 report, feeding flotilla's admin action-items / moderation
|
// NIP-56 kind-1984 report.
|
||||||
// review queue (see app/actionItems.ts `deriveSpaceActionItems`). The reported
|
|
||||||
// author is named in the "p" tag and the reported event in the "e" tag, with the
|
|
||||||
// report reason carried as the 3rd element of the "e" tag (NOT a separate tag).
|
|
||||||
// Flotilla destructures this by hand in ReactionSummary.svelte and
|
|
||||||
// ReportMenu.svelte; the accessors centralize that access. The report body lives
|
|
||||||
// in `content` as plain text (not JSON).
|
|
||||||
export class Report extends EventReader {
|
export class Report extends EventReader {
|
||||||
readonly kind = REPORT
|
readonly kind = REPORT
|
||||||
|
|
||||||
// The reported author. Distinct from the base `pubkey()` (the reporter).
|
|
||||||
reportedPubkey() {
|
reportedPubkey() {
|
||||||
return getTagValue("p", this.event.tags)
|
return getTagValue("p", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The reported event, if any.
|
|
||||||
eventId() {
|
eventId() {
|
||||||
return getTag("e", this.event.tags)?.[1]
|
return getTag("e", this.event.tags)?.[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
// The report reason, carried as the 3rd element of the "e" tag.
|
|
||||||
reason() {
|
reason() {
|
||||||
return getTag("e", this.event.tags)?.[2]
|
return getTag("e", this.event.tags)?.[2]
|
||||||
}
|
}
|
||||||
|
|
||||||
// The report body, plain text.
|
|
||||||
content() {
|
|
||||||
return this.event.content || ""
|
|
||||||
}
|
|
||||||
|
|
||||||
builder() {
|
builder() {
|
||||||
return new ReportBuilder(this)
|
return new ReportBuilder(this)
|
||||||
}
|
}
|
||||||
@@ -44,21 +30,16 @@ export class ReportBuilder extends EventBuilder<Report> {
|
|||||||
reportedPubkey?: string
|
reportedPubkey?: string
|
||||||
eventId?: string
|
eventId?: string
|
||||||
reason?: string
|
reason?: string
|
||||||
content = ""
|
|
||||||
|
|
||||||
constructor(readonly reader?: Report) {
|
constructor(readonly reader?: Report) {
|
||||||
super(reader)
|
super(reader)
|
||||||
|
|
||||||
// Consume the represented tags out of the carried-over extraTags so they
|
|
||||||
// round-trip through the structured fields below rather than being emitted
|
|
||||||
// twice (once from buildTags, once from the base's extraTags pass-through).
|
|
||||||
const p = first(this.consumeTags("p"))
|
const p = first(this.consumeTags("p"))
|
||||||
const e = first(this.consumeTags("e"))
|
const e = first(this.consumeTags("e"))
|
||||||
|
|
||||||
this.reportedPubkey = p?.[1]
|
this.reportedPubkey = p?.[1]
|
||||||
this.eventId = e?.[1]
|
this.eventId = e?.[1]
|
||||||
this.reason = e?.[2]
|
this.reason = e?.[2]
|
||||||
this.content = reader?.event.content ?? ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setReportedPubkey(reportedPubkey: string) {
|
setReportedPubkey(reportedPubkey: string) {
|
||||||
@@ -79,12 +60,6 @@ export class ReportBuilder extends EventBuilder<Report> {
|
|||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
setContent(content: string) {
|
|
||||||
this.content = content
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
protected buildTags() {
|
protected buildTags() {
|
||||||
const tags: string[][] = []
|
const tags: string[][] = []
|
||||||
|
|
||||||
@@ -98,8 +73,4 @@ export class ReportBuilder extends EventBuilder<Report> {
|
|||||||
|
|
||||||
return tags
|
return tags
|
||||||
}
|
}
|
||||||
|
|
||||||
protected buildContent() {
|
|
||||||
return this.content
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import {uniq, nth, uniqBy} from "@welshman/lib"
|
||||||
|
import {ROOM_ADD_MEMBER, getPubkeyTagValues} from "@welshman/util"
|
||||||
|
import {EventReader} from "../EventReader.js"
|
||||||
|
import {EventBuilder} from "../EventBuilder.js"
|
||||||
|
|
||||||
|
// NIP-29 room add-member op (kind 9000).
|
||||||
|
export class RoomAddMember extends EventReader {
|
||||||
|
readonly kind = ROOM_ADD_MEMBER
|
||||||
|
|
||||||
|
pubkeys() {
|
||||||
|
return uniq(getPubkeyTagValues(this.event.tags))
|
||||||
|
}
|
||||||
|
|
||||||
|
builder() {
|
||||||
|
return new RoomAddMemberBuilder(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RoomAddMemberBuilder extends EventBuilder<RoomAddMember> {
|
||||||
|
readonly kind = ROOM_ADD_MEMBER
|
||||||
|
|
||||||
|
pubkeyTags: string[][] = []
|
||||||
|
|
||||||
|
constructor(readonly reader?: RoomAddMember) {
|
||||||
|
super(reader)
|
||||||
|
|
||||||
|
this.pubkeyTags = uniqBy(nth(1), this.consumeTags("p"))
|
||||||
|
}
|
||||||
|
|
||||||
|
addPubkey(pubkey: string) {
|
||||||
|
this.pubkeyTags = uniqBy(nth(1), [...this.pubkeyTags, ["p", pubkey]])
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildTags() {
|
||||||
|
return this.pubkeyTags
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,12 @@
|
|||||||
import {first, randomId, uniq} from "@welshman/lib"
|
import {nth, nthNe, uniq, uniqBy} from "@welshman/lib"
|
||||||
import {ROOM_ADMINS, getPubkeyTagValues} from "@welshman/util"
|
import {ROOM_ADMINS, getPubkeyTagValues} from "@welshman/util"
|
||||||
import {EventReader} from "../EventReader.js"
|
import {EventReader} from "../EventReader.js"
|
||||||
import {EventBuilder} from "../EventBuilder.js"
|
import {EventBuilder} from "../EventBuilder.js"
|
||||||
|
|
||||||
// NIP-29 kind-39001 relay-generated room admin list. Addressable, with the group
|
// NIP-29 kind-39001 room admins list.
|
||||||
// id ("h") stored in the "d" tag and admins as "p" tags. Tags-only content.
|
|
||||||
export class RoomAdmins extends EventReader {
|
export class RoomAdmins extends EventReader {
|
||||||
readonly kind = ROOM_ADMINS
|
readonly kind = ROOM_ADMINS
|
||||||
|
|
||||||
// The group id is the addressable identifier (the "d" tag).
|
|
||||||
h() {
|
|
||||||
return this.identifier()
|
|
||||||
}
|
|
||||||
|
|
||||||
pubkeys() {
|
pubkeys() {
|
||||||
return uniq(getPubkeyTagValues(this.event.tags))
|
return uniq(getPubkeyTagValues(this.event.tags))
|
||||||
}
|
}
|
||||||
@@ -25,42 +19,27 @@ export class RoomAdmins extends EventReader {
|
|||||||
export class RoomAdminsBuilder extends EventBuilder<RoomAdmins> {
|
export class RoomAdminsBuilder extends EventBuilder<RoomAdmins> {
|
||||||
readonly kind = ROOM_ADMINS
|
readonly kind = ROOM_ADMINS
|
||||||
|
|
||||||
h = randomId()
|
adminTags: string[][] = []
|
||||||
pubkeys: string[] = []
|
|
||||||
|
|
||||||
constructor(readonly reader?: RoomAdmins) {
|
constructor(readonly reader?: RoomAdmins) {
|
||||||
super(reader)
|
super(reader)
|
||||||
|
|
||||||
// Consume the represented tags out of the carried-over extraTags so they
|
this.adminTags = uniqBy(nth(1), this.consumeTags("p"))
|
||||||
// round-trip through the structured fields below rather than being emitted
|
|
||||||
// twice (once from buildTags, once from the base's extraTags pass-through).
|
|
||||||
const d = first(this.consumeTags("d"))
|
|
||||||
|
|
||||||
this.h = d?.[1] || randomId()
|
|
||||||
this.pubkeys = uniq(this.consumeTags("p").map(t => t[1]))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setH(h: string) {
|
addAdmin(pubkey: string) {
|
||||||
this.h = h
|
this.adminTags = uniqBy(nth(1), [...this.adminTags, ["p", pubkey]])
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
addPubkey(pubkey: string) {
|
removeAdmin(pubkey: string) {
|
||||||
if (!this.pubkeys.includes(pubkey)) {
|
this.adminTags = this.adminTags.filter(nthNe(1, pubkey))
|
||||||
this.pubkeys.push(pubkey)
|
|
||||||
}
|
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
protected validate() {
|
|
||||||
if (!this.h) {
|
|
||||||
throw new Error("RoomAdmins requires an h/d identifier")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected buildTags() {
|
protected buildTags() {
|
||||||
return [["d", this.h], ...this.pubkeys.map(pk => ["p", pk])]
|
return this.adminTags
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,7 @@ import {ROOM_CREATE} from "@welshman/util"
|
|||||||
import {EventReader} from "../EventReader.js"
|
import {EventReader} from "../EventReader.js"
|
||||||
import {EventBuilder} from "../EventBuilder.js"
|
import {EventBuilder} from "../EventBuilder.js"
|
||||||
|
|
||||||
// NIP-29 kind-9007 create-room action op. A regular (write-primarily) event
|
// NIP-29 kind-9007 create-room op.
|
||||||
// carrying only the target group id ("h") tag. The "h" tag is a base behavior
|
|
||||||
// tag, so the reader exposes it via the inherited group() accessor and the
|
|
||||||
// builder sets it via the inherited group() setter — there are no kind-specific
|
|
||||||
// represented tags, so nothing else is needed here.
|
|
||||||
export class RoomCreate extends EventReader {
|
export class RoomCreate extends EventReader {
|
||||||
readonly kind = ROOM_CREATE
|
readonly kind = ROOM_CREATE
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import {uniq} from "@welshman/lib"
|
import {uniq, nth, uniqBy} from "@welshman/lib"
|
||||||
import {ROOM_CREATE_PERMISSION, getPubkeyTagValues} from "@welshman/util"
|
import {ROOM_CREATE_PERMISSION, getPubkeyTagValues} from "@welshman/util"
|
||||||
import {EventReader} from "../EventReader.js"
|
import {EventReader} from "../EventReader.js"
|
||||||
import {EventBuilder} from "../EventBuilder.js"
|
import {EventBuilder} from "../EventBuilder.js"
|
||||||
|
|
||||||
// Flotilla/NIP-29 extension: relay-authored grant of room-creation permission
|
// Flotilla/NIP-29 kind-19004 room-creation permission grant.
|
||||||
// (kind 19004). The "p" tags list the pubkeys allowed to create rooms. Read-only
|
|
||||||
// in practice. Tags-only content.
|
|
||||||
export class RoomCreatePermission extends EventReader {
|
export class RoomCreatePermission extends EventReader {
|
||||||
readonly kind = ROOM_CREATE_PERMISSION
|
readonly kind = ROOM_CREATE_PERMISSION
|
||||||
|
|
||||||
@@ -25,24 +23,21 @@ export class RoomCreatePermission extends EventReader {
|
|||||||
export class RoomCreatePermissionBuilder extends EventBuilder<RoomCreatePermission> {
|
export class RoomCreatePermissionBuilder extends EventBuilder<RoomCreatePermission> {
|
||||||
readonly kind = ROOM_CREATE_PERMISSION
|
readonly kind = ROOM_CREATE_PERMISSION
|
||||||
|
|
||||||
pubkeys: string[] = []
|
pubkeyTags: string[][] = []
|
||||||
|
|
||||||
constructor(readonly reader?: RoomCreatePermission) {
|
constructor(readonly reader?: RoomCreatePermission) {
|
||||||
super(reader)
|
super(reader)
|
||||||
|
|
||||||
// Consume the represented "p" tags out of the carried-over extraTags so they
|
this.pubkeyTags = uniqBy(nth(1), this.consumeTags("p"))
|
||||||
// round-trip through the structured field below rather than being emitted
|
|
||||||
// twice (once from buildTags, once from the base's extraTags pass-through).
|
|
||||||
this.pubkeys = uniq(this.consumeTags("p").map(t => t[1]))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setPubkeys(pubkeys: string[]) {
|
setPubkeys(pubkeys: string[]) {
|
||||||
this.pubkeys = pubkeys
|
this.pubkeyTags = pubkeys.map(pk => ["p", pk])
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
protected buildTags() {
|
protected buildTags() {
|
||||||
return uniq(this.pubkeys).map(pk => ["p", pk])
|
return uniqBy(nth(1), this.pubkeyTags)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,11 @@
|
|||||||
import {ROOM_DELETE, getTagValues} from "@welshman/util"
|
import {ROOM_DELETE} from "@welshman/util"
|
||||||
import {EventReader} from "../EventReader.js"
|
import {EventReader} from "../EventReader.js"
|
||||||
import {EventBuilder} from "../EventBuilder.js"
|
import {EventBuilder} from "../EventBuilder.js"
|
||||||
|
|
||||||
// NIP-29 kind-9008 delete-room/tombstone op. A regular event that may carry
|
// NIP-29 kind-9008 delete-room op. The target room is the "h" group tag.
|
||||||
// MULTIPLE group id ("h") tags, allowing a single delete event to tombstone
|
|
||||||
// several rooms at once. Tags-only content.
|
|
||||||
//
|
|
||||||
// Note: unlike most kinds, "h" here is a repeatable identity tag (the rooms to
|
|
||||||
// delete), not the base's single behavior group. So we handle "h" explicitly —
|
|
||||||
// hs() reads them all, the builder emits one tag per id, and the base group is
|
|
||||||
// cleared — and we do NOT use the base group accessor/setter.
|
|
||||||
export class RoomDelete extends EventReader {
|
export class RoomDelete extends EventReader {
|
||||||
readonly kind = ROOM_DELETE
|
readonly kind = ROOM_DELETE
|
||||||
|
|
||||||
// All group ids tombstoned by this event.
|
|
||||||
hs() {
|
|
||||||
return getTagValues("h", this.event.tags)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convenience accessor for the first group id.
|
|
||||||
h() {
|
|
||||||
return this.hs()[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
builder() {
|
builder() {
|
||||||
return new RoomDeleteBuilder(this)
|
return new RoomDeleteBuilder(this)
|
||||||
}
|
}
|
||||||
@@ -31,40 +14,11 @@ export class RoomDelete extends EventReader {
|
|||||||
export class RoomDeleteBuilder extends EventBuilder<RoomDelete> {
|
export class RoomDeleteBuilder extends EventBuilder<RoomDelete> {
|
||||||
readonly kind = ROOM_DELETE
|
readonly kind = ROOM_DELETE
|
||||||
|
|
||||||
hs: string[] = []
|
|
||||||
|
|
||||||
constructor(readonly reader?: RoomDelete) {
|
|
||||||
super(reader)
|
|
||||||
|
|
||||||
// "h" here is a repeatable room-id tag emitted by buildTags(), not the base's
|
|
||||||
// single behavior group. The base's constructor already consumed EVERY "h" out
|
|
||||||
// of extraTags but kept only the first as groupTag, so recover the full set
|
|
||||||
// from the reader and clear groupTag so the base doesn't emit a duplicate "h".
|
|
||||||
this.hs = reader?.hs() ?? []
|
|
||||||
this.groupTag = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
addRoom(h: string) {
|
|
||||||
if (!this.hs.includes(h)) {
|
|
||||||
this.hs.push(h)
|
|
||||||
}
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
removeRoom(h: string) {
|
|
||||||
this.hs = this.hs.filter(value => value !== h)
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
protected validate() {
|
protected validate() {
|
||||||
if (this.hs.length === 0) {
|
super.validate()
|
||||||
throw new Error("RoomDelete requires at least one h tag")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected buildTags() {
|
if (!this.groupTag) {
|
||||||
return this.hs.map(h => ["h", h])
|
throw new Error("RoomDelete requires an h group")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import {first} from "@welshman/lib"
|
||||||
|
import {ROOM_EDIT_META, getTag, getTagValue} from "@welshman/util"
|
||||||
|
import {EventReader} from "../EventReader.js"
|
||||||
|
import {EventBuilder} from "../EventBuilder.js"
|
||||||
|
|
||||||
|
// NIP-29 kind-9002 edit-room-metadata action op. Carries the same metadata as the
|
||||||
|
// addressable RoomMeta (kind 39000), but as a regular event scoped to the target
|
||||||
|
// room via the "h" group tag rather than a "d" identifier.
|
||||||
|
export class RoomEdit extends EventReader {
|
||||||
|
readonly kind = ROOM_EDIT_META
|
||||||
|
|
||||||
|
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() {
|
||||||
|
return new RoomEditBuilder(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RoomEditBuilder extends EventBuilder<RoomEdit> {
|
||||||
|
readonly kind = ROOM_EDIT_META
|
||||||
|
|
||||||
|
nameTag?: string[]
|
||||||
|
aboutTag?: string[]
|
||||||
|
pictureTag?: string[]
|
||||||
|
closedTag?: string[]
|
||||||
|
hiddenTag?: string[]
|
||||||
|
privateTag?: string[]
|
||||||
|
restrictedTag?: string[]
|
||||||
|
livekitTag?: string[]
|
||||||
|
|
||||||
|
constructor(readonly reader?: RoomEdit) {
|
||||||
|
super(reader)
|
||||||
|
|
||||||
|
this.nameTag = first(this.consumeTags("name"))
|
||||||
|
this.aboutTag = first(this.consumeTags("about"))
|
||||||
|
this.pictureTag = first(this.consumeTags("picture"))
|
||||||
|
this.closedTag = first(this.consumeTags("closed"))
|
||||||
|
this.hiddenTag = first(this.consumeTags("hidden"))
|
||||||
|
this.privateTag = first(this.consumeTags("private"))
|
||||||
|
this.restrictedTag = first(this.consumeTags("restricted"))
|
||||||
|
this.livekitTag = first(this.consumeTags("livekit"))
|
||||||
|
}
|
||||||
|
|
||||||
|
setName(name: string) {
|
||||||
|
this.nameTag = ["name", name]
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setAbout(about: string) {
|
||||||
|
this.aboutTag = ["about", about]
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setPicture(picture: string, meta: string[] = []) {
|
||||||
|
this.pictureTag = ["picture", picture, ...meta]
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setClosed(closed = true) {
|
||||||
|
this.closedTag = closed ? ["closed"] : undefined
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setHidden(hidden = true) {
|
||||||
|
this.hiddenTag = hidden ? ["hidden"] : undefined
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setPrivate(isPrivate = true) {
|
||||||
|
this.privateTag = isPrivate ? ["private"] : undefined
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setRestricted(restricted = true) {
|
||||||
|
this.restrictedTag = restricted ? ["restricted"] : undefined
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setLivekit(livekit = true) {
|
||||||
|
this.livekitTag = livekit ? ["livekit"] : undefined
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildTags() {
|
||||||
|
const tags: string[][] = []
|
||||||
|
|
||||||
|
if (this.nameTag) tags.push(this.nameTag)
|
||||||
|
if (this.aboutTag) tags.push(this.aboutTag)
|
||||||
|
if (this.pictureTag) tags.push(this.pictureTag)
|
||||||
|
if (this.closedTag) tags.push(this.closedTag)
|
||||||
|
if (this.hiddenTag) tags.push(this.hiddenTag)
|
||||||
|
if (this.privateTag) tags.push(this.privateTag)
|
||||||
|
if (this.restrictedTag) tags.push(this.restrictedTag)
|
||||||
|
if (this.livekitTag) tags.push(this.livekitTag)
|
||||||
|
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,20 +4,14 @@ import type {ISigner} from "@welshman/signer"
|
|||||||
import {EventReader} from "../EventReader.js"
|
import {EventReader} from "../EventReader.js"
|
||||||
import {EventBuilder} from "../EventBuilder.js"
|
import {EventBuilder} from "../EventBuilder.js"
|
||||||
|
|
||||||
// NIP-29 kind-9021 room join request. A regular (read-and-written) event
|
// NIP-29 kind-9021 room join request.
|
||||||
// carrying the target group id ("h", handled by the base group accessor), an
|
|
||||||
// optional invite "claim" tag (exposed as code), and a free-text reason in the
|
|
||||||
// event content. Drives the membership state machine (ROOM_JOIN ->
|
|
||||||
// Pending/Granted) and the pending-join admin queue, grouped by h + pubkey.
|
|
||||||
export class RoomJoin extends EventReader {
|
export class RoomJoin extends EventReader {
|
||||||
readonly kind = ROOM_JOIN
|
readonly kind = ROOM_JOIN
|
||||||
|
|
||||||
// The invite "claim" tag.
|
|
||||||
code() {
|
code() {
|
||||||
return getTagValue("claim", this.event.tags)
|
return getTagValue("claim", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Free-text reason carried in the event content.
|
|
||||||
reason() {
|
reason() {
|
||||||
return this.event.content || undefined
|
return this.event.content || undefined
|
||||||
}
|
}
|
||||||
@@ -30,23 +24,18 @@ export class RoomJoin extends EventReader {
|
|||||||
export class RoomJoinBuilder extends EventBuilder<RoomJoin> {
|
export class RoomJoinBuilder extends EventBuilder<RoomJoin> {
|
||||||
readonly kind = ROOM_JOIN
|
readonly kind = ROOM_JOIN
|
||||||
|
|
||||||
code?: string
|
codeTag?: string[]
|
||||||
reason?: string
|
reason?: string
|
||||||
|
|
||||||
constructor(readonly reader?: RoomJoin) {
|
constructor(readonly reader?: RoomJoin) {
|
||||||
super(reader)
|
super(reader)
|
||||||
|
|
||||||
// Consume the represented "claim" tag out of the carried-over extraTags so it
|
this.codeTag = first(this.consumeTags("claim"))
|
||||||
// round-trips through the structured field below rather than being emitted
|
|
||||||
// twice (once from buildTags, once from the base's extraTags pass-through).
|
|
||||||
const claim = first(this.consumeTags("claim"))
|
|
||||||
|
|
||||||
this.code = claim?.[1]
|
|
||||||
this.reason = reader?.event.content || undefined
|
this.reason = reader?.event.content || undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
setCode(code: string) {
|
setCode(code: string) {
|
||||||
this.code = code
|
this.codeTag = ["claim", code]
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
@@ -58,6 +47,8 @@ export class RoomJoinBuilder extends EventBuilder<RoomJoin> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected validate() {
|
protected validate() {
|
||||||
|
super.validate()
|
||||||
|
|
||||||
if (!this.groupTag) {
|
if (!this.groupTag) {
|
||||||
throw new Error("RoomJoin requires an h/group")
|
throw new Error("RoomJoin requires an h/group")
|
||||||
}
|
}
|
||||||
@@ -70,7 +61,7 @@ export class RoomJoinBuilder extends EventBuilder<RoomJoin> {
|
|||||||
protected buildTags() {
|
protected buildTags() {
|
||||||
const tags: string[][] = []
|
const tags: string[][] = []
|
||||||
|
|
||||||
if (this.code) tags.push(["claim", this.code])
|
if (this.codeTag) tags.push(this.codeTag)
|
||||||
|
|
||||||
return tags
|
return tags
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,10 @@ import {ROOM_LEAVE} from "@welshman/util"
|
|||||||
import {EventReader} from "../EventReader.js"
|
import {EventReader} from "../EventReader.js"
|
||||||
import {EventBuilder} from "../EventBuilder.js"
|
import {EventBuilder} from "../EventBuilder.js"
|
||||||
|
|
||||||
// NIP-29 kind-9022 room leave op, the counterpart to RoomJoin. A regular event
|
// NIP-29 kind-9022 room leave op. The target room is the "h" group tag.
|
||||||
// carrying the target group id ("h") tag, which resets the membership state
|
|
||||||
// machine (ROOM_LEAVE -> Initial). The only represented tag is the group ("h"),
|
|
||||||
// which the base owns as a behavior tag, so buildTags is empty. Tags-only content.
|
|
||||||
export class RoomLeave extends EventReader {
|
export class RoomLeave extends EventReader {
|
||||||
readonly kind = ROOM_LEAVE
|
readonly kind = ROOM_LEAVE
|
||||||
|
|
||||||
// The group id ("h") is read via the base group() accessor.
|
|
||||||
h() {
|
|
||||||
return this.group()
|
|
||||||
}
|
|
||||||
|
|
||||||
builder() {
|
builder() {
|
||||||
return new RoomLeaveBuilder(this)
|
return new RoomLeaveBuilder(this)
|
||||||
}
|
}
|
||||||
@@ -23,8 +15,10 @@ export class RoomLeaveBuilder extends EventBuilder<RoomLeave> {
|
|||||||
readonly kind = ROOM_LEAVE
|
readonly kind = ROOM_LEAVE
|
||||||
|
|
||||||
protected validate() {
|
protected validate() {
|
||||||
|
super.validate()
|
||||||
|
|
||||||
if (!this.groupTag) {
|
if (!this.groupTag) {
|
||||||
throw new Error("RoomLeave requires an h identifier")
|
throw new Error("RoomLeave requires an h group")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ import {ROOMS, getGroupTags, getGroupTagValues} from "@welshman/util"
|
|||||||
import {ListReader} from "../ListReader.js"
|
import {ListReader} from "../ListReader.js"
|
||||||
import {ListBuilder} from "../ListBuilder.js"
|
import {ListBuilder} from "../ListBuilder.js"
|
||||||
|
|
||||||
// NIP-51 / NIP-29 kind-10009 simple-groups membership list. Each entry is a
|
// NIP-51 kind-10009 simple-groups membership list.
|
||||||
// group tag `["group", groupId, relayUrl]` (legacy `"h"` is also accepted on
|
|
||||||
// read). Distinct from the NIP-29 room management events, which are not lists.
|
|
||||||
export class RoomList extends ListReader {
|
export class RoomList extends ListReader {
|
||||||
readonly kind = ROOMS
|
readonly kind = ROOMS
|
||||||
|
|
||||||
@@ -25,8 +23,8 @@ export class RoomList extends ListReader {
|
|||||||
export class RoomListBuilder extends ListBuilder<RoomList> {
|
export class RoomListBuilder extends ListBuilder<RoomList> {
|
||||||
readonly kind = ROOMS
|
readonly kind = ROOMS
|
||||||
|
|
||||||
join(groupId: string, relayUrl: string) {
|
join(groupId: string, url: string) {
|
||||||
return this.addPublic(["group", groupId, relayUrl])
|
return this.addPublic(["group", groupId, url])
|
||||||
}
|
}
|
||||||
|
|
||||||
leave(groupId: string) {
|
leave(groupId: string) {
|
||||||
|
|||||||
@@ -1,19 +1,12 @@
|
|||||||
import {first, uniq} from "@welshman/lib"
|
import {nth, nthNe, uniq, uniqBy} from "@welshman/lib"
|
||||||
import {ROOM_MEMBERS, getPubkeyTagValues} from "@welshman/util"
|
import {ROOM_MEMBERS, getPubkeyTagValues} from "@welshman/util"
|
||||||
import {EventReader} from "../EventReader.js"
|
import {EventReader} from "../EventReader.js"
|
||||||
import {EventBuilder} from "../EventBuilder.js"
|
import {EventBuilder} from "../EventBuilder.js"
|
||||||
|
|
||||||
// NIP-29 kind-39002 relay-authored room member-list snapshot. Addressable, with
|
// NIP-29 kind-39002 room members list.
|
||||||
// the group id ("h") stored in the "d" tag and members listed as "p" tags.
|
|
||||||
// Tags-only content.
|
|
||||||
export class RoomMembers extends EventReader {
|
export class RoomMembers extends EventReader {
|
||||||
readonly kind = ROOM_MEMBERS
|
readonly kind = ROOM_MEMBERS
|
||||||
|
|
||||||
// The group id is the addressable identifier (the "d" tag).
|
|
||||||
h() {
|
|
||||||
return this.identifier()
|
|
||||||
}
|
|
||||||
|
|
||||||
members() {
|
members() {
|
||||||
return uniq(getPubkeyTagValues(this.event.tags))
|
return uniq(getPubkeyTagValues(this.event.tags))
|
||||||
}
|
}
|
||||||
@@ -30,40 +23,27 @@ export class RoomMembers extends EventReader {
|
|||||||
export class RoomMembersBuilder extends EventBuilder<RoomMembers> {
|
export class RoomMembersBuilder extends EventBuilder<RoomMembers> {
|
||||||
readonly kind = ROOM_MEMBERS
|
readonly kind = ROOM_MEMBERS
|
||||||
|
|
||||||
h = ""
|
memberTags: string[][] = []
|
||||||
members: string[] = []
|
|
||||||
|
|
||||||
constructor(readonly reader?: RoomMembers) {
|
constructor(readonly reader?: RoomMembers) {
|
||||||
super(reader)
|
super(reader)
|
||||||
|
|
||||||
// Consume the represented tags out of the carried-over extraTags so they
|
this.memberTags = uniqBy(nth(1), this.consumeTags("p"))
|
||||||
// round-trip through the structured fields below rather than being emitted
|
|
||||||
// twice (once from buildTags, once from the base's extraTags pass-through).
|
|
||||||
const d = first(this.consumeTags("d"))
|
|
||||||
|
|
||||||
this.h = d?.[1] || ""
|
|
||||||
this.members = uniq(this.consumeTags("p").map(t => t[1]))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
addMember(pubkey: string) {
|
addMember(pubkey: string) {
|
||||||
this.members = uniq([...this.members, pubkey])
|
this.memberTags = uniqBy(nth(1), [...this.memberTags, ["p", pubkey]])
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
removeMember(pubkey: string) {
|
removeMember(pubkey: string) {
|
||||||
this.members = this.members.filter(pk => pk !== pubkey)
|
this.memberTags = this.memberTags.filter(nthNe(1, pubkey))
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
protected validate() {
|
|
||||||
if (!this.h) {
|
|
||||||
throw new Error("RoomMembers requires an h/d identifier")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected buildTags() {
|
protected buildTags() {
|
||||||
return [["d", this.h], ...this.members.map(pk => ["p", pk])]
|
return this.memberTags
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
import {uniq} from "@welshman/lib"
|
|
||||||
import {ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER, getPubkeyTagValues} from "@welshman/util"
|
|
||||||
import {EventReader} from "../EventReader.js"
|
|
||||||
import {EventBuilder} from "../EventBuilder.js"
|
|
||||||
|
|
||||||
// NIP-29 moderation op for adding (kind 9000) or removing (kind 9001) room
|
|
||||||
// members. Regular (non-addressable) events carrying the affected pubkeys in "p"
|
|
||||||
// tags; the target group id is the base `group` ("h") behavior tag. Add and
|
|
||||||
// remove share this shape; each is its own concrete reader/builder fixing the
|
|
||||||
// kind via a static field.
|
|
||||||
//
|
|
||||||
// Flotilla's membership replay treats RoomAddMember => member, RoomRemoveMember
|
|
||||||
// => not a member.
|
|
||||||
export abstract class RoomMembershipOp extends EventReader {
|
|
||||||
// The affected pubkeys, deduped.
|
|
||||||
pubkeys() {
|
|
||||||
return uniq(getPubkeyTagValues(this.event.tags))
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract builder(): RoomMembershipOpBuilder
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared write side: collect pubkeys, emit them as "p" tags. The target group id
|
|
||||||
// ("h") is set via the base group behavior tag.
|
|
||||||
export abstract class RoomMembershipOpBuilder extends EventBuilder<RoomMembershipOp> {
|
|
||||||
pubkeys: string[] = []
|
|
||||||
|
|
||||||
constructor(readonly reader?: RoomMembershipOp) {
|
|
||||||
super(reader)
|
|
||||||
|
|
||||||
// Consume the represented "p" tags out of the carried-over extraTags so they
|
|
||||||
// round-trip through the structured field below rather than being emitted
|
|
||||||
// twice (once from buildTags, once from the base's extraTags pass-through).
|
|
||||||
this.pubkeys = uniq(this.consumeTags("p").map(t => t[1]))
|
|
||||||
}
|
|
||||||
|
|
||||||
addPubkey(pubkey: string) {
|
|
||||||
this.pubkeys = uniq([...this.pubkeys, pubkey])
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
protected buildTags() {
|
|
||||||
return this.pubkeys.map(pk => ["p", pk])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RoomAddMember extends RoomMembershipOp {
|
|
||||||
readonly kind = ROOM_ADD_MEMBER
|
|
||||||
|
|
||||||
builder() {
|
|
||||||
return new RoomAddMemberBuilder(this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RoomAddMemberBuilder extends RoomMembershipOpBuilder {
|
|
||||||
readonly kind = ROOM_ADD_MEMBER
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RoomRemoveMember extends RoomMembershipOp {
|
|
||||||
readonly kind = ROOM_REMOVE_MEMBER
|
|
||||||
|
|
||||||
builder() {
|
|
||||||
return new RoomRemoveMemberBuilder(this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RoomRemoveMemberBuilder extends RoomMembershipOpBuilder {
|
|
||||||
readonly kind = ROOM_REMOVE_MEMBER
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,12 @@
|
|||||||
import {first, randomId} from "@welshman/lib"
|
import {first, spec} from "@welshman/lib"
|
||||||
import {ROOM_META, getTag, getTagValue} from "@welshman/util"
|
import {ROOM_META, getTag, getTagValue} from "@welshman/util"
|
||||||
import {EventReader} from "../EventReader.js"
|
import {EventReader} from "../EventReader.js"
|
||||||
import {EventBuilder} from "../EventBuilder.js"
|
import {EventBuilder} from "../EventBuilder.js"
|
||||||
|
|
||||||
// NIP-29 kind-39000 relay-generated group metadata. Addressable, with the group
|
// NIP-29 kind-39000 room metadata.
|
||||||
// id ("h") stored in the "d" tag. Tags-only content.
|
|
||||||
export class RoomMeta extends EventReader {
|
export class RoomMeta extends EventReader {
|
||||||
readonly kind = ROOM_META
|
readonly kind = ROOM_META
|
||||||
|
|
||||||
// The group id is the addressable identifier (the "d" tag).
|
|
||||||
h() {
|
|
||||||
return this.identifier()
|
|
||||||
}
|
|
||||||
|
|
||||||
name() {
|
name() {
|
||||||
return getTagValue("name", this.event.tags)
|
return getTagValue("name", this.event.tags)
|
||||||
}
|
}
|
||||||
@@ -32,23 +26,23 @@ export class RoomMeta extends EventReader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isClosed() {
|
isClosed() {
|
||||||
return this.event.tags.some(t => t[0] === "closed")
|
return this.event.tags.some(spec(["closed"]))
|
||||||
}
|
}
|
||||||
|
|
||||||
isHidden() {
|
isHidden() {
|
||||||
return this.event.tags.some(t => t[0] === "hidden")
|
return this.event.tags.some(spec(["hidden"]))
|
||||||
}
|
}
|
||||||
|
|
||||||
isPrivate() {
|
isPrivate() {
|
||||||
return this.event.tags.some(t => t[0] === "private")
|
return this.event.tags.some(spec(["private"]))
|
||||||
}
|
}
|
||||||
|
|
||||||
isRestricted() {
|
isRestricted() {
|
||||||
return this.event.tags.some(t => t[0] === "restricted")
|
return this.event.tags.some(spec(["restricted"]))
|
||||||
}
|
}
|
||||||
|
|
||||||
livekit() {
|
hasLivekit() {
|
||||||
return this.event.tags.some(t => t[0] === "livekit")
|
return this.event.tags.some(spec(["livekit"]))
|
||||||
}
|
}
|
||||||
|
|
||||||
builder() {
|
builder() {
|
||||||
@@ -59,74 +53,87 @@ export class RoomMeta extends EventReader {
|
|||||||
export class RoomMetaBuilder extends EventBuilder<RoomMeta> {
|
export class RoomMetaBuilder extends EventBuilder<RoomMeta> {
|
||||||
readonly kind = ROOM_META
|
readonly kind = ROOM_META
|
||||||
|
|
||||||
h = randomId()
|
nameTag?: string[]
|
||||||
name?: string
|
aboutTag?: string[]
|
||||||
about?: string
|
pictureTag?: string[]
|
||||||
picture?: string
|
closedTag?: string[]
|
||||||
pictureMeta?: string[]
|
hiddenTag?: string[]
|
||||||
closed = false
|
privateTag?: string[]
|
||||||
hidden = false
|
restrictedTag?: string[]
|
||||||
isPrivate = false
|
livekitTag?: string[]
|
||||||
restricted = false
|
|
||||||
livekit = false
|
|
||||||
|
|
||||||
constructor(readonly reader?: RoomMeta) {
|
constructor(readonly reader?: RoomMeta) {
|
||||||
super(reader)
|
super(reader)
|
||||||
|
|
||||||
// Consume the represented tags out of the carried-over extraTags so they
|
this.nameTag = first(this.consumeTags("name"))
|
||||||
// round-trip through the structured fields below rather than being emitted
|
this.aboutTag = first(this.consumeTags("about"))
|
||||||
// twice (once from buildTags, once from the base's extraTags pass-through).
|
this.pictureTag = first(this.consumeTags("picture"))
|
||||||
const d = first(this.consumeTags("d"))
|
this.closedTag = first(this.consumeTags("closed"))
|
||||||
const picture = first(this.consumeTags("picture"))
|
this.hiddenTag = first(this.consumeTags("hidden"))
|
||||||
|
this.privateTag = first(this.consumeTags("private"))
|
||||||
this.h = d?.[1] || randomId()
|
this.restrictedTag = first(this.consumeTags("restricted"))
|
||||||
this.name = first(this.consumeTags("name"))?.[1]
|
this.livekitTag = first(this.consumeTags("livekit"))
|
||||||
this.about = first(this.consumeTags("about"))?.[1]
|
|
||||||
this.picture = picture?.[1]
|
|
||||||
this.pictureMeta = picture ? picture.slice(2) : undefined
|
|
||||||
this.closed = this.consumeTags("closed").length > 0
|
|
||||||
this.hidden = this.consumeTags("hidden").length > 0
|
|
||||||
this.isPrivate = this.consumeTags("private").length > 0
|
|
||||||
this.restricted = this.consumeTags("restricted").length > 0
|
|
||||||
this.livekit = this.consumeTags("livekit").length > 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setName(name: string) {
|
setName(name: string) {
|
||||||
this.name = name
|
this.nameTag = ["name", name]
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
setAbout(about: string) {
|
setAbout(about: string) {
|
||||||
this.about = about
|
this.aboutTag = ["about", about]
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
setPicture(picture: string, meta?: string[]) {
|
setPicture(picture: string, meta: string[] = []) {
|
||||||
this.picture = picture
|
this.pictureTag = ["picture", picture, ...meta]
|
||||||
this.pictureMeta = meta
|
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
protected validate() {
|
setClosed(closed = true) {
|
||||||
if (!this.h) {
|
this.closedTag = closed ? ["closed"] : undefined
|
||||||
throw new Error("RoomMeta requires an h/d identifier")
|
|
||||||
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setHidden(hidden = true) {
|
||||||
|
this.hiddenTag = hidden ? ["hidden"] : undefined
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setPrivate(isPrivate = true) {
|
||||||
|
this.privateTag = isPrivate ? ["private"] : undefined
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setRestricted(restricted = true) {
|
||||||
|
this.restrictedTag = restricted ? ["restricted"] : undefined
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setLivekit(livekit = true) {
|
||||||
|
this.livekitTag = livekit ? ["livekit"] : undefined
|
||||||
|
|
||||||
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
protected buildTags() {
|
protected buildTags() {
|
||||||
const tags: string[][] = [["d", this.h]]
|
const tags: string[][] = []
|
||||||
|
|
||||||
if (this.name) tags.push(["name", this.name])
|
if (this.nameTag) tags.push(this.nameTag)
|
||||||
if (this.about) tags.push(["about", this.about])
|
if (this.aboutTag) tags.push(this.aboutTag)
|
||||||
if (this.picture) tags.push(["picture", this.picture, ...(this.pictureMeta || [])])
|
if (this.pictureTag) tags.push(this.pictureTag)
|
||||||
if (this.closed) tags.push(["closed"])
|
if (this.closedTag) tags.push(this.closedTag)
|
||||||
if (this.hidden) tags.push(["hidden"])
|
if (this.hiddenTag) tags.push(this.hiddenTag)
|
||||||
if (this.isPrivate) tags.push(["private"])
|
if (this.privateTag) tags.push(this.privateTag)
|
||||||
if (this.restricted) tags.push(["restricted"])
|
if (this.restrictedTag) tags.push(this.restrictedTag)
|
||||||
if (this.livekit) tags.push(["livekit"])
|
if (this.livekitTag) tags.push(this.livekitTag)
|
||||||
|
|
||||||
return tags
|
return tags
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import {uniq, nth, uniqBy} from "@welshman/lib"
|
||||||
|
import {ROOM_REMOVE_MEMBER, getPubkeyTagValues} from "@welshman/util"
|
||||||
|
import {EventReader} from "../EventReader.js"
|
||||||
|
import {EventBuilder} from "../EventBuilder.js"
|
||||||
|
|
||||||
|
// NIP-29 room remove-member op (kind 9001).
|
||||||
|
export class RoomRemoveMember extends EventReader {
|
||||||
|
readonly kind = ROOM_REMOVE_MEMBER
|
||||||
|
|
||||||
|
pubkeys() {
|
||||||
|
return uniq(getPubkeyTagValues(this.event.tags))
|
||||||
|
}
|
||||||
|
|
||||||
|
builder() {
|
||||||
|
return new RoomRemoveMemberBuilder(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RoomRemoveMemberBuilder extends EventBuilder<RoomRemoveMember> {
|
||||||
|
readonly kind = ROOM_REMOVE_MEMBER
|
||||||
|
|
||||||
|
pubkeyTags: string[][] = []
|
||||||
|
|
||||||
|
constructor(readonly reader?: RoomRemoveMember) {
|
||||||
|
super(reader)
|
||||||
|
|
||||||
|
this.pubkeyTags = uniqBy(nth(1), this.consumeTags("p"))
|
||||||
|
}
|
||||||
|
|
||||||
|
addPubkey(pubkey: string) {
|
||||||
|
this.pubkeyTags = uniqBy(nth(1), [...this.pubkeyTags, ["p", pubkey]])
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildTags() {
|
||||||
|
return this.pubkeyTags
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,7 @@ import {SEARCH_RELAYS, getTagValues, normalizeRelayUrl} from "@welshman/util"
|
|||||||
import {ListReader} from "../ListReader.js"
|
import {ListReader} from "../ListReader.js"
|
||||||
import {ListBuilder} from "../ListBuilder.js"
|
import {ListBuilder} from "../ListBuilder.js"
|
||||||
|
|
||||||
// NIP-51 kind-10007 search relays (NIP-50). Entries are marker-less
|
// NIP-51 kind-10007 search relays list.
|
||||||
// ['relay', url] tags (NOT NIP-65 'r' tags with read/write markers). Identical
|
|
||||||
// structure to BlockedRelayList; `urls()` stays a flat, normalized set.
|
|
||||||
export class SearchRelayList extends ListReader {
|
export class SearchRelayList extends ListReader {
|
||||||
readonly kind = SEARCH_RELAYS
|
readonly kind = SEARCH_RELAYS
|
||||||
|
|
||||||
@@ -25,15 +23,15 @@ export class SearchRelayList extends ListReader {
|
|||||||
export class SearchRelayListBuilder extends ListBuilder<SearchRelayList> {
|
export class SearchRelayListBuilder extends ListBuilder<SearchRelayList> {
|
||||||
readonly kind = SEARCH_RELAYS
|
readonly kind = SEARCH_RELAYS
|
||||||
|
|
||||||
addRelay(url: string) {
|
addUrl(url: string) {
|
||||||
return this.addPublic(["relay", normalizeRelayUrl(url)])
|
return this.addPublic(["relay", normalizeRelayUrl(url)])
|
||||||
}
|
}
|
||||||
|
|
||||||
removeRelay(url: string) {
|
removeUrl(url: string) {
|
||||||
return this.drop(nthEq(1, normalizeRelayUrl(url)))
|
return this.drop(nthEq(1, normalizeRelayUrl(url)))
|
||||||
}
|
}
|
||||||
|
|
||||||
setRelays(urls: string[]) {
|
setUrls(urls: string[]) {
|
||||||
this.clear()
|
this.clear()
|
||||||
|
|
||||||
return this.addPublic(...urls.map(url => ["relay", normalizeRelayUrl(url)]))
|
return this.addPublic(...urls.map(url => ["relay", normalizeRelayUrl(url)]))
|
||||||
|
|||||||
@@ -3,12 +3,7 @@ import {THREAD, getTagValue} from "@welshman/util"
|
|||||||
import {EventReader} from "../EventReader.js"
|
import {EventReader} from "../EventReader.js"
|
||||||
import {EventBuilder} from "../EventBuilder.js"
|
import {EventBuilder} from "../EventBuilder.js"
|
||||||
|
|
||||||
// NIP-7D kind-11 forum thread root. The body lives in `content` as plain text
|
// NIP-7D kind-11 forum thread root.
|
||||||
// (not JSON) and the title is carried in a "title" tag; room scoping is handled
|
|
||||||
// by the base `group` behavior tag. Non-addressable (referenced by event id);
|
|
||||||
// replies are COMMENT (kind 1111) via "#E". Flotilla also appends editor/inline
|
|
||||||
// tags at call sites; those round-trip via the base `extraTags` (with "title"
|
|
||||||
// consumed by the builder so it isn't double-counted).
|
|
||||||
export class Thread extends EventReader {
|
export class Thread extends EventReader {
|
||||||
readonly kind = THREAD
|
readonly kind = THREAD
|
||||||
|
|
||||||
@@ -16,10 +11,6 @@ export class Thread extends EventReader {
|
|||||||
return getTagValue("title", this.event.tags)
|
return getTagValue("title", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
content() {
|
|
||||||
return this.event.content || ""
|
|
||||||
}
|
|
||||||
|
|
||||||
builder() {
|
builder() {
|
||||||
return new ThreadBuilder(this)
|
return new ThreadBuilder(this)
|
||||||
}
|
}
|
||||||
@@ -28,27 +19,16 @@ export class Thread extends EventReader {
|
|||||||
export class ThreadBuilder extends EventBuilder<Thread> {
|
export class ThreadBuilder extends EventBuilder<Thread> {
|
||||||
readonly kind = THREAD
|
readonly kind = THREAD
|
||||||
|
|
||||||
title?: string
|
titleTag?: string[]
|
||||||
content = ""
|
|
||||||
|
|
||||||
constructor(readonly reader?: Thread) {
|
constructor(readonly reader?: Thread) {
|
||||||
super(reader)
|
super(reader)
|
||||||
|
|
||||||
// Consume the represented "title" tag out of the carried-over extraTags so it
|
this.titleTag = first(this.consumeTags("title"))
|
||||||
// round-trips through the field below rather than being emitted twice (once
|
|
||||||
// from buildTags, once from the base's extraTags pass-through).
|
|
||||||
this.title = first(this.consumeTags("title"))?.[1]
|
|
||||||
this.content = reader?.event.content ?? ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setTitle(title: string) {
|
setTitle(title: string) {
|
||||||
this.title = title
|
this.titleTag = ["title", title]
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
setContent(content: string) {
|
|
||||||
this.content = content
|
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
@@ -56,12 +36,8 @@ export class ThreadBuilder extends EventBuilder<Thread> {
|
|||||||
protected buildTags() {
|
protected buildTags() {
|
||||||
const tags: string[][] = []
|
const tags: string[][] = []
|
||||||
|
|
||||||
if (this.title) tags.push(["title", this.title])
|
if (this.titleTag) tags.push(this.titleTag)
|
||||||
|
|
||||||
return tags
|
return tags
|
||||||
}
|
}
|
||||||
|
|
||||||
protected buildContent() {
|
|
||||||
return this.content
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,20 @@
|
|||||||
import {first, randomId, range, DAY} from "@welshman/lib"
|
import {first, range, DAY} from "@welshman/lib"
|
||||||
import {EVENT_TIME, getTagValue} from "@welshman/util"
|
import {EVENT_TIME, getTagValue} from "@welshman/util"
|
||||||
import {EventReader} from "../EventReader.js"
|
import {EventReader} from "../EventReader.js"
|
||||||
import {EventBuilder} from "../EventBuilder.js"
|
import {EventBuilder} from "../EventBuilder.js"
|
||||||
|
|
||||||
// NIP-52 kind-31923 time-based calendar event. Addressable via the "d" tag.
|
// NIP-52 kind-31923 time-based calendar event.
|
||||||
// `start`/`end` are unix-second timestamps carried in "start"/"end" tags
|
|
||||||
// (parsed with parseInt), `title` falls back to the legacy "name" tag, and the
|
|
||||||
// plain-text body lives in the event content. Room scoping is handled by the
|
|
||||||
// base `group` behavior tag. Named TimeEvent (not CalendarEvent) to leave room
|
|
||||||
// for a future date-based event (EVENT_DATE 31922); CALENDAR 31924 /
|
|
||||||
// EVENT_RSVP 31925 are not used. Tags + plain-text content, so it extends
|
|
||||||
// EventReader/EventBuilder directly (no parsed `plain`).
|
|
||||||
//
|
|
||||||
// The "D" day tags are NOT intrinsic state — they're a derived index over
|
|
||||||
// start..end used purely so calendar events can be filtered by day, so they're
|
|
||||||
// dropped on read and recomputed in buildTags (matching flotilla's
|
|
||||||
// daysBetween: one tag per epoch-day floor(seconds / DAY) the event spans).
|
|
||||||
export class TimeEvent extends EventReader {
|
export class TimeEvent extends EventReader {
|
||||||
readonly kind = EVENT_TIME
|
readonly kind = EVENT_TIME
|
||||||
|
|
||||||
title() {
|
title() {
|
||||||
return getTagValue("title", this.event.tags) || getTagValue("name", this.event.tags)
|
return getTagValue("title", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
location() {
|
location() {
|
||||||
return getTagValue("location", this.event.tags)
|
return getTagValue("location", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
content() {
|
|
||||||
return this.event.content || ""
|
|
||||||
}
|
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
const start = parseInt(getTagValue("start", this.event.tags)!)
|
const start = parseInt(getTagValue("start", this.event.tags)!)
|
||||||
|
|
||||||
@@ -51,51 +35,33 @@ export class TimeEvent extends EventReader {
|
|||||||
export class TimeEventBuilder extends EventBuilder<TimeEvent> {
|
export class TimeEventBuilder extends EventBuilder<TimeEvent> {
|
||||||
readonly kind = EVENT_TIME
|
readonly kind = EVENT_TIME
|
||||||
|
|
||||||
identifier = randomId()
|
titleTag?: string[]
|
||||||
title?: string
|
locationTag?: string[]
|
||||||
location?: string
|
|
||||||
content = ""
|
|
||||||
start?: number
|
start?: number
|
||||||
end?: number
|
end?: number
|
||||||
|
|
||||||
constructor(readonly reader?: TimeEvent) {
|
constructor(readonly reader?: TimeEvent) {
|
||||||
super(reader)
|
super(reader)
|
||||||
|
|
||||||
// Consume the represented tags out of the carried-over extraTags so they
|
|
||||||
// round-trip through the structured fields below rather than being emitted
|
|
||||||
// twice (once from buildTags, once from the base's extraTags pass-through).
|
|
||||||
// The legacy "name" tag and the derived "D" day tags are consumed too: name
|
|
||||||
// folds into title, and the "D" index is recomputed in buildTags.
|
|
||||||
const d = first(this.consumeTags("d"))
|
|
||||||
const title = first(this.consumeTags("title"))
|
|
||||||
const name = first(this.consumeTags("name"))
|
|
||||||
const start = first(this.consumeTags("start"))
|
const start = first(this.consumeTags("start"))
|
||||||
const end = first(this.consumeTags("end"))
|
const end = first(this.consumeTags("end"))
|
||||||
|
|
||||||
this.consumeTags("D")
|
this.consumeTags("D")
|
||||||
|
|
||||||
this.identifier = d?.[1] || randomId()
|
this.titleTag = first(this.consumeTags("title"))
|
||||||
this.title = title?.[1] || name?.[1]
|
this.locationTag = first(this.consumeTags("location"))
|
||||||
this.location = first(this.consumeTags("location"))?.[1]
|
|
||||||
this.content = reader?.event.content ?? ""
|
|
||||||
this.start = start ? (isNaN(parseInt(start[1])) ? undefined : parseInt(start[1])) : undefined
|
this.start = start ? (isNaN(parseInt(start[1])) ? undefined : parseInt(start[1])) : undefined
|
||||||
this.end = end ? (isNaN(parseInt(end[1])) ? undefined : parseInt(end[1])) : undefined
|
this.end = end ? (isNaN(parseInt(end[1])) ? undefined : parseInt(end[1])) : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
setTitle(title: string) {
|
setTitle(title: string) {
|
||||||
this.title = title
|
this.titleTag = ["title", title]
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
setLocation(location: string) {
|
setLocation(location: string) {
|
||||||
this.location = location
|
this.locationTag = ["location", location]
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
setContent(content: string) {
|
|
||||||
this.content = content
|
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
@@ -112,20 +78,15 @@ export class TimeEventBuilder extends EventBuilder<TimeEvent> {
|
|||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
protected buildContent() {
|
|
||||||
return this.content
|
|
||||||
}
|
|
||||||
|
|
||||||
protected buildTags() {
|
protected buildTags() {
|
||||||
const tags: string[][] = [["d", this.identifier]]
|
const tags: string[][] = []
|
||||||
|
|
||||||
if (this.title) tags.push(["title", this.title])
|
if (this.titleTag) tags.push(this.titleTag)
|
||||||
if (this.location) tags.push(["location", this.location])
|
if (this.locationTag) tags.push(this.locationTag)
|
||||||
if (this.start != null) tags.push(["start", String(this.start)])
|
if (this.start !== undefined) tags.push(["start", String(this.start)])
|
||||||
if (this.end != null) tags.push(["end", String(this.end)])
|
if (this.end !== undefined) tags.push(["end", String(this.end)])
|
||||||
|
|
||||||
// Derived day index for filtering: one "D" tag per epoch-day the event spans.
|
if (this.start !== undefined && this.end !== undefined) {
|
||||||
if (this.start != null && this.end != null) {
|
|
||||||
for (const t of range(this.start, this.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))])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,7 @@ import {TOPICS, getTopicTagValues, getAddressTagValues} from "@welshman/util"
|
|||||||
import {ListReader} from "../ListReader.js"
|
import {ListReader} from "../ListReader.js"
|
||||||
import {ListBuilder} from "../ListBuilder.js"
|
import {ListBuilder} from "../ListBuilder.js"
|
||||||
|
|
||||||
// NIP-51 kind-10015 interests/followed-topics list. Followed hashtags are stored
|
// NIP-51 kind-10015 interests/topics list.
|
||||||
// as `t` tags; the list may also reference interest sets (kind 30015) via `a`
|
|
||||||
// tags. Extends ListReader/ListBuilder so entries may be public (tags) or private
|
|
||||||
// (encrypted content), treated as one merged set by the accessors.
|
|
||||||
export class TopicList extends ListReader {
|
export class TopicList extends ListReader {
|
||||||
readonly kind = TOPICS
|
readonly kind = TOPICS
|
||||||
|
|
||||||
|
|||||||
@@ -3,18 +3,10 @@ import {ZAP_GOAL, getTagValue, getTagValues} from "@welshman/util"
|
|||||||
import {EventReader} from "../EventReader.js"
|
import {EventReader} from "../EventReader.js"
|
||||||
import {EventBuilder} from "../EventBuilder.js"
|
import {EventBuilder} from "../EventBuilder.js"
|
||||||
|
|
||||||
// NIP-75 kind-9041 zap goal. A fundraising target that drives flotilla's goals
|
// NIP-75 kind-9041 zap goal.
|
||||||
// feature: the goal title lives in `content` as plain text (not JSON), the body
|
|
||||||
// in a "summary" tag, the target amount in an "amount" tag (millisats, parsed as
|
|
||||||
// an int defaulting to 0), and the relays to tally receipts from in repeated
|
|
||||||
// "relays" tags; room scoping is handled by the base `group` behavior tag.
|
|
||||||
// Non-addressable (referenced by event id via "#E"); the funding tally is
|
|
||||||
// computed elsewhere from sibling zap receipts (ZAP_RESPONSE) and is not modeled
|
|
||||||
// here. Tags + plain-text content, so it extends EventReader/EventBuilder.
|
|
||||||
export class ZapGoal extends EventReader {
|
export class ZapGoal extends EventReader {
|
||||||
readonly kind = ZAP_GOAL
|
readonly kind = ZAP_GOAL
|
||||||
|
|
||||||
// The goal title is plain-text content, not JSON or encrypted.
|
|
||||||
title() {
|
title() {
|
||||||
return this.event.content || ""
|
return this.event.content || ""
|
||||||
}
|
}
|
||||||
@@ -27,7 +19,7 @@ export class ZapGoal extends EventReader {
|
|||||||
return parseInt(getTagValue("amount", this.event.tags) || "0") || 0
|
return parseInt(getTagValue("amount", this.event.tags) || "0") || 0
|
||||||
}
|
}
|
||||||
|
|
||||||
relays() {
|
urls() {
|
||||||
return getTagValues("relays", this.event.tags)
|
return getTagValues("relays", this.event.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,20 +32,17 @@ export class ZapGoalBuilder extends EventBuilder<ZapGoal> {
|
|||||||
readonly kind = ZAP_GOAL
|
readonly kind = ZAP_GOAL
|
||||||
|
|
||||||
title = ""
|
title = ""
|
||||||
summary?: string
|
summaryTag?: string[]
|
||||||
amount = 0
|
amountTag?: string[]
|
||||||
relays: string[] = []
|
urlTags: string[][] = []
|
||||||
|
|
||||||
constructor(readonly reader?: ZapGoal) {
|
constructor(readonly reader?: ZapGoal) {
|
||||||
super(reader)
|
super(reader)
|
||||||
|
|
||||||
// Consume the represented tags out of the carried-over extraTags so they
|
|
||||||
// round-trip through the structured fields below rather than being emitted
|
|
||||||
// twice (once from buildTags, once from the base's extraTags pass-through).
|
|
||||||
this.title = reader?.title() ?? ""
|
this.title = reader?.title() ?? ""
|
||||||
this.summary = first(this.consumeTags("summary"))?.[1]
|
this.summaryTag = first(this.consumeTags("summary"))
|
||||||
this.amount = parseInt(first(this.consumeTags("amount"))?.[1] || "0") || 0
|
this.amountTag = first(this.consumeTags("amount"))
|
||||||
this.relays = this.consumeTags("relays").map(t => t[1])
|
this.urlTags = this.consumeTags("relays")
|
||||||
}
|
}
|
||||||
|
|
||||||
setTitle(title: string) {
|
setTitle(title: string) {
|
||||||
@@ -63,24 +52,26 @@ export class ZapGoalBuilder extends EventBuilder<ZapGoal> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSummary(summary: string) {
|
setSummary(summary: string) {
|
||||||
this.summary = summary
|
this.summaryTag = ["summary", summary]
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
setAmount(amount: number) {
|
setAmount(amount: number) {
|
||||||
this.amount = amount
|
this.amountTag = ["amount", String(amount)]
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
setRelays(relays: string[]) {
|
setUrls(urls: string[]) {
|
||||||
this.relays = relays
|
this.urlTags = urls.map(url => ["relays", url])
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
protected validate() {
|
protected validate() {
|
||||||
|
super.validate()
|
||||||
|
|
||||||
if (!this.title) {
|
if (!this.title) {
|
||||||
throw new Error("ZapGoal requires a title")
|
throw new Error("ZapGoal requires a title")
|
||||||
}
|
}
|
||||||
@@ -93,13 +84,10 @@ export class ZapGoalBuilder extends EventBuilder<ZapGoal> {
|
|||||||
protected buildTags() {
|
protected buildTags() {
|
||||||
const tags: string[][] = []
|
const tags: string[][] = []
|
||||||
|
|
||||||
if (this.summary) tags.push(["summary", this.summary])
|
if (this.summaryTag) tags.push(this.summaryTag)
|
||||||
|
|
||||||
tags.push(["amount", String(this.amount)])
|
tags.push(this.amountTag ?? ["amount", "0"])
|
||||||
|
tags.push(...this.urlTags)
|
||||||
for (const relay of this.relays) {
|
|
||||||
tags.push(["relays", relay])
|
|
||||||
}
|
|
||||||
|
|
||||||
return tags
|
return tags
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user