Compare commits

...

5 Commits

Author SHA1 Message Date
Jon Staab dfeb7a747b Simplify client plugin interfaces
tests / tests (push) Failing after 5m11s
2026-06-18 11:48:14 -07:00
Jon Staab eb451d795b Add domain package 2026-06-18 11:41:21 -07:00
Jon Staab 393c95e107 Clean up projections 2026-06-18 11:40:39 -07:00
Jon Staab 772895e3ab Tweak relay lists 2026-06-18 11:15:02 -07:00
Jon Staab fafa3b172e Move plugins to a plugin directory 2026-06-18 10:31:48 -07:00
46 changed files with 880 additions and 203 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ import {call} from "@welshman/lib"
import {Pool, Tracker, Repository, WrapManager} from "@welshman/net"
import type {NetContext, AdapterFactory} from "@welshman/net"
import type {User} from "./user.js"
import type {ClientPolicy} from "./policies.js"
import type {ClientPolicy} from "./policy.js"
export type ClientConfig = {
dufflepudUrl?: string
+1 -1
View File
@@ -1,6 +1,6 @@
import {Client} from "./client.js"
import type {ClientOptions} from "./client.js"
import {defaultClientPolicies} from "./policies.js"
import {defaultClientPolicies} from "./policy.js"
/**
* Creates a batteries-included client: a `Client` wired with the default client
+29 -29
View File
@@ -1,34 +1,34 @@
export * from "./client.js"
export * from "./policies.js"
export * from "./network.js"
export * from "./stores.js"
export * from "./clientData.js"
export * from "./policy.js"
export * from "./user.js"
export * from "./router.js"
export * from "./relays.js"
export * from "./relayStats.js"
export * from "./relayLists.js"
export * from "./blockedRelayLists.js"
export * from "./plaintext.js"
export * from "./profiles.js"
export * from "./follows.js"
export * from "./mutes.js"
export * from "./pins.js"
export * from "./blossom.js"
export * from "./messagingRelayLists.js"
export * from "./searchRelayLists.js"
export * from "./handles.js"
export * from "./zappers.js"
export * from "./topics.js"
export * from "./tags.js"
export * from "./session.js"
export * from "./logging.js"
export * from "./wot.js"
export * from "./feeds.js"
export * from "./search.js"
export * from "./sync.js"
export * from "./giftWraps.js"
export * from "./rooms.js"
export * from "./relayManagement.js"
export * from "./thunk.js"
export * from "./createApp.js"
export * from "./plugins/base.js"
export * from "./plugins/network.js"
export * from "./plugins/stores.js"
export * from "./plugins/router.js"
export * from "./plugins/relays.js"
export * from "./plugins/relayStats.js"
export * from "./plugins/relayLists.js"
export * from "./plugins/blockedRelayLists.js"
export * from "./plugins/plaintext.js"
export * from "./plugins/profiles.js"
export * from "./plugins/follows.js"
export * from "./plugins/mutes.js"
export * from "./plugins/pins.js"
export * from "./plugins/blossom.js"
export * from "./plugins/messagingRelayLists.js"
export * from "./plugins/searchRelayLists.js"
export * from "./plugins/handles.js"
export * from "./plugins/zappers.js"
export * from "./plugins/topics.js"
export * from "./plugins/tags.js"
export * from "./plugins/wot.js"
export * from "./plugins/feeds.js"
export * from "./plugins/search.js"
export * from "./plugins/sync.js"
export * from "./plugins/wraps.js"
export * from "./plugins/rooms.js"
export * from "./plugins/relayManagement.js"
export * from "./plugins/thunk.js"
@@ -1,10 +1,10 @@
import {writable} from "svelte/store"
import {writable, derived} from "svelte/store"
import type {Readable, Unsubscriber} from "svelte/store"
import type {Maybe} from "@welshman/lib"
import type {Filter} from "@welshman/util"
import {deriveItems, getter, makeDeriveItem, makeLoadItem, makeForceLoadItem} from "@welshman/store"
import type {EventToItem, ItemsByKey, MakeLoadItemOptions} from "@welshman/store"
import type {IClient} from "./client.js"
import type {IClient} from "../client.js"
import {Stores} from "./stores.js"
/**
@@ -17,6 +17,13 @@ export type Projection<T> = {
export const projection = <T>($: Readable<T>, get = getter($)) => ({$, get})
/**
* Build a `Projection` derived from another `Projection`: re-read `src`
* reactively via `.$` or synchronously via `.get()`.
*/
export const projectFrom = <S, U>(src: Projection<S>, read: ($: S) => U): Projection<U> =>
projection(derived(src.$, read), () => read(src.get()))
/**
* Base class for a reactive, keyed collection of "local" (non-event) data
* things like relay stats or NIP-11 profiles that aren't backed by the
@@ -26,7 +33,7 @@ export const projection = <T>($: Readable<T>, get = getter($)) => ({$, get})
* snapshot via `.get()`. Per-key access is `one(key)`, a plain on-demand store
* (snapshot with svelte's `get(...)`, or read `get(key)` directly).
*/
export class ClientData<T> {
export class MapPlugin<T> {
protected store = writable(new Map<string, T>())
index: Projection<ItemsByKey<T>>
all: Projection<T[]>
@@ -39,12 +46,11 @@ export class ClientData<T> {
this.one = makeDeriveItem(this.store)
}
keys = () => this.index.get().keys()
values = () => this.index.get().values()
get = (key: string) => this.index.get().get(key)
project = <U>(key: string, read: (item: Maybe<T>) => U): Projection<U> =>
projection(derived(this.one(key), read), () => read(this.get(key)))
set = (key: string, value: T) => {
this.store.update($items => {
$items.set(key, value)
@@ -93,11 +99,11 @@ export class ClientData<T> {
}
/**
* A `ClientData` collection that knows how to lazily load items by key from the
* A `MapPlugin` collection that knows how to lazily load items by key from the
* network. Subclasses implement `fetch`; `load`/`forceLoad`/`one` are derived
* from it (with per-key caching and backoff via `makeLoadItem`).
*/
export abstract class LoadableData<T> extends ClientData<T> {
export abstract class LoadableMapPlugin<T> extends MapPlugin<T> {
load: (key: string, ...args: any[]) => Promise<Maybe<T>>
forceLoad: (key: string, ...args: any[]) => Promise<Maybe<T>>
@@ -118,7 +124,7 @@ export abstract class LoadableData<T> extends ClientData<T> {
}
}
export type DerivedDataOptions<T> = {
export type DerivedPluginOptions<T> = {
filters: Filter[]
eventToItem: EventToItem<T>
getKey: (item: T) => string
@@ -135,7 +141,7 @@ export type DerivedDataOptions<T> = {
* `index` (map) and `all` (values) are `Projection`s subscribe via `.$`,
* snapshot via `.get()`. Per-key access is `one(key)`, a plain on-demand store.
*/
export abstract class DerivedData<T> {
export abstract class DerivedPlugin<T> {
index: Projection<ItemsByKey<T>>
all: Projection<T[]>
one: (key?: string, ...args: any[]) => Readable<Maybe<T>>
@@ -146,7 +152,7 @@ export abstract class DerivedData<T> {
constructor(
protected readonly ctx: IClient,
options: DerivedDataOptions<T>,
options: DerivedPluginOptions<T>,
) {
const index = ctx.use(Stores).itemsByKey<T>({
filters: options.filters,
@@ -165,9 +171,8 @@ export abstract class DerivedData<T> {
this.one = makeDeriveItem(index, this.load)
}
keys = () => this.index.get().keys()
values = () => this.index.get().values()
get = (key: string) => this.index.get().get(key)
project = <U>(key: string, read: (item: Maybe<T>) => U): Projection<U> =>
projection(derived(this.one(key), read), () => read(this.get(key)))
}
@@ -9,19 +9,20 @@ import {
removeFromList,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {DerivedData} from "./clientData.js"
import {DerivedPlugin} from "./base.js"
import type {Projection} from "./base.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 type {IClient} from "./client.js"
import type {IClient} from "../client.js"
/**
* Kind-10006 blocked-relay lists, keyed by pubkey. Loaded via the outbox model,
* so it depends on the relay-list collection. Feeds `RelayStats.getQuality` so
* blocked relays are never selected.
*/
export class BlockedRelayLists extends DerivedData<ReturnType<typeof readList>> {
export class BlockedRelayLists extends DerivedPlugin<ReturnType<typeof readList>> {
constructor(ctx: IClient) {
super(ctx, {
filters: [{kinds: [BLOCKED_RELAYS]}],
@@ -34,7 +35,8 @@ export class BlockedRelayLists extends DerivedData<ReturnType<typeof readList>>
return this.ctx.use(Network).loadUsingOutbox(pubkey, {kinds: [BLOCKED_RELAYS]}, relayHints)
}
getBlockedRelays = (pubkey: string) => getRelaysFromList(this.get(pubkey))
urls = (pubkey: string): Projection<string[]> =>
this.project(pubkey, list => getRelaysFromList(list))
addRelay = async (url: string) => {
const user = User.require(this.ctx)
@@ -1,14 +1,14 @@
import {BLOSSOM_SERVERS, asDecryptedEvent, readList} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {DerivedData} from "./clientData.js"
import {DerivedPlugin} from "./base.js"
import {Network} from "./network.js"
import type {IClient} from "./client.js"
import type {IClient} from "../client.js"
/**
* 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.
*/
export class BlossomServerLists extends DerivedData<ReturnType<typeof readList>> {
export class BlossomServerLists extends DerivedPlugin<ReturnType<typeof readList>> {
constructor(ctx: IClient) {
super(ctx, {
filters: [{kinds: [BLOSSOM_SERVERS]}],
@@ -1,7 +1,7 @@
import {Scope, FeedController} from "@welshman/feeds"
import type {FeedControllerOptions, Feed} from "@welshman/feeds"
import type {AdapterContext} from "@welshman/net"
import type {IClient} from "./client.js"
import type {IClient} from "../client.js"
import {Router} from "./router.js"
import {Wot} from "./wot.js"
@@ -7,17 +7,17 @@ import {
removeFromList,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {DerivedData} from "./clientData.js"
import {DerivedPlugin} from "./base.js"
import {Network} from "./network.js"
import {Thunks} from "./thunk.js"
import {User} from "./user.js"
import type {IClient} from "./client.js"
import {User} from "../user.js"
import type {IClient} from "../client.js"
/**
* 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.
*/
export class FollowLists extends DerivedData<ReturnType<typeof readList>> {
export class FollowLists extends DerivedPlugin<ReturnType<typeof readList>> {
constructor(ctx: IClient) {
super(ctx, {
filters: [{kinds: [FOLLOWS]}],
@@ -3,9 +3,9 @@ import type {Maybe} from "@welshman/lib"
import {queryProfile, displayNip05} from "@welshman/util"
import type {Handle} from "@welshman/util"
import {deriveDeduplicated} from "@welshman/store"
import {LoadableData, projection} from "./clientData.js"
import type {Projection} from "./clientData.js"
import type {IClient} from "./client.js"
import {LoadableMapPlugin, projection} from "./base.js"
import type {Projection} from "./base.js"
import type {IClient} from "../client.js"
import {Profiles} from "./profiles.js"
/**
@@ -15,7 +15,7 @@ import {Profiles} from "./profiles.js"
* user privacy). Depends on the profiles collection to resolve a pubkey's
* handle.
*/
export class Handles extends LoadableData<Handle> {
export class Handles extends LoadableMapPlugin<Handle> {
constructor(ctx: IClient) {
super(ctx)
}
@@ -2,25 +2,27 @@ import {
MESSAGING_RELAYS,
asDecryptedEvent,
readList,
getRelaysFromList,
makeList,
makeEvent,
addToListPublicly,
removeFromList,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {DerivedData} from "./clientData.js"
import {DerivedPlugin} from "./base.js"
import type {Projection} from "./base.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 type {IClient} from "./client.js"
import type {IClient} from "../client.js"
/**
* Kind-10050 messaging relay lists (NIP-17), keyed by pubkey. Loaded via the
* outbox model (the author's write relays), so it depends on the relay-list
* collection.
*/
export class MessagingRelayLists extends DerivedData<ReturnType<typeof readList>> {
export class MessagingRelayLists extends DerivedPlugin<ReturnType<typeof readList>> {
constructor(ctx: IClient) {
super(ctx, {
filters: [{kinds: [MESSAGING_RELAYS]}],
@@ -33,6 +35,9 @@ export class MessagingRelayLists extends DerivedData<ReturnType<typeof readList>
return this.ctx.use(Network).loadUsingOutbox(pubkey, {kinds: [MESSAGING_RELAYS]}, relayHints)
}
urls = (pubkey: string): Projection<string[]> =>
this.project(pubkey, list => getRelaysFromList(list))
addRelay = async (url: string) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: MESSAGING_RELAYS})
@@ -9,18 +9,18 @@ import {
updateList,
} from "@welshman/util"
import type {TrustedEvent, PublishedList} from "@welshman/util"
import {DerivedData} from "./clientData.js"
import type {IClient} from "./client.js"
import {DerivedPlugin} from "./base.js"
import type {IClient} from "../client.js"
import {Network} from "./network.js"
import {Thunks} from "./thunk.js"
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
* encrypted content, so decoding goes through the plaintext cache.
*/
export class MuteLists extends DerivedData<PublishedList> {
export class MuteLists extends DerivedPlugin<PublishedList> {
constructor(ctx: IClient) {
super(ctx, {
filters: [{kinds: [MUTES]}],
@@ -11,9 +11,10 @@ import type {
PullOptions,
PushOptions,
} from "@welshman/net"
import {Router, addMinimalFallbacks} from "./router.js"
import {addMinimalFallbacks} from "@welshman/router"
import {Router} from "./router.js"
import {RelayLists} from "./relayLists.js"
import type {IClient} from "./client.js"
import type {IClient} from "../client.js"
/**
* Net utilities bound to the client's net context (its pool + repository). Reach
@@ -7,17 +7,17 @@ import {
removeFromList,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {DerivedData} from "./clientData.js"
import {DerivedPlugin} from "./base.js"
import {Network} from "./network.js"
import {Thunks} from "./thunk.js"
import {User} from "./user.js"
import type {IClient} from "./client.js"
import {User} from "../user.js"
import type {IClient} from "../client.js"
/**
* 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.
*/
export class PinLists extends DerivedData<ReturnType<typeof readList>> {
export class PinLists extends DerivedPlugin<ReturnType<typeof readList>> {
constructor(ctx: IClient) {
super(ctx, {
filters: [{kinds: [PINS]}],
@@ -1,12 +1,12 @@
import {decrypt} from "@welshman/signer"
import type {Maybe} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {ClientData} from "./clientData.js"
import {MapPlugin} from "./base.js"
/**
* A cache of decrypted event content, keyed by event id.
*/
export class Plaintext extends ClientData<string> {
export class Plaintext extends MapPlugin<string> {
ensure = async (event: TrustedEvent): Promise<Maybe<string>> => {
if (this.ctx.user?.pubkey !== event.pubkey) return
@@ -10,18 +10,18 @@ import {
} from "@welshman/util"
import type {Profile} from "@welshman/util"
import type {Maybe} from "@welshman/lib"
import {DerivedData, projection} from "./clientData.js"
import type {Projection} from "./clientData.js"
import {DerivedPlugin, projection} from "./base.js"
import type {Projection} from "./base.js"
import {Network} from "./network.js"
import {Router} from "./router.js"
import {Thunks} from "./thunk.js"
import type {IClient} from "./client.js"
import type {IClient} from "../client.js"
/**
* 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.
*/
export class Profiles extends DerivedData<ReturnType<typeof readProfile>> {
export class Profiles extends DerivedPlugin<ReturnType<typeof readProfile>> {
constructor(ctx: IClient) {
super(ctx, {
filters: [{kinds: [PROFILE]}],
@@ -12,18 +12,20 @@ import {
makeEvent,
} from "@welshman/util"
import type {TrustedEvent, PublishedList} from "@welshman/util"
import {DerivedData} from "./clientData.js"
import {Router, addMinimalFallbacks} from "./router.js"
import {DerivedPlugin} from "./base.js"
import type {Projection} from "./base.js"
import {addMinimalFallbacks} from "@welshman/router"
import {Router} from "./router.js"
import {Network} from "./network.js"
import {User} from "./user.js"
import {User} from "../user.js"
import {Thunks} from "./thunk.js"
import type {IClient} from "./client.js"
import type {IClient} from "../client.js"
/**
* NIP-65 relay lists, keyed by pubkey. This is the routing substrate every other
* outbox-model load depends on (see `Network.loadUsingOutbox`).
*/
export class RelayLists extends DerivedData<PublishedList> {
export class RelayLists extends DerivedPlugin<PublishedList> {
constructor(ctx: IClient) {
super(ctx, {
filters: [{kinds: [RELAYS]}],
@@ -44,8 +46,14 @@ export class RelayLists extends DerivedData<PublishedList> {
])
}
getRelaysForPubkey = (pubkey: string, mode?: RelayMode) =>
getRelaysFromList(this.get(pubkey), mode)
urls = (pubkey: string): Projection<string[]> =>
this.project(pubkey, list => getRelaysFromList(list))
readUrls = (pubkey: string): Projection<string[]> =>
this.project(pubkey, list => getRelaysFromList(list, RelayMode.Read))
writeUrls = (pubkey: string): Projection<string[]> =>
this.project(pubkey, list => getRelaysFromList(list, RelayMode.Write))
// NIP-65 relay-list mutations for the client's user
@@ -1,7 +1,7 @@
import {makeHttpAuth, sendManagementRequest} from "@welshman/util"
import type {ManagementRequest} from "@welshman/util"
import {User} from "./user.js"
import type {IClient} from "./client.js"
import {User} from "../user.js"
import type {IClient} from "../client.js"
/**
* NIP-86 relay management. Signs an HTTP-auth event as the client's user and
@@ -2,7 +2,7 @@ import {groupBy, batch, now, uniq, ago, DAY, HOUR, MINUTE} from "@welshman/lib"
import {isOnionUrl, isLocalUrl, isIPAddress, isRelayUrl} from "@welshman/util"
import {SocketStatus, SocketEvent} from "@welshman/net"
import type {ClientMessage, RelayMessage, Socket} from "@welshman/net"
import {ClientData} from "./clientData.js"
import {MapPlugin} from "./base.js"
import {BlockedRelayLists} from "./blockedRelayLists.js"
export type RelayStatsUpdate = [string, (stats: RelayStatsItem) => void]
@@ -56,7 +56,7 @@ export const makeRelayStatsItem = (url: string): RelayStatsItem => ({
* the router uses to rank relays. A pure store the socket wiring that fills it
* lives in `clientPolicyRelayStats`.
*/
export class RelayStats extends ClientData<RelayStatsItem> {
export class RelayStats extends MapPlugin<RelayStatsItem> {
getQuality = (url: string) => {
// Skip non-relays entirely
if (!isRelayUrl(url)) return 0
@@ -64,7 +64,7 @@ export class RelayStats extends ClientData<RelayStatsItem> {
// Skip relays the user has blocked
const pubkey = this.ctx.user?.pubkey
if (pubkey && this.ctx.use(BlockedRelayLists).getBlockedRelays(pubkey).includes(url)) {
if (pubkey && this.ctx.use(BlockedRelayLists).urls(pubkey).get().includes(url)) {
return 0
}
@@ -1,16 +1,15 @@
import {derived} from "svelte/store"
import {fetchJson} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import {displayRelayUrl, displayRelayProfile} from "@welshman/util"
import type {RelayProfile} from "@welshman/util"
import {LoadableData, projection} from "./clientData.js"
import type {Projection} from "./clientData.js"
import {LoadableMapPlugin} from "./base.js"
import type {Projection} from "./base.js"
/**
* NIP-11 relay profiles, keyed by url. A "local" loadable collection: items
* aren't nostr events, they're fetched over HTTP from each relay.
*/
export class Relays extends LoadableData<RelayProfile> {
export class Relays extends LoadableMapPlugin<RelayProfile> {
fetch = async (url: string): Promise<Maybe<RelayProfile>> => {
try {
const json = await fetchJson(url.replace(/^ws/, "http"), {
@@ -37,9 +36,19 @@ export class Relays extends LoadableData<RelayProfile> {
}
}
display = (url: string): Projection<string> => {
const read = ($relay: Maybe<RelayProfile>) => displayRelayProfile($relay, displayRelayUrl(url))
display = (url: string): Projection<string> =>
this.project(url, $relay => displayRelayProfile($relay, displayRelayUrl(url)))
return projection(derived(this.one(url), read), () => read(this.get(url)))
hasNegentropy = async (url: string) => {
const relay = await this.load(url)
if (relay?.negentropy) return true
if (relay?.supported_nips?.includes("77")) return true
if (relay?.software?.includes?.("strfry") && !relay?.version?.match(/^0\./)) return true
return false
}
hasNip = async (url: string, nip: number | string) =>
(await this.load(url))?.supported_nips?.includes(String(nip)) ?? false
}
@@ -10,7 +10,7 @@ import {
import type {RoomMeta} from "@welshman/util"
import {Thunks} from "./thunk.js"
import type {ThunkOptions} from "./thunk.js"
import type {IClient} from "./client.js"
import type {IClient} from "../client.js"
/**
* NIP-29 relay-based group (room) management. Each method publishes the relevant
@@ -1,12 +1,8 @@
import {RelayMode} from "@welshman/util"
import {Router as BaseRouter} from "@welshman/router"
import {RelayLists} from "./relayLists.js"
import {RelayStats} from "./relayStats.js"
import type {IClient} from "./client.js"
// Re-export the upstream router surface (scenarios, fallback policies,
// makeSelection, getFilterSelections, types). The local `Router` below shadows
// the upstream `Router` in this re-export.
export * from "@welshman/router"
import type {IClient} from "../client.js"
/**
* The upstream `@welshman/router` Router, wired to this client: relay lists come
@@ -19,7 +15,11 @@ export class Router extends BaseRouter {
constructor(ctx: IClient) {
super({
getUserPubkey: () => ctx.user?.pubkey,
getPubkeyRelays: (pubkey, mode) => ctx.use(RelayLists).getRelaysForPubkey(pubkey, mode),
getPubkeyRelays: (pubkey, mode) =>
(mode === RelayMode.Read
? ctx.use(RelayLists).readUrls(pubkey)
: ctx.use(RelayLists).writeUrls(pubkey)
).get(),
getRelayQuality: url => ctx.use(RelayStats).getQuality(url),
getDefaultRelays: ctx.config.getDefaultRelays,
getIndexerRelays: ctx.config.getIndexerRelays,
@@ -7,7 +7,7 @@ import {dec, inc, sortBy} from "@welshman/lib"
import {PROFILE} from "@welshman/util"
import type {PublishedProfile, RelayProfile} from "@welshman/util"
import {throttled} from "@welshman/store"
import type {IClient} from "./client.js"
import type {IClient} from "../client.js"
import {Network} from "./network.js"
import {Router} from "./router.js"
import {Profiles} from "./profiles.js"
@@ -2,25 +2,27 @@ import {
SEARCH_RELAYS,
asDecryptedEvent,
readList,
getRelaysFromList,
makeList,
makeEvent,
addToListPublicly,
removeFromList,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {DerivedData} from "./clientData.js"
import {DerivedPlugin} from "./base.js"
import type {Projection} from "./base.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 type {IClient} from "./client.js"
import type {IClient} from "../client.js"
/**
* NIP-51 search relay lists (kind 10007), keyed by pubkey. Loaded via the
* outbox model (the author's write relays), so it depends on the relay-list
* collection.
*/
export class SearchRelayLists extends DerivedData<ReturnType<typeof readList>> {
export class SearchRelayLists extends DerivedPlugin<ReturnType<typeof readList>> {
constructor(ctx: IClient) {
super(ctx, {
filters: [{kinds: [SEARCH_RELAYS]}],
@@ -33,6 +35,9 @@ export class SearchRelayLists extends DerivedData<ReturnType<typeof readList>> {
return this.ctx.use(Network).loadUsingOutbox(pubkey, {kinds: [SEARCH_RELAYS]}, relayHints)
}
urls = (pubkey: string): Projection<string[]> =>
this.project(pubkey, list => getRelaysFromList(list))
addRelay = async (url: string) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: SEARCH_RELAYS})
@@ -18,7 +18,7 @@ import type {
ItemsByKeyOptions,
} from "@welshman/store"
import type {TrustedEvent} from "@welshman/util"
import type {IClient} from "./client.js"
import type {IClient} from "../client.js"
/**
* Store/derivation utilities bound to the client's repository and tracker. Reach
@@ -1,6 +1,6 @@
import {isSignedEvent} from "@welshman/util"
import type {Filter, SignedEvent} from "@welshman/util"
import type {IClient} from "./client.js"
import type {IClient} from "../client.js"
import {Network} from "./network.js"
import {Relays} from "./relays.js"
@@ -21,25 +21,17 @@ export class Sync {
query = (filters: Filter[]) =>
this.ctx.repository.query(filters, {shouldSort: filters.every(f => f.limit === undefined)})
hasNegentropy = (url: string) => {
const relay = this.ctx.use(Relays).get(url)
if (relay?.negentropy) return true
if (relay?.supported_nips?.includes?.("77")) return true
if (relay?.software?.includes?.("strfry") && !relay?.version?.match(/^0\./)) return true
return false
}
pull = async ({relays, filters}: AppSyncOpts) => {
const net = this.ctx.use(Network)
const events = this.query(filters).filter(isSignedEvent)
await Promise.all(
relays.map(async relay => {
await (this.hasNegentropy(relay)
? net.pull({filters, events, relays: [relay]})
: net.request({filters, relays: [relay], autoClose: true}))
if (await this.ctx.use(Relays).hasNegentropy(relay)) {
await net.pull({filters, events, relays: [relay]})
} else {
await net.request({filters, relays: [relay], autoClose: true})
}
}),
)
}
@@ -50,9 +42,11 @@ export class Sync {
await Promise.all(
relays.map(async relay => {
await (this.hasNegentropy(relay)
? net.push({filters, events, relays: [relay]})
: Promise.all(events.map((event: SignedEvent) => net.publish({event, relays: [relay]}))))
if (await this.ctx.use(Relays).hasNegentropy(relay)) {
await net.push({filters, events, relays: [relay]})
} else {
await Promise.all(events.map((event: SignedEvent) => net.publish({event, relays: [relay]})))
}
}),
)
}
@@ -10,7 +10,7 @@ import {
import type {TrustedEvent} from "@welshman/util"
import {Router} from "./router.js"
import {Profiles} from "./profiles.js"
import type {IClient} from "./client.js"
import type {IClient} from "../client.js"
/**
* Builders for nostr tags (p/e/a/q/zap/reply/comment/reaction). Needs the router
@@ -13,10 +13,11 @@ import {
} from "@welshman/util"
import {PublishStatus, PublishResult, PublishOptions, PublishResultsByRelay} from "@welshman/net"
import {Nip01Signer, Nip59} from "@welshman/signer"
import type {IClient} from "./client.js"
import type {IClient} from "../client.js"
import {Network} from "./network.js"
import {Router, addMinimalFallbacks} from "./router.js"
import {User} from "./user.js"
import {addMinimalFallbacks} from "@welshman/router"
import {Router} from "./router.js"
import {User} from "../user.js"
export type ThunkOptions = Override<
PublishOptions,
@@ -4,7 +4,7 @@ import {on} from "@welshman/lib"
import {getTopicTagValues} from "@welshman/util"
import type {RepositoryUpdate} from "@welshman/net"
import {deriveItems} from "@welshman/store"
import type {IClient} from "./client.js"
import type {IClient} from "../client.js"
export type Topic = {
name: string
@@ -2,9 +2,9 @@ import {readable, derived} from "svelte/store"
import {max, throttle, addToMapKey, inc, dec} from "@welshman/lib"
import {getListTags, getPubkeyTagValues} from "@welshman/util"
import type {List} from "@welshman/util"
import type {IClient} from "./client.js"
import {projection} from "./clientData.js"
import type {Projection} from "./clientData.js"
import type {IClient} from "../client.js"
import {projection, projectFrom} from "./base.js"
import type {Projection} from "./base.js"
import {FollowLists} from "./follows.js"
import {MuteLists} from "./mutes.js"
@@ -95,24 +95,14 @@ export class Wot {
this.max = projection(maxStore)
}
follows = (pubkey: string): Projection<string[]> => {
const read = ($lists: ReadonlyMap<string, List>) => listPubkeys($lists.get(pubkey))
follows = (pubkey: string): Projection<string[]> =>
projectFrom(this.ctx.use(FollowLists).index, $lists => listPubkeys($lists.get(pubkey)))
return projection(derived(this.ctx.use(FollowLists).index.$, read), () =>
read(this.ctx.use(FollowLists).index.get()),
)
}
mutes = (pubkey: string): Projection<string[]> =>
projectFrom(this.ctx.use(MuteLists).index, $lists => listPubkeys($lists.get(pubkey)))
mutes = (pubkey: string): Projection<string[]> => {
const read = ($lists: ReadonlyMap<string, List>) => listPubkeys($lists.get(pubkey))
return projection(derived(this.ctx.use(MuteLists).index.$, read), () =>
read(this.ctx.use(MuteLists).index.get()),
)
}
network = (pubkey: string): Projection<string[]> => {
const read = ($lists: ReadonlyMap<string, List>) => {
network = (pubkey: string): Projection<string[]> =>
projectFrom(this.ctx.use(FollowLists).index, $lists => {
const pubkeys = new Set(listPubkeys($lists.get(pubkey)))
const network = new Set<string>()
@@ -125,39 +115,20 @@ export class Wot {
}
return Array.from(network)
}
})
return projection(derived(this.ctx.use(FollowLists).index.$, read), () =>
read(this.ctx.use(FollowLists).index.get()),
)
}
followers = (pubkey: string): Projection<string[]> =>
projectFrom(this.followersByPubkey, $followers => Array.from($followers.get(pubkey) || []))
followers = (pubkey: string): Projection<string[]> => {
const read = ($followers: ReadonlyMap<string, Set<string>>) =>
Array.from($followers.get(pubkey) || [])
muters = (pubkey: string): Projection<string[]> =>
projectFrom(this.mutersByPubkey, $muters => Array.from($muters.get(pubkey) || []))
return projection(derived(this.followersByPubkey.$, read), () =>
read(this.followersByPubkey.get()),
)
}
muters = (pubkey: string): Projection<string[]> => {
const read = ($muters: ReadonlyMap<string, Set<string>>) =>
Array.from($muters.get(pubkey) || [])
return projection(derived(this.mutersByPubkey.$, read), () => read(this.mutersByPubkey.get()))
}
followsWhoFollow = (pubkey: string, target: string): Projection<string[]> => {
const read = ($lists: ReadonlyMap<string, List>) =>
followsWhoFollow = (pubkey: string, target: string): Projection<string[]> =>
projectFrom(this.ctx.use(FollowLists).index, $lists =>
listPubkeys($lists.get(pubkey)).filter(other =>
listPubkeys($lists.get(other)).includes(target),
)
return projection(derived(this.ctx.use(FollowLists).index.$, read), () =>
read(this.ctx.use(FollowLists).index.get()),
),
)
}
followsWhoMute = (pubkey: string, target: string): Projection<string[]> => {
const read = ($follows: ReadonlyMap<string, List>, $mutes: ReadonlyMap<string, List>) =>
@@ -5,9 +5,9 @@ import type {TrustedEvent, SignedEvent, EventTemplate} from "@welshman/util"
import {Nip59} from "@welshman/signer"
import {MergedThunk, Thunks} from "./thunk.js"
import type {ThunkOptions} from "./thunk.js"
import {User} from "./user.js"
import {User} from "../user.js"
import {MessagingRelayLists} from "./messagingRelayLists.js"
import type {IClient} from "./client.js"
import type {IClient} from "../client.js"
export type SendWrappedOptions = Omit<
ThunkOptions,
@@ -18,13 +18,13 @@ export type SendWrappedOptions = Omit<
}
/**
* Per-client gift-wrap (NIP-59) state: the unwrap queue plus failure/dedup
* Per-client wrap (NIP-59) state: the unwrap queue plus failure/dedup
* tracking. Scoped to `ctx.user`, so a client only ever unwraps its own user's
* messages into its own repository which is what keeps DM history from being
* merged across identities. The repository subscription that feeds it lives in
* `clientPolicyGiftWraps`.
* `clientPolicyWraps`.
*/
export class GiftWraps {
export class Wraps {
failedUnwraps = new Set<string>()
queue: TaskQueue<TrustedEvent>
@@ -12,9 +12,9 @@ import type {Maybe} from "@welshman/lib"
import {getTagValue, getZapSplits, zapFromEvent} from "@welshman/util"
import type {Zapper, Zap, TrustedEvent} from "@welshman/util"
import {deriveDeduplicated, deriveDeduplicatedByValue} from "@welshman/store"
import {LoadableData, projection} from "./clientData.js"
import type {Projection} from "./clientData.js"
import type {IClient} from "./client.js"
import {LoadableMapPlugin, projection} from "./base.js"
import type {Projection} from "./base.js"
import type {IClient} from "../client.js"
import {Profiles} from "./profiles.js"
/**
@@ -23,7 +23,7 @@ import {Profiles} from "./profiles.js"
* lnurl, or via a dufflepud proxy to protect user privacy). Depends on the
* profiles collection to resolve a pubkey's lnurl.
*/
export class Zappers extends LoadableData<Zapper> {
export class Zappers extends LoadableMapPlugin<Zapper> {
fetch = batcher(800, async (lnurls: string[]) => {
const result = new Map<string, Zapper>()
const valid = lnurls.filter(lnurl => lnurl.startsWith("lnurl1"))
@@ -5,9 +5,9 @@ import type {TrustedEvent} from "@welshman/util"
import {SocketEvent, isRelayEvent, makeSocketPolicyAuth} from "@welshman/net"
import type {RelayMessage, Socket} from "@welshman/net"
import type {IClient} from "./client.js"
import {RelayStats} from "./relayStats.js"
import {GiftWraps} from "./giftWraps.js"
import {BlockedRelayLists} from "./blockedRelayLists.js"
import {RelayStats} from "./plugins/relayStats.js"
import {Wraps} from "./plugins/wraps.js"
import {BlockedRelayLists} from "./plugins/blockedRelayLists.js"
import {LoggingSigner} from "./logging.js"
import type {LogMessage} from "./logging.js"
@@ -64,7 +64,8 @@ export const clientPolicyAuthUnlessBlocked = makeClientPolicyAuth((socket, clien
return !client
.use(BlockedRelayLists)
.getBlockedRelays(client.user.pubkey)
.urls(client.user.pubkey)
.get()
.includes(socket.url)
})
@@ -103,17 +104,17 @@ export const clientPolicyRelayStats: ClientPolicy = client => {
* Watches the client's repository for gift wraps (existing and incoming) and
* feeds them to the unwrap queue.
*/
export const clientPolicyGiftWraps: ClientPolicy = client => {
const giftWraps = client.use(GiftWraps)
export const clientPolicyWraps: ClientPolicy = client => {
const wraps = client.use(Wraps)
for (const wrap of client.repository.query([{kinds: [WRAP]}])) {
giftWraps.enqueue(wrap)
wraps.enqueue(wrap)
}
return on(client.repository, "update", ({added}: {added: TrustedEvent[]}) => {
for (const event of added) {
if (event.kind === WRAP) {
giftWraps.enqueue(event)
wraps.enqueue(event)
}
}
})
@@ -139,6 +140,6 @@ export const makeClientPolicyLogger =
export const defaultClientPolicies: ClientPolicy[] = [
clientPolicyIngest,
clientPolicyRelayStats,
clientPolicyGiftWraps,
clientPolicyWraps,
clientPolicyAuthUnlessBlocked,
]
+2
View File
@@ -0,0 +1,2 @@
build
__tests__
@@ -0,0 +1,91 @@
import {describe, it, expect} from "vitest"
import {makeSecret, MUTES, FOLLOWS, getPubkeyTagValues} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {MuteList} from "../src/MuteList"
const signer = new Nip01Signer(makeSecret())
const a = "aa".repeat(32)
const b = "bb".repeat(32)
const c = "cc".repeat(32)
describe("MuteList", () => {
it("round-trips public and private mutes through encryption", async () => {
const list = MuteList.make().addPublicly(a).addPrivately(b)
expect(list.pubkeys.sort()).toEqual([a, b].sort())
expect(list.includes(a)).toBe(true)
expect(list.includes(b)).toBe(true)
expect(list.includes(c)).toBe(false)
const event = await list.toEvent(signer)
expect(event.kind).toBe(MUTES)
expect(event.sig).toBeTruthy()
// Public entry is visible in tags; private entry is encrypted in content.
expect(getPubkeyTagValues(event.tags)).toEqual([a])
expect(event.content).not.toBe("")
// Re-parsing with a capable signer recovers the private entries.
const decrypted = await MuteList.parse(event, signer)
expect(decrypted.isDecrypted).toBe(true)
expect(decrypted.pubkeys.sort()).toEqual([a, b].sort())
// Parsing without a signer exposes only the public entries.
const publicOnly = await MuteList.parse(event)
expect(publicOnly.isDecrypted).toBe(false)
expect(publicOnly.pubkeys).toEqual([a])
})
it("removes from both public and private entries", async () => {
const list = MuteList.make().addPublicly(a).addPrivately(b)
list.remove(a)
list.remove(b)
expect(list.pubkeys).toEqual([])
})
it("preserves undecrypted ciphertext on pass-through serialization", async () => {
const event = await MuteList.make().addPrivately(b).toEvent(signer)
const undecrypted = await MuteList.parse(event)
// We never decrypted, so the original ciphertext must survive untouched.
const template = await undecrypted.getTemplate(signer)
expect(template.content).toBe(event.content)
})
it("refuses private mutation when undecrypted", async () => {
const event = await MuteList.make().addPrivately(b).toEvent(signer)
const undecrypted = await MuteList.parse(event)
expect(() => undecrypted.addPrivately(c)).toThrow()
})
it("toRumor encrypts but does not sign", async () => {
const rumor = await MuteList.make().addPrivately(b).toRumor(signer)
expect(rumor.id).toBeTruthy()
expect((rumor as TrustedEvent).sig).toBeUndefined()
expect(rumor.content).not.toBe("")
})
it("serializes to JSON", async () => {
const list = MuteList.make().addPublicly(a).addPrivately(b)
const json = JSON.parse(JSON.stringify(list))
expect(json.kind).toBe(MUTES)
expect(json.publicTags).toEqual([["p", a]])
expect(json.privateTags).toEqual([["p", b]])
})
it("throws on the wrong kind", async () => {
const event = {kind: FOLLOWS, tags: [], content: "", pubkey: a} as TrustedEvent
await expect(MuteList.parse(event)).rejects.toThrow()
})
})
+73
View File
@@ -0,0 +1,73 @@
import {describe, it, expect} from "vitest"
import {makeSecret, PROFILE, NOTE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {Profile, displayPubkey} from "../src/Profile"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: PROFILE,
tags: [],
content: "",
sig: "00".repeat(64),
...overrides,
}) as TrustedEvent
describe("Profile", () => {
it("parses and re-signs profile content", async () => {
const event = makeEvent({
content: JSON.stringify({name: "alice", about: "hi"}),
tags: [["alt", "profile"]],
})
const profile = Profile.parse(event)
expect(profile.values.name).toBe("alice")
expect(profile.hasName()).toBe(true)
expect(profile.display()).toBe("alice")
const signed = await profile.toEvent(signer)
expect(signed.kind).toBe(PROFILE)
expect(JSON.parse(signed.content).name).toBe("alice")
// Source tags are preserved.
expect(signed.tags).toEqual([["alt", "profile"]])
})
it("derives lnurl from a lud16 address", () => {
const profile = Profile.make({lud16: "alice@example.com"})
expect(profile.values.lnurl).toBeTruthy()
})
it("set merges and re-derives values", () => {
const profile = Profile.make({name: "alice"})
profile.set({about: "hello"})
expect(profile.values.name).toBe("alice")
expect(profile.values.about).toBe("hello")
})
it("display falls back to a shortened npub", () => {
const profile = Profile.parse(makeEvent({content: "{}"}))
expect(profile.display()).toBe(displayPubkey(pubkey))
})
it("serializes to JSON", () => {
const profile = Profile.make({name: "alice"})
expect(JSON.parse(JSON.stringify(profile))).toEqual({name: "alice"})
})
it("throws on the wrong kind", () => {
expect(() => Profile.parse(makeEvent({kind: NOTE}))).toThrow()
})
})
+36
View File
@@ -0,0 +1,36 @@
{
"name": "@welshman/domain",
"version": "0.8.16",
"author": "hodlbod",
"license": "MIT",
"description": "Stateless utilities for translating nostr events to and from domain objects.",
"publishConfig": {
"access": "public"
},
"type": "module",
"main": "dist/domain/src/index.js",
"types": "dist/domain/src/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "pnpm run clean && pnpm run compile --force",
"clean": "rimraf ./dist",
"compile": "tsc -b tsconfig.build.json",
"prepublishOnly": "pnpm run build"
},
"peerDependencies": {
"@welshman/lib": "workspace:*",
"@welshman/signer": "workspace:*",
"@welshman/util": "workspace:*",
"nostr-tools": "^2.19.4"
},
"devDependencies": {
"@welshman/lib": "workspace:*",
"@welshman/signer": "workspace:*",
"@welshman/util": "workspace:*",
"nostr-tools": "^2.19.4",
"rimraf": "~6.0.0",
"typescript": "~5.8.0"
}
}
+179
View File
@@ -0,0 +1,179 @@
import {nthEq, parseJson} from "@welshman/lib"
import {uniqTags} from "@welshman/util"
import type {EventTemplate, TrustedEvent} from "@welshman/util"
import {decrypt} from "@welshman/signer"
import type {ISigner} from "@welshman/signer"
import {DomainObject} from "./base.js"
const isValidTag = (tag: unknown): tag is string[] =>
Array.isArray(tag) && tag.length > 0 && tag.every(v => typeof v === "string")
export type DecryptedTags = {
privateTags: string[][]
// True when the private content was read (or there was none), false when we
// hold ciphertext we couldn't decrypt. See `EncryptableList.isDecrypted`.
decrypted: boolean
}
/**
* Read and decrypt the private tags stored in an event's content. Returns
* `decrypted: false` (and leaves `privateTags` empty) when there is encrypted
* content but no signer, or when decryption fails — in that case the original
* ciphertext is preserved verbatim on serialization.
*/
export const decryptListContent = async (
event: TrustedEvent,
signer?: ISigner,
): Promise<DecryptedTags> => {
// No private content to read.
if (!event.content) return {privateTags: [], decrypted: true}
// No signer to read it with — keep the ciphertext, mark it undecrypted.
if (!signer) return {privateTags: [], decrypted: false}
try {
const plaintext = await decrypt(signer, event.pubkey, event.content)
const privateTags = (parseJson(plaintext) || []).filter(isValidTag)
return {privateTags, decrypted: true}
} catch {
return {privateTags: [], decrypted: false}
}
}
export type EncryptableListParams = {
publicTags?: string[][]
privateTags?: string[][]
decrypted?: boolean
event?: TrustedEvent
}
/**
* Base class for replaceable lists that carry public entries in tags and
* private entries as an encrypted JSON array in content (NIP-51 style). The
* private entries are decrypted to plaintext on `parse` and re-encrypted on
* `getTemplate`, so all in-between reads and writes are synchronous.
*
* Subclasses fix the `kind` and add domain-specific accessors (see
* `MuteList`). The generic tag mechanics live here.
*/
export abstract class EncryptableList extends DomainObject {
abstract readonly kind: number
publicTags: string[][]
privateTags: string[][]
readonly event?: TrustedEvent
// Whether `privateTags` reflects the real (decrypted) private content. False
// means we're holding ciphertext we couldn't read, so private entries are
// unknown and must not be mutated.
protected decrypted: boolean
constructor({
publicTags = [],
privateTags = [],
decrypted = true,
event,
}: EncryptableListParams = {}) {
super()
this.publicTags = publicTags
this.privateTags = privateTags
this.decrypted = decrypted
this.event = event
}
/**
* Whether the private entries were successfully decrypted (or there were
* none). When false, only public entries are available and private mutations
* throw.
*/
get isDecrypted() {
return this.decrypted
}
/** All entries, merging public and (when decrypted) private tags. */
getTags() {
return [...this.publicTags, ...this.privateTags]
}
getPublicTags() {
return this.publicTags
}
getPrivateTags() {
return this.privateTags
}
/** Add one or more tags to the public (cleartext) entries. */
addPublicTags(...tags: string[][]) {
this.publicTags = uniqTags([...this.publicTags, ...tags])
return this
}
/** Add one or more tags to the private (encrypted) entries. */
addPrivateTags(...tags: string[][]) {
this.assertDecrypted()
this.privateTags = uniqTags([...this.privateTags, ...tags])
return this
}
/** Remove every tag matching `pred` from both public and private entries. */
removeTagsBy(pred: (tag: string[]) => boolean) {
this.publicTags = this.publicTags.filter(t => !pred(t))
if (this.decrypted) {
this.privateTags = this.privateTags.filter(t => !pred(t))
}
return this
}
/** Remove every tag whose value (index 1) equals `value`, public or private. */
removeTagsByValue(value: string) {
return this.removeTagsBy(nthEq(1, value))
}
protected assertDecrypted() {
if (!this.decrypted) {
throw new Error("Cannot modify the private entries of a list that has not been decrypted")
}
}
async getTemplate(signer?: ISigner): Promise<EventTemplate> {
const tags = this.publicTags
// Preserve the original ciphertext when we never decrypted it, so a
// pass-through round trip doesn't destroy private entries we can't read.
let content = this.event?.content || ""
if (this.decrypted) {
if (this.privateTags.length === 0) {
content = ""
} else {
if (!signer) {
throw new Error("A signer is required to encrypt the private entries of a list")
}
const pubkey = await signer.getPubkey()
content = await signer.nip44.encrypt(pubkey, JSON.stringify(this.privateTags))
}
}
return {kind: this.kind, tags, content}
}
toJSON() {
return {
kind: this.kind,
publicTags: this.publicTags,
privateTags: this.privateTags,
decrypted: this.decrypted,
event: this.event,
}
}
}
+66
View File
@@ -0,0 +1,66 @@
import {uniq} from "@welshman/lib"
import {MUTES, getPubkeyTagValues} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import type {ISigner} from "@welshman/signer"
import {EncryptableList, decryptListContent} from "./List.js"
/**
* A NIP-51 kind-10000 mute list. Pubkeys can be muted publicly (in tags) or
* privately (in encrypted content); the convenience accessors here treat both
* as one merged set.
*
* @example
* const mutes = await MuteList.parse(event, signer) // decrypts if able
* mutes.addPrivately(pubkey1)
* mutes.remove(pubkey2) // public and private
* mutes.pubkeys // merged
* mutes.includes(pubkey)
* const signed = await mutes.toEvent(signer) // encrypts + signs
*/
export class MuteList extends EncryptableList {
readonly kind = MUTES
/** Create an empty, decrypted mute list (e.g. for a user with none yet). */
static make() {
return new MuteList()
}
/**
* Parse a kind-10000 event into a `MuteList`, decrypting its private entries
* when a capable signer is supplied. Throws on the wrong kind.
*/
static async parse(event: TrustedEvent, signer?: ISigner) {
if (event.kind !== MUTES) {
throw new Error(`Expected a kind ${MUTES} event, got kind ${event.kind}`)
}
const {privateTags, decrypted} = await decryptListContent(event, signer)
return new MuteList({event, publicTags: event.tags, privateTags, decrypted})
}
/** The muted pubkeys, merging public and (when decrypted) private entries. */
get pubkeys() {
return uniq(getPubkeyTagValues(this.getTags()))
}
/** Whether `pubkey` is muted, publicly or privately. */
includes(pubkey: string) {
return this.pubkeys.includes(pubkey)
}
/** Mute a pubkey publicly (visible to anyone who reads the event). */
addPublicly(pubkey: string) {
return this.addPublicTags(["p", pubkey])
}
/** Mute a pubkey privately (stored in encrypted content). */
addPrivately(pubkey: string) {
return this.addPrivateTags(["p", pubkey])
}
/** Unmute a pubkey, removing it from both public and private entries. */
remove(pubkey: string) {
return this.removeTagsByValue(pubkey)
}
}
+117
View File
@@ -0,0 +1,117 @@
import {npubEncode} from "nostr-tools/nip19"
import {ellipsize, parseJson} from "@welshman/lib"
import {PROFILE, getLnUrl} from "@welshman/util"
import type {EventTemplate, TrustedEvent} from "@welshman/util"
import {DomainObject} from "./base.js"
export type ProfileValues = {
name?: string
nip05?: string
lud06?: string
lud16?: string
lnurl?: string
about?: string
banner?: string
picture?: string
website?: string
display_name?: string
}
/**
* Normalize raw profile values, deriving `lnurl` from a `lud06` or `lud16`
* address when present.
*/
export const makeProfileValues = (values: Partial<ProfileValues> = {}): ProfileValues => {
const result: ProfileValues = {...values}
for (const key of ["lud06", "lud16"] as const) {
if (typeof result[key] === "string") {
const lnurl = getLnUrl(result[key]!)
if (lnurl) {
result.lnurl = lnurl
}
}
}
return result
}
export const displayPubkey = (pubkey: string) => {
const d = npubEncode(pubkey)
return d.slice(0, 8) + "…" + d.slice(-5)
}
/**
* A kind-0 profile. Profile data lives unencrypted in the event content as
* JSON, so this is the simplest kind of domain object — `getTemplate` ignores
* the signer and only `toEvent`/`toRumor` need one (to sign).
*
* @example
* const profile = Profile.parse(event)
* profile.set({about: "hello"})
* const signed = await profile.toEvent(signer)
*/
export class Profile extends DomainObject {
readonly kind = PROFILE
readonly event?: TrustedEvent
values: ProfileValues
constructor(values: Partial<ProfileValues> = {}, event?: TrustedEvent) {
super()
this.values = makeProfileValues(values)
this.event = event
}
static make(values: Partial<ProfileValues> = {}) {
return new Profile(values)
}
/** Parse a kind-0 event into a `Profile`. Throws on the wrong kind. */
static parse(event: TrustedEvent) {
if (event.kind !== PROFILE) {
throw new Error(`Expected a kind ${PROFILE} event, got kind ${event.kind}`)
}
return new Profile(parseJson(event.content) || {}, event)
}
/** Merge `updates` into the profile values, re-deriving `lnurl` as needed. */
set(updates: Partial<ProfileValues>) {
this.values = makeProfileValues({...this.values, ...updates})
return this
}
/** Whether the profile has a display-worthy name. */
hasName() {
return Boolean(this.values.name || this.values.display_name)
}
/** A human-readable label, falling back to a shortened npub, then `fallback`. */
display(fallback = "") {
const {name, display_name} = this.values
if (name) return ellipsize(name, 60).trim()
if (display_name) return ellipsize(display_name, 60).trim()
if (this.event) return displayPubkey(this.event.pubkey).trim()
return fallback.trim()
}
async getTemplate(): Promise<EventTemplate> {
return {
kind: PROFILE,
content: JSON.stringify(this.values),
// Preserve any tags from the source event (e.g. nip05/relay hints).
tags: this.event?.tags || [],
}
}
toJSON() {
return {...this.values}
}
}
+64
View File
@@ -0,0 +1,64 @@
import {stamp, prep} from "@welshman/util"
import type {EventTemplate, SignedEvent, HashedEvent, TrustedEvent} from "@welshman/util"
import type {ISigner} from "@welshman/signer"
/**
* The base class for domain objects.
*
* A domain object is an in-memory, mutable, JSON-serializable view of a single
* nostr event. The pattern is "decrypt on parse, mutate in memory, encrypt on
* serialize": concrete subclasses decrypt private content up front (in their
* static `parse`), expose synchronous accessors and mutators over the
* plaintext, and only touch the signer again when building an event.
*
* Subclasses provide:
*
* - a static `parse(event, signer?)` that reads (and, when possible, decrypts)
* an event into a domain object
* - `getTemplate(signer?)` that builds (and, when needed, encrypts) the event
* template — the signer is optional for objects with no private content
* - `toJSON()` for plain-object serialization
*
* The base provides the shared signing/wrapping orchestration on top of
* `getTemplate`.
*/
export abstract class DomainObject {
/**
* The source event, present when this object was parsed from one and absent
* when it was freshly constructed.
*/
abstract readonly event?: TrustedEvent
/**
* Build the event template for this object, encrypting any private content
* with the signer. Subclasses that hold no private data may ignore the
* signer (which is why it is optional).
*/
abstract getTemplate(signer?: ISigner): Promise<EventTemplate>
/**
* A plain-JSON snapshot of this object — safe for storage, `structuredClone`,
* or `postMessage`. Also lets `JSON.stringify` work transparently.
*/
abstract toJSON(): object
/**
* Build a hashed-but-unsigned rumor (the inner event of a NIP-59 gift wrap),
* encrypting private content as needed. A fresh `created_at` is stamped.
*/
async toRumor(signer: ISigner): Promise<HashedEvent> {
const [template, pubkey] = await Promise.all([this.getTemplate(signer), signer.getPubkey()])
return prep(template, pubkey)
}
/**
* Build and sign a full event, encrypting private content as needed. A fresh
* `created_at` is stamped so the result supersedes any prior version.
*/
async toEvent(signer: ISigner): Promise<SignedEvent> {
const template = await this.getTemplate(signer)
return signer.sign(stamp(template))
}
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./base.js"
export * from "./List.js"
export * from "./MuteList.js"
export * from "./Profile.js"
+17
View File
@@ -0,0 +1,17 @@
{
"extends": "../../tsconfig.build.json",
"compilerOptions": {
"outDir": "./dist",
"paths": {
"@welshman/lib": ["../lib/src/index.js"],
"@welshman/util": ["../util/src/index.js"],
"@welshman/net": ["../net/src/index.js"],
"@welshman/signer": ["../signer/src/index.js"]
}
},
"include": [
"src/**/*"
]
}
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}
+21
View File
@@ -172,6 +172,27 @@ importers:
specifier: ~5.8.0
version: 5.8.2
packages/domain:
devDependencies:
'@welshman/lib':
specifier: workspace:*
version: link:../lib
'@welshman/signer':
specifier: workspace:*
version: link:../signer
'@welshman/util':
specifier: workspace:*
version: link:../util
nostr-tools:
specifier: ^2.19.4
version: 2.19.4(typescript@5.8.2)
rimraf:
specifier: ~6.0.0
version: 6.0.1
typescript:
specifier: ~5.8.0
version: 5.8.2
packages/editor:
dependencies:
'@tiptap/core':
+1
View File
@@ -21,6 +21,7 @@
"references": [
{"path": "./packages/app/tsconfig.build.json"},
{"path": "./packages/content/tsconfig.build.json"},
{"path": "./packages/domain/tsconfig.build.json"},
{"path": "./packages/dvm/tsconfig.build.json"},
{"path": "./packages/editor/tsconfig.build.json"},
{"path": "./packages/feeds/tsconfig.build.json"},
+1
View File
@@ -15,6 +15,7 @@ export default defineConfig({
alias: {
"@welshman/app": resolve(__dirname, "packages/app/src"),
"@welshman/content": resolve(__dirname, "packages/content/src"),
"@welshman/domain": resolve(__dirname, "packages/domain/src"),
"@welshman/feeds": resolve(__dirname, "packages/feeds/src"),
"@welshman/lib": resolve(__dirname, "packages/lib/src"),
"@welshman/net": resolve(__dirname, "packages/net/src"),