Compare commits

...

10 Commits

Author SHA1 Message Date
Jon Staab 72ab746254 Add projection utility and type
tests / tests (push) Failing after 5m56s
2026-06-18 10:15:28 -07:00
Jon Staab aae201414d Clean up store semantics 2026-06-18 08:25:23 -07:00
Jon Staab f5124a6c4e Clean up wot 2026-06-17 14:56:39 -07:00
Jon Staab 28219eb64f Small fixes, rework zaps 2026-06-17 10:36:00 -07:00
Jon Staab bc728c680e Rework thunks 2026-06-16 17:07:20 -07:00
Jon Staab abb9f20747 Split up commands and add them to domain modules 2026-06-16 16:34:43 -07:00
Jon Staab 163d2dc355 Tweak outbox loader 2026-06-16 15:00:18 -07:00
Jon Staab 9094d30b89 Add socket policy for authenticating unless blocked 2026-06-16 14:44:33 -07:00
Jon Staab f8130da2bb Rework sessions 2026-06-16 14:07:15 -07:00
Jon Staab 2e12010e26 rework client auth 2026-06-16 13:06:29 -07:00
44 changed files with 1421 additions and 1331 deletions
+1
View File
@@ -2,4 +2,5 @@ node_modules
docs
docs/reference
docs/.vitepress/cache
dist
build
+39 -5
View File
@@ -1,7 +1,19 @@
import {BLOCKED_RELAYS, asDecryptedEvent, readList, getRelaysFromList} from "@welshman/util"
import {
BLOCKED_RELAYS,
asDecryptedEvent,
readList,
getRelaysFromList,
makeList,
makeEvent,
addToListPublicly,
removeFromList,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js"
import {RelayLists} from "./relayLists.js"
import {DerivedData} from "./clientData.js"
import {Network} from "./network.js"
import {Router} from "./router.js"
import {User} from "./user.js"
import {Thunks} from "./thunk.js"
import type {IClient} from "./client.js"
/**
@@ -9,7 +21,7 @@ import type {IClient} from "./client.js"
* so it depends on the relay-list collection. Feeds `RelayStats.getQuality` so
* blocked relays are never selected.
*/
export class BlockedRelayLists extends RepositoryCollection<ReturnType<typeof readList>> {
export class BlockedRelayLists extends DerivedData<ReturnType<typeof readList>> {
constructor(ctx: IClient) {
super(ctx, {
filters: [{kinds: [BLOCKED_RELAYS]}],
@@ -19,8 +31,30 @@ export class BlockedRelayLists extends RepositoryCollection<ReturnType<typeof re
}
fetch(pubkey: string, relayHints: string[] = []) {
return this.ctx.use(RelayLists).makeOutboxLoader(BLOCKED_RELAYS)(pubkey, relayHints)
return this.ctx.use(Network).loadUsingOutbox(pubkey, {kinds: [BLOCKED_RELAYS]}, relayHints)
}
getBlockedRelays = (pubkey: string) => getRelaysFromList(this.get(pubkey))
addRelay = async (url: string) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: BLOCKED_RELAYS})
const event = await addToListPublicly(list, ["relay", url]).reconcile(user.nip44EncryptToSelf)
return this.ctx.use(Thunks).publishToOutbox({event})
}
removeRelay = async (url: string) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: BLOCKED_RELAYS})
const event = await removeFromList(list, url).reconcile(user.nip44EncryptToSelf)
return this.ctx.use(Thunks).publishToOutbox({event})
}
setRelays = (urls: string[]) =>
this.ctx.use(Thunks).publish({
event: makeEvent(BLOCKED_RELAYS, {tags: urls.map(url => ["relay", url])}),
relays: this.ctx.use(Router).FromUser().getUrls(),
})
}
+4 -4
View File
@@ -1,14 +1,14 @@
import {BLOSSOM_SERVERS, asDecryptedEvent, readList} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js"
import {RelayLists} from "./relayLists.js"
import {DerivedData} from "./clientData.js"
import {Network} from "./network.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 RepositoryCollection<ReturnType<typeof readList>> {
export class BlossomServerLists extends DerivedData<ReturnType<typeof readList>> {
constructor(ctx: IClient) {
super(ctx, {
filters: [{kinds: [BLOSSOM_SERVERS]}],
@@ -18,6 +18,6 @@ export class BlossomServerLists extends RepositoryCollection<ReturnType<typeof r
}
fetch(pubkey: string, relayHints: string[] = []) {
return this.ctx.use(RelayLists).makeOutboxLoader(BLOSSOM_SERVERS)(pubkey, relayHints)
return this.ctx.use(Network).loadUsingOutbox(pubkey, {kinds: [BLOSSOM_SERVERS]}, relayHints)
}
}
+8 -22
View File
@@ -1,7 +1,7 @@
import type {Unsubscriber} from "svelte/store"
import {call} from "@welshman/lib"
import {Pool, Socket, Tracker, Repository, WrapManager, defaultSocketPolicies} from "@welshman/net"
import type {NetContext, AdapterFactory, SocketPolicy} from "@welshman/net"
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"
@@ -16,7 +16,6 @@ export type ClientOptions = {
user?: User
config?: ClientConfig
getAdapter?: AdapterFactory
socketPolicies?: SocketPolicy[]
policies?: ClientPolicy[]
}
@@ -46,26 +45,13 @@ export class Client implements IClient {
repository: Repository
wrapManager: WrapManager
// Per-client singletons of data modules, keyed by constructor. Owned by the
// client (so it's GC'd with the client — no WeakMap needed), this is what
// `use` memoizes against.
private singletons = new Map<Function, unknown>()
private policyCleanups: Unsubscriber[] = []
private unsubscribers: Unsubscriber[] = []
constructor(options: ClientOptions = {}) {
this.user = options.user
this.config = options.config ?? {}
this.pool = new Pool({
makeSocket: (url: string) => {
let socketPolicies = options.socketPolicies ?? defaultSocketPolicies
if (this.user) {
socketPolicies = [...socketPolicies, this.user.makeSocketPolicyAuth()]
}
return new Socket(url, socketPolicies)
},
})
this.pool = new Pool()
this.tracker = new Tracker()
this.repository = new Repository()
this.wrapManager = new WrapManager({
@@ -78,9 +64,9 @@ export class Client implements IClient {
getAdapter: options.getAdapter,
}
// Apply policies last, once the primitives and `use` registry exist. They
// own all side effects; their cleanups run on `cleanup()`.
this.policyCleanups = (options.policies ?? []).map(policy => policy(this))
for (const policy of options.policies ?? []) {
this.unsubscribers.push(policy(this))
}
}
// Resolve the per-client singleton of a data module, constructing it on first
@@ -97,7 +83,7 @@ export class Client implements IClient {
}
cleanup() {
this.policyCleanups.forEach(call)
this.unsubscribers.forEach(call)
this.pool.clear()
this.tracker.clear()
this.repository.clear()
+96 -30
View File
@@ -1,41 +1,52 @@
import {writable} from "svelte/store"
import type {Readable, Unsubscriber} from "svelte/store"
import type {Maybe} from "@welshman/lib"
import {getter, makeDeriveItem, makeLoadItem, makeForceLoadItem} from "@welshman/store"
import type {MakeLoadItemOptions} from "@welshman/store"
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 {Stores} from "./stores.js"
/**
* Utility type which allows for using the same value both for hot gets and derived subscriptions
*/
export type Projection<T> = {
get: () => T
$: Readable<T>
}
export const projection = <T>($: Readable<T>, get = getter($)) => ({$, 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
* repository. The collection owns its own map and is its own Svelte store: its
* `subscribe` emits the underlying `Map`.
* repository. The collection owns its own map.
*
* Subclasses reach the client through the `IClient` seam, never the
* concrete `Client`, so they never create a dependency cycle.
* `index` (map) and `all` (values) are `Projection`s — subscribe via `.$`,
* 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> {
protected index = writable(new Map<string, T>())
protected getIndex = getter(this.index)
protected itemSubscribers: ((key: string, value: Maybe<T>) => void)[] = []
public derive: (key?: string, ...args: any[]) => Readable<Maybe<T>>
protected store = writable(new Map<string, T>())
index: Projection<ItemsByKey<T>>
all: Projection<T[]>
one: (key?: string, ...args: any[]) => Readable<Maybe<T>>
subs: ((key: string, value: Maybe<T>) => void)[] = []
constructor(protected readonly ctx: IClient) {
this.derive = makeDeriveItem(this.index)
this.index = projection(this.store)
this.all = projection(deriveItems(this.store))
this.one = makeDeriveItem(this.store)
}
subscribe = this.index.subscribe
keys = () => this.index.get().keys()
get = (key: string): Maybe<T> => this.getIndex().get(key)
values = () => this.index.get().values()
getAll = (): T[] => Array.from(this.getIndex().values())
keys = () => this.getIndex().keys()
values = () => this.getIndex().values()
get = (key: string) => this.index.get().get(key)
set = (key: string, value: T) => {
this.index.update($items => {
this.store.update($items => {
$items.set(key, value)
return $items
@@ -45,7 +56,7 @@ export class ClientData<T> {
}
delete = (key: string) => {
this.index.update($items => {
this.store.update($items => {
$items.delete(key)
return $items
@@ -55,9 +66,9 @@ export class ClientData<T> {
}
clear = () => {
const keys = Array.from(this.getIndex().keys())
const keys = Array.from(this.index.get().keys())
this.index.set(new Map())
this.store.set(new Map())
for (const key of keys) {
this.emitItem(key, undefined)
@@ -65,17 +76,17 @@ export class ClientData<T> {
}
onItem = (subscriber: (key: string, value: Maybe<T>) => void): Unsubscriber => {
this.itemSubscribers.push(subscriber)
this.subs.push(subscriber)
return () => {
const i = this.itemSubscribers.indexOf(subscriber)
const i = this.subs.indexOf(subscriber)
if (i !== -1) this.itemSubscribers.splice(i, 1)
if (i !== -1) this.subs.splice(i, 1)
}
}
protected emitItem = (key: string, value: Maybe<T>) => {
for (const subscriber of this.itemSubscribers) {
for (const subscriber of this.subs) {
subscriber(key, value)
}
}
@@ -83,7 +94,7 @@ export class ClientData<T> {
/**
* A `ClientData` collection that knows how to lazily load items by key from the
* network. Subclasses implement `fetch`; `load`/`forceLoad`/`derive` are derived
* 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> {
@@ -99,9 +110,64 @@ export abstract class LoadableData<T> extends ClientData<T> {
// *after* super() — so `this.fetch` is undefined here. makeLoadItem captures
// its loadItem eagerly, so we defer the lookup to call time via this wrapper.
const fetch = (key: string, ...args: any[]) => this.fetch(key, ...args)
const read = (key: string) => this.index.get().get(key)
this.load = makeLoadItem(fetch, this.get, options)
this.forceLoad = makeForceLoadItem(fetch, this.get)
this.derive = makeDeriveItem(this.index, this.load)
this.load = makeLoadItem(fetch, read, options)
this.forceLoad = makeForceLoadItem(fetch, read)
this.one = makeDeriveItem(this.store, this.load)
}
}
export type DerivedDataOptions<T> = {
filters: Filter[]
eventToItem: EventToItem<T>
getKey: (item: T) => string
loadOptions?: MakeLoadItemOptions
}
/**
* Base class for a reactive, keyed collection of data derived from nostr events.
* The repository is the single source of truth — the collection is a live view
* over `ctx.itemsByKey`, never a duplicated map. Subclasses implement `fetch`
* (how to load an item by key from the network) and pass the filters/decoder via
* `super`.
*
* `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> {
index: Projection<ItemsByKey<T>>
all: Projection<T[]>
one: (key?: string, ...args: any[]) => Readable<Maybe<T>>
load: (key: string, ...args: any[]) => Promise<Maybe<T>>
forceLoad: (key: string, ...args: any[]) => Promise<Maybe<T>>
abstract fetch(key: string, ...args: any[]): Promise<unknown>
constructor(
protected readonly ctx: IClient,
options: DerivedDataOptions<T>,
) {
const index = ctx.use(Stores).itemsByKey<T>({
filters: options.filters,
eventToItem: options.eventToItem,
getKey: options.getKey,
})
this.index = projection(index)
this.all = projection(deriveItems(index))
const fetch = (key: string, ...args: any[]) => this.fetch(key, ...args)
const read = (key: string) => this.index.get().get(key)
this.load = makeLoadItem(fetch, read, options.loadOptions)
this.forceLoad = makeForceLoadItem(fetch, read)
this.one = makeDeriveItem(index, this.load)
}
keys = () => this.index.get().keys()
values = () => this.index.get().values()
get = (key: string) => this.index.get().get(key)
}
-382
View File
@@ -1,382 +0,0 @@
import {uniq, reject, nth, now, nthNe, removeUndefined, nthEq} from "@welshman/lib"
import {
sendManagementRequest,
addToListPublicly,
addToListPrivately,
updateList,
removeFromList,
makeHttpAuth,
getListTags,
getRelayTags,
getRelayTagValues,
getRelaysFromList,
makeList,
makeRoomCreateEvent,
makeRoomDeleteEvent,
makeRoomEditEvent,
makeRoomJoinEvent,
makeRoomLeaveEvent,
makeRoomAddMemberEvent,
makeRoomRemoveMemberEvent,
isPublishedProfile,
createProfile,
editProfile,
RelayMode,
makeEvent,
MESSAGING_RELAYS,
BLOCKED_RELAYS,
SEARCH_RELAYS,
FOLLOWS,
RELAYS,
MUTES,
PINS,
prep,
} from "@welshman/util"
import type {ManagementRequest, EventTemplate, RoomMeta, Profile} from "@welshman/util"
import {addMaximalFallbacks, Router} from "./router.js"
import {MergedThunk, publishThunk} from "./thunk.js"
import type {ThunkOptions} from "./thunk.js"
import type {IClient} from "./client.js"
import type {User} from "./user.js"
import {RelayLists} from "./relayLists.js"
import {MessagingRelayLists} from "./messagingRelayLists.js"
import {BlockedRelayLists} from "./blockedRelayLists.js"
import {SearchRelayLists} from "./searchRelayLists.js"
import {FollowLists} from "./follows.js"
import {MuteLists} from "./mutes.js"
import {PinLists} from "./pins.js"
export type SendWrappedOptions = Omit<
ThunkOptions,
"event" | "relays" | "recipient" | "client" | "user"
> & {
event: EventTemplate
recipients: string[]
}
/**
* The high-level "do an action" API: each method builds an event for the
* client's user and publishes it via a thunk. Siblings (the user's lists, the
* router) are resolved lazily through `ctx.use`; the acting user is `ctx.user`.
*/
export class Commands {
constructor(readonly ctx: IClient) {}
private get user(): User {
if (!this.ctx.user) {
throw new Error("Commands require a signed-in user")
}
return this.ctx.user
}
private get router() {
return this.ctx.use(Router)
}
private get relayLists() {
return this.ctx.use(RelayLists)
}
private get messagingRelayLists() {
return this.ctx.use(MessagingRelayLists)
}
private get blockedRelayLists() {
return this.ctx.use(BlockedRelayLists)
}
private get searchRelayLists() {
return this.ctx.use(SearchRelayLists)
}
private get followLists() {
return this.ctx.use(FollowLists)
}
private get muteLists() {
return this.ctx.use(MuteLists)
}
private get pinLists() {
return this.ctx.use(PinLists)
}
private publish = (options: Omit<ThunkOptions, "client" | "user">) =>
publishThunk({...options, client: this.ctx, user: this.user})
private fromUser = () => this.router.FromUser().policy(addMaximalFallbacks).getUrls()
private encryptToSelf = (payload: string) => this.user.nip44EncryptToSelf(payload)
// NIP 65
removeRelay = async (url: string, mode: RelayMode) => {
const list = (await this.relayLists.forceLoad(this.user.pubkey, [])) || makeList({kind: RELAYS})
const dup = getRelayTags(getListTags(list)).find(nthEq(1, url))
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}
const relays = this.fromUser()
// Make sure to notify the old relay too
relays.push(url)
return this.publish({event, relays})
}
addRelay = async (url: string, mode: RelayMode) => {
const list = (await this.relayLists.forceLoad(this.user.pubkey, [])) || makeList({kind: RELAYS})
const dup = getRelayTags(getListTags(list)).find(nthEq(1, url))
const tag = removeUndefined(["r", url, dup && dup[2] !== mode ? undefined : mode])
const tags = [...list.publicTags.filter(nthNe(1, url)), tag]
const event = {kind: list.kind, content: list.event?.content || "", tags}
return this.publish({event, relays: this.fromUser()})
}
setRelays = async (tags: string[][]) => {
const event = makeEvent(RELAYS, {tags})
const relays = this.router
.merge([this.router.Index(), this.router.FromRelays(getRelayTagValues(tags))])
.getUrls()
return this.publish({event, relays})
}
setReadRelays = async (urls: string[]) => {
const list = (await this.relayLists.forceLoad(this.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.publish({event, relays: this.fromUser()})
}
setWriteRelays = async (urls: string[]) => {
const list = (await this.relayLists.forceLoad(this.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.publish({event, relays: this.fromUser()})
}
// NIP 17
removeMessagingRelay = async (url: string) => {
const list =
(await this.messagingRelayLists.forceLoad(this.user.pubkey, [])) ||
makeList({kind: MESSAGING_RELAYS})
const event = await removeFromList(list, url).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
addMessagingRelay = async (url: string) => {
const list =
(await this.messagingRelayLists.forceLoad(this.user.pubkey, [])) ||
makeList({kind: MESSAGING_RELAYS})
const event = await addToListPublicly(list, ["relay", url]).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
setMessagingRelays = async (urls: string[]) => {
const event = makeEvent(MESSAGING_RELAYS, {tags: urls.map(url => ["relay", url])})
return this.publish({event, relays: this.router.FromUser().getUrls()})
}
// Blocked Relays
removeBlockedRelay = async (url: string) => {
const list =
(await this.blockedRelayLists.forceLoad(this.user.pubkey, [])) ||
makeList({kind: BLOCKED_RELAYS})
const event = await removeFromList(list, url).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
addBlockedRelay = async (url: string) => {
const list =
(await this.blockedRelayLists.forceLoad(this.user.pubkey, [])) ||
makeList({kind: BLOCKED_RELAYS})
const event = await addToListPublicly(list, ["relay", url]).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
setBlockedRelays = async (urls: string[]) => {
const event = makeEvent(BLOCKED_RELAYS, {tags: urls.map(url => ["relay", url])})
return this.publish({event, relays: this.router.FromUser().getUrls()})
}
// Search Relays
removeSearchRelay = async (url: string) => {
const list =
(await this.searchRelayLists.forceLoad(this.user.pubkey, [])) ||
makeList({kind: SEARCH_RELAYS})
const event = await removeFromList(list, url).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
addSearchRelay = async (url: string) => {
const list =
(await this.searchRelayLists.forceLoad(this.user.pubkey, [])) ||
makeList({kind: SEARCH_RELAYS})
const event = await addToListPublicly(list, ["relay", url]).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
setSearchRelays = async (urls: string[]) => {
const event = makeEvent(SEARCH_RELAYS, {tags: urls.map(url => ["relay", url])})
return this.publish({event, relays: this.router.FromUser().getUrls()})
}
// NIP 01
setProfile = (profile: Profile) => {
const relays = this.router.merge([this.router.Index(), this.router.FromUser()]).getUrls()
const event = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile)
return this.publish({event, relays})
}
// NIP 02
unfollow = async (value: string) => {
const list =
(await this.followLists.forceLoad(this.user.pubkey, [])) || makeList({kind: FOLLOWS})
const event = await removeFromList(list, value).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
follow = async (tag: string[]) => {
const list =
(await this.followLists.forceLoad(this.user.pubkey, [])) || makeList({kind: FOLLOWS})
const event = await addToListPublicly(list, tag).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
unmute = async (value: string) => {
const list = (await this.muteLists.forceLoad(this.user.pubkey, [])) || makeList({kind: MUTES})
const event = await removeFromList(list, value).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
mutePublicly = async (tag: string[]) => {
const list = (await this.muteLists.forceLoad(this.user.pubkey, [])) || makeList({kind: MUTES})
const event = await addToListPublicly(list, tag).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
mutePrivately = async (tag: string[]) => {
const list = (await this.muteLists.forceLoad(this.user.pubkey, [])) || makeList({kind: MUTES})
const event = await addToListPrivately(list, tag).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
setMutes = async ({
publicTags,
privateTags,
}: {
publicTags?: string[][]
privateTags?: string[][]
}) => {
const list = (await this.muteLists.forceLoad(this.user.pubkey, [])) || makeList({kind: MUTES})
const event = await updateList(list, {publicTags, privateTags}).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
unpin = async (value: string) => {
const list = (await this.pinLists.forceLoad(this.user.pubkey, [])) || makeList({kind: PINS})
const event = await removeFromList(list, value).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
pin = async (tag: string[]) => {
const list = (await this.pinLists.forceLoad(this.user.pubkey, [])) || makeList({kind: PINS})
const event = await addToListPublicly(list, tag).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
// NIP 59
sendWrapped = async ({event, recipients, ...options}: SendWrappedOptions) => {
// Stabilize the event id across the different wraps
const stableEvent = prep(event, this.user.pubkey, now())
return new MergedThunk(
await Promise.all(
uniq(recipients).map(async recipient => {
const relays = getRelaysFromList(await this.messagingRelayLists.load(recipient))
return this.publish({event: stableEvent, relays, recipient, ...options})
}),
),
)
}
// NIP 86
manageRelay = async (url: string, request: ManagementRequest) => {
url = url.replace(/^ws/, "http")
const authTemplate = await makeHttpAuth(url, "POST", JSON.stringify(request))
const authEvent = await this.user.sign(authTemplate)
return sendManagementRequest(url, request, authEvent)
}
// NIP 29
createRoom = (url: string, room: RoomMeta) =>
this.publish({event: makeRoomCreateEvent(room), relays: [url]})
deleteRoom = (url: string, room: RoomMeta) =>
this.publish({event: makeRoomDeleteEvent(room), relays: [url]})
editRoom = (url: string, room: RoomMeta) =>
this.publish({event: makeRoomEditEvent(room), relays: [url]})
joinRoom = (url: string, room: RoomMeta) =>
this.publish({event: makeRoomJoinEvent(room), relays: [url]})
leaveRoom = (url: string, room: RoomMeta) =>
this.publish({event: makeRoomLeaveEvent(room), relays: [url]})
addRoomMember = (url: string, room: RoomMeta, pubkey: string) =>
this.publish({event: makeRoomAddMemberEvent(room, pubkey), relays: [url]})
removeRoomMember = (url: string, room: RoomMeta, pubkey: string) =>
this.publish({event: makeRoomRemoveMemberEvent(room, pubkey), relays: [url]})
}
+1 -1
View File
@@ -5,7 +5,7 @@ import {defaultClientPolicies} from "./policies.js"
/**
* Creates a batteries-included client: a `Client` wired with the default client
* policies (event ingestion, relay-stats collection, gift-wrap unwrapping).
* Reach data modules via `client.use(Profiles)`, `client.use(Commands)`, etc.
* Reach data modules via `client.use(Profiles)`, `client.use(FollowLists)`, etc.
*
* For a bare client (no default side effects) construct `new Client(...)`
* directly, or pass your own `policies`.
+5 -5
View File
@@ -26,11 +26,11 @@ export class Feeds {
case Scope.Self:
return [$pubkey]
case Scope.Follows:
return this.ctx.use(Wot).getFollows($pubkey)
return this.ctx.use(Wot).follows($pubkey).get()
case Scope.Network:
return this.ctx.use(Wot).getNetwork($pubkey)
return this.ctx.use(Wot).network($pubkey).get()
case Scope.Followers:
return this.ctx.use(Wot).getFollowers($pubkey)
return this.ctx.use(Wot).followers($pubkey).get()
default:
return []
}
@@ -38,11 +38,11 @@ export class Feeds {
getPubkeysForWOTRange = (min: number, max: number): string[] => {
const pubkeys = []
const $maxWot = this.ctx.use(Wot).getMaxWot() ?? 0
const $maxWot = this.ctx.use(Wot).max.get() ?? 0
const thresholdMin = $maxWot * min
const thresholdMax = $maxWot * max
for (const [tpk, score] of this.ctx.use(Wot).getWotGraph().entries()) {
for (const [tpk, score] of this.ctx.use(Wot).graph.get().entries()) {
if (score >= thresholdMin && score <= thresholdMax) {
pubkeys.push(tpk)
}
+30 -5
View File
@@ -1,14 +1,23 @@
import {FOLLOWS, asDecryptedEvent, readList} from "@welshman/util"
import {
FOLLOWS,
asDecryptedEvent,
readList,
makeList,
addToListPublicly,
removeFromList,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js"
import {RelayLists} from "./relayLists.js"
import {DerivedData} from "./clientData.js"
import {Network} from "./network.js"
import {Thunks} from "./thunk.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 RepositoryCollection<ReturnType<typeof readList>> {
export class FollowLists extends DerivedData<ReturnType<typeof readList>> {
constructor(ctx: IClient) {
super(ctx, {
filters: [{kinds: [FOLLOWS]}],
@@ -18,6 +27,22 @@ export class FollowLists extends RepositoryCollection<ReturnType<typeof readList
}
fetch(pubkey: string, relayHints: string[] = []) {
return this.ctx.use(RelayLists).makeOutboxLoader(FOLLOWS)(pubkey, relayHints)
return this.ctx.use(Network).loadUsingOutbox(pubkey, {kinds: [FOLLOWS]}, relayHints)
}
follow = async (tag: string[]) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: FOLLOWS})
const event = await addToListPublicly(list, tag).reconcile(user.nip44EncryptToSelf)
return this.ctx.use(Thunks).publishToOutbox({event})
}
unfollow = async (value: string) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: FOLLOWS})
const event = await removeFromList(list, value).reconcile(user.nip44EncryptToSelf)
return this.ctx.use(Thunks).publishToOutbox({event})
}
}
+34 -3
View File
@@ -1,10 +1,22 @@
import {get, writable} from "svelte/store"
import {TaskQueue} from "@welshman/lib"
import {getPubkeyTagValues} from "@welshman/util"
import type {TrustedEvent, SignedEvent} from "@welshman/util"
import {TaskQueue, uniq, now} from "@welshman/lib"
import {getPubkeyTagValues, getRelaysFromList, prep} from "@welshman/util"
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 {MessagingRelayLists} from "./messagingRelayLists.js"
import type {IClient} from "./client.js"
export type SendWrappedOptions = Omit<
ThunkOptions,
"event" | "relays" | "recipient" | "client" | "user"
> & {
event: EventTemplate
recipients: string[]
}
/**
* Per-client gift-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
@@ -46,4 +58,23 @@ export class GiftWraps {
this.queue.push(wrap)
}
// NIP-59: wrap an event for each recipient (using their messaging relays) and
// publish the wraps as the client's user.
publish = async ({event, recipients, ...options}: SendWrappedOptions) => {
const user = User.require(this.ctx)
// Stabilize the event id across the different wraps
const stableEvent = prep(event, user.pubkey, now())
return new MergedThunk(
await Promise.all(
uniq(recipients).map(async recipient => {
const relays = getRelaysFromList(await this.ctx.use(MessagingRelayLists).load(recipient))
return this.ctx.use(Thunks).publish({event: stableEvent, relays, recipient, ...options})
}),
),
)
}
}
+16 -52
View File
@@ -1,51 +1,13 @@
import {tryCatch, fetchJson, batcher, postJson, last} from "@welshman/lib"
import {tryCatch, batcher, postJson} from "@welshman/lib"
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} from "./clientData.js"
import {LoadableData, projection} from "./clientData.js"
import type {Projection} from "./clientData.js"
import type {IClient} from "./client.js"
import {Profiles} from "./profiles.js"
export type Handle = {
nip05: string
pubkey?: string
nip46?: string[]
relays?: string[]
}
export async function queryProfile(nip05: string): Promise<Maybe<Handle>> {
const parts = nip05.split("@")
const name = parts.length > 1 ? parts[0] : "_"
const domain = last(parts)
try {
const {
names,
relays = {},
nip46 = {},
} = await fetchJson(`https://${domain}/.well-known/nostr.json?name=${name}`)
const pubkey = names[name]
if (!pubkey) {
return undefined
}
return {
nip05,
pubkey,
nip46: nip46[pubkey],
relays: relays[pubkey],
}
} catch (_e) {
return undefined
}
}
export const displayNip05 = (nip05: string) =>
nip05?.startsWith("_@") ? last(nip05.split("@")) : nip05
export const displayHandle = (handle: Handle) => displayNip05(handle.nip05)
/**
* NIP-05 handles, keyed by nip05 identifier. A "local" loadable collection:
* items aren't nostr events, they're fetched over HTTP (either directly from
@@ -101,20 +63,22 @@ export class Handles extends LoadableData<Handle> {
return $profile?.nip05 ? this.load($profile.nip05) : undefined
}
deriveForPubkey = (pubkey: string, relays: string[] = []) => {
forPubkey = (pubkey: string, relays: string[] = []): Projection<Maybe<Handle>> => {
this.loadForPubkey(pubkey, relays)
return deriveDeduplicated(
[this.index, this.ctx.use(Profiles).derive(pubkey, relays)],
([$handlesByNip05, $profile]) => {
if (!$profile?.nip05) return undefined
const read = ([$handlesByNip05, $profile]: [ReadonlyMap<string, Handle>, Maybe<{nip05?: string}>]) => {
if (!$profile?.nip05) return undefined
const handle = $handlesByNip05.get($profile.nip05)
const handle = $handlesByNip05.get($profile.nip05)
if (handle?.pubkey !== pubkey) return undefined
if (handle?.pubkey !== pubkey) return undefined
return handle
},
return handle
}
return projection(
deriveDeduplicated([this.index.$, this.ctx.use(Profiles).one(pubkey, relays)], read),
() => read([this.index.get(), this.ctx.use(Profiles).get(pubkey)]),
)
}
+4 -3
View File
@@ -1,9 +1,8 @@
export * from "./client.js"
export * from "./policies.js"
export * from "./networking.js"
export * from "./network.js"
export * from "./stores.js"
export * from "./clientData.js"
export * from "./repositoryCollection.js"
export * from "./user.js"
export * from "./router.js"
export * from "./relays.js"
@@ -23,11 +22,13 @@ 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 "./commands.js"
export * from "./rooms.js"
export * from "./relayManagement.js"
export * from "./thunk.js"
export * from "./createApp.js"
+46
View File
@@ -0,0 +1,46 @@
import {randomId} from "@welshman/lib"
import {WrappedSigner} from "@welshman/signer"
import type {ISigner} from "@welshman/signer"
/**
* A structured, extensible log event. The built-in `signer` variant tracks each
* signer operation (sign/encrypt/decrypt/getPubkey); the open variant lets
* callers emit their own event types — it's not just a string.
*/
export type LogMessage =
| {
type: "signer"
id: string
method: string
status: "pending" | "success" | "failure"
error?: unknown
at: number
}
| {type: string; at: number; [key: string]: unknown}
/**
* An `ISigner` wrapper that emits a structured `LogMessage` (as a "message"
* event on itself) for every operation it performs. `User.fromSigner` wraps
* signers in this so they're observable; subscribe via `makeClientPolicyLogger`.
*/
export class LoggingSigner extends WrappedSigner {
constructor(signer: ISigner) {
super(signer, async (method, thunk) => {
const id = randomId()
this.emit("message", {type: "signer", id, method, status: "pending", at: Date.now()})
try {
const result = await thunk()
this.emit("message", {type: "signer", id, method, status: "success", at: Date.now()})
return result
} catch (error) {
this.emit("message", {type: "signer", id, method, status: "failure", error, at: Date.now()})
throw error
}
})
}
}
+38 -5
View File
@@ -1,7 +1,18 @@
import {MESSAGING_RELAYS, asDecryptedEvent, readList} from "@welshman/util"
import {
MESSAGING_RELAYS,
asDecryptedEvent,
readList,
makeList,
makeEvent,
addToListPublicly,
removeFromList,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js"
import {RelayLists} from "./relayLists.js"
import {DerivedData} from "./clientData.js"
import {Network} from "./network.js"
import {Router} from "./router.js"
import {User} from "./user.js"
import {Thunks} from "./thunk.js"
import type {IClient} from "./client.js"
/**
@@ -9,7 +20,7 @@ import type {IClient} from "./client.js"
* outbox model (the author's write relays), so it depends on the relay-list
* collection.
*/
export class MessagingRelayLists extends RepositoryCollection<ReturnType<typeof readList>> {
export class MessagingRelayLists extends DerivedData<ReturnType<typeof readList>> {
constructor(ctx: IClient) {
super(ctx, {
filters: [{kinds: [MESSAGING_RELAYS]}],
@@ -19,6 +30,28 @@ export class MessagingRelayLists extends RepositoryCollection<ReturnType<typeof
}
fetch(pubkey: string, relayHints: string[] = []) {
return this.ctx.use(RelayLists).makeOutboxLoader(MESSAGING_RELAYS)(pubkey, relayHints)
return this.ctx.use(Network).loadUsingOutbox(pubkey, {kinds: [MESSAGING_RELAYS]}, relayHints)
}
addRelay = async (url: string) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: MESSAGING_RELAYS})
const event = await addToListPublicly(list, ["relay", url]).reconcile(user.nip44EncryptToSelf)
return this.ctx.use(Thunks).publishToOutbox({event})
}
removeRelay = async (url: string) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: MESSAGING_RELAYS})
const event = await removeFromList(list, url).reconcile(user.nip44EncryptToSelf)
return this.ctx.use(Thunks).publishToOutbox({event})
}
setRelays = (urls: string[]) =>
this.ctx.use(Thunks).publish({
event: makeEvent(MESSAGING_RELAYS, {tags: urls.map(url => ["relay", url])}),
relays: this.ctx.use(Router).FromUser().getUrls(),
})
}
+48 -15
View File
@@ -1,31 +1,32 @@
import {MUTES, asDecryptedEvent, readList} from "@welshman/util"
import {
MUTES,
asDecryptedEvent,
readList,
makeList,
addToListPublicly,
addToListPrivately,
removeFromList,
updateList,
} from "@welshman/util"
import type {TrustedEvent, PublishedList} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js"
import {DerivedData} from "./clientData.js"
import type {IClient} from "./client.js"
import {RelayLists} from "./relayLists.js"
import {Network} from "./network.js"
import {Thunks} from "./thunk.js"
import {Plaintext} from "./plaintext.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 RepositoryCollection<PublishedList> {
export class MuteLists extends DerivedData<PublishedList> {
constructor(ctx: IClient) {
super(ctx, {
filters: [{kinds: [MUTES]}],
eventToItem: async (event: TrustedEvent) => {
const content = await ctx.use(Plaintext).ensure(event)
// If this is our own mute list but it couldn't be decrypted yet because
// no signer is available, don't cache a result with empty private tags —
// that would get stuck permanently since the repository view won't
// re-process an already-seen event id. Returning undefined leaves it
// uncached so it's retried once a signer is available. For other
// pubkeys' lists we fall through and read just the public tags.
if (event.content && content === undefined && event.pubkey === ctx.user?.pubkey) {
return undefined
}
return readList(asDecryptedEvent(event, {content}))
},
getKey: mute => mute.event.pubkey,
@@ -33,6 +34,38 @@ export class MuteLists extends RepositoryCollection<PublishedList> {
}
fetch(pubkey: string, relayHints: string[] = []) {
return this.ctx.use(RelayLists).makeOutboxLoader(MUTES)(pubkey, relayHints)
return this.ctx.use(Network).loadUsingOutbox(pubkey, {kinds: [MUTES]}, relayHints)
}
mutePublicly = async (tag: string[]) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: MUTES})
const event = await addToListPublicly(list, tag).reconcile(user.nip44EncryptToSelf)
return this.ctx.use(Thunks).publishToOutbox({event})
}
mutePrivately = async (tag: string[]) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: MUTES})
const event = await addToListPrivately(list, tag).reconcile(user.nip44EncryptToSelf)
return this.ctx.use(Thunks).publishToOutbox({event})
}
unmute = async (value: string) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: MUTES})
const event = await removeFromList(list, value).reconcile(user.nip44EncryptToSelf)
return this.ctx.use(Thunks).publishToOutbox({event})
}
setMutes = async (updates: {publicTags?: string[][]; privateTags?: string[][]}) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: MUTES})
const event = await updateList(list, updates).reconcile(user.nip44EncryptToSelf)
return this.ctx.use(Thunks).publishToOutbox({event})
}
}
@@ -1,3 +1,6 @@
import {chunk, first} from "@welshman/lib"
import {RelayMode, getRelaysFromList, sortEventsDesc} from "@welshman/util"
import type {Filter} from "@welshman/util"
import {request, publish, diff, pull, push, makeLoader} from "@welshman/net"
import type {
Loader,
@@ -8,19 +11,24 @@ import type {
PullOptions,
PushOptions,
} from "@welshman/net"
import {Router, addMinimalFallbacks} from "./router.js"
import {RelayLists} from "./relayLists.js"
import type {IClient} from "./client.js"
/**
* Net utilities bound to the client's net context (its pool + repository). Reach
* it via `client.use(Networking)`; `load` is a shared, batched loader.
* it via `client.use(Network)`; `load` is a shared, batched loader.
*/
export class Networking {
export class Network {
load: Loader
constructor(readonly ctx: IClient) {
this.load = this.makeLoader({delay: 200, timeout: 3000, threshold: 0.5})
this.load = this.makeLoader({delay: 50, timeout: 3000, threshold: 0.5})
}
makeLoader = (options: Omit<LoaderOptions, "context">): Loader =>
makeLoader({...options, context: this.ctx.netContext})
request = (options: Omit<RequestOptions, "context">) =>
request({...options, context: this.ctx.netContext})
@@ -33,6 +41,22 @@ export class Networking {
push = (options: Omit<PushOptions, "context">) => push({...options, context: this.ctx.netContext})
makeLoader = (options: Omit<LoaderOptions, "context">): Loader =>
makeLoader({...options, context: this.ctx.netContext})
loadUsingOutbox = async (pubkey: string, filter: Filter = {}, relayHints: string[] = []) => {
const filters: Filter[] = [{...filter, authors: [pubkey]}]
const writeRelays = getRelaysFromList(await this.ctx.use(RelayLists).load(pubkey), RelayMode.Write)
const allRelays = this.ctx
.use(Router)
.FromRelays([...relayHints, ...writeRelays])
.policy(addMinimalFallbacks)
.limit(8)
.getUrls()
for (const relays of chunk(2, allRelays)) {
const events = await this.load({filters, relays})
if (events.length > 0) {
return first(sortEventsDesc(events))
}
}
}
}
+30 -5
View File
@@ -1,14 +1,23 @@
import {PINS, asDecryptedEvent, readList} from "@welshman/util"
import {
PINS,
asDecryptedEvent,
readList,
makeList,
addToListPublicly,
removeFromList,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js"
import {RelayLists} from "./relayLists.js"
import {DerivedData} from "./clientData.js"
import {Network} from "./network.js"
import {Thunks} from "./thunk.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 RepositoryCollection<ReturnType<typeof readList>> {
export class PinLists extends DerivedData<ReturnType<typeof readList>> {
constructor(ctx: IClient) {
super(ctx, {
filters: [{kinds: [PINS]}],
@@ -18,6 +27,22 @@ export class PinLists extends RepositoryCollection<ReturnType<typeof readList>>
}
fetch(pubkey: string, relayHints: string[] = []) {
return this.ctx.use(RelayLists).makeOutboxLoader(PINS)(pubkey, relayHints)
return this.ctx.use(Network).loadUsingOutbox(pubkey, {kinds: [PINS]}, relayHints)
}
pin = async (tag: string[]) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: PINS})
const event = await addToListPublicly(list, tag).reconcile(user.nip44EncryptToSelf)
return this.ctx.use(Thunks).publishToOutbox({event})
}
unpin = async (value: string) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: PINS})
const event = await removeFromList(list, value).reconcile(user.nip44EncryptToSelf)
return this.ctx.use(Thunks).publishToOutbox({event})
}
}
+6 -21
View File
@@ -5,38 +5,23 @@ import {ClientData} from "./clientData.js"
/**
* A cache of decrypted event content, keyed by event id.
*
* In the old global model decryption used `getSigner(getSession(event.pubkey))`
* — whichever logged-in account authored the event. In the per-client model
* there is exactly one identity, so this reduces to "is this our user?". That
* scoping is also what keeps decrypted content (including DM rumors) from
* bleeding across identities — each client decrypts only its own.
*/
export class Plaintext extends ClientData<string> {
ensure = async (event: TrustedEvent): Promise<Maybe<string>> => {
// Check for key presence rather than truthiness so a legitimately empty
// decrypted result ("") is treated as cached and we don't re-hit the signer
// on every call.
if (event.content && this.get(event.id) === undefined) {
const signer = event.pubkey === this.ctx.user?.pubkey ? this.ctx.user?.signer : undefined
if (!signer) return
let result
if (this.ctx.user?.pubkey !== event.pubkey) return
let result = this.get(event.id)
if (event.content && result === undefined) {
try {
result = await decrypt(signer, event.pubkey, event.content)
result = await decrypt(this.ctx.user.signer, event.pubkey, event.content)
this.set(event.id, result)
} catch (e: any) {
if (!String(e).match(/invalid base64/)) {
throw e
}
}
if (result !== undefined) {
this.set(event.id, result)
}
}
return this.get(event.id)
return result
}
}
+74 -17
View File
@@ -1,12 +1,15 @@
import type {Unsubscriber} from "svelte/store"
import {on} from "@welshman/lib"
import {on, noop, always, call} from "@welshman/lib"
import {WRAP, isDVMKind, isEphemeralKind, verifyEvent} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {SocketEvent, isRelayEvent} from "@welshman/net"
import type {RelayMessage} from "@welshman/net"
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 {LoggingSigner} from "./logging.js"
import type {LogMessage} from "./logging.js"
/**
* A client policy is a side effect applied once per client at construction,
@@ -17,6 +20,54 @@ import {GiftWraps} from "./giftWraps.js"
*/
export type ClientPolicy = (client: IClient) => Unsubscriber
/**
* Builds a client policy that authenticates the client's sockets (NIP-42) with
* the user's signer. It appends an auth socket policy to the pool's
* `socketPolicies`, so every socket the pool creates answers AUTH challenges
* according to `shouldAuth`; the policy is spliced back out on cleanup. No-op
* when the client has no user.
*
* Use the `clientPolicyAuthAlways` / `clientPolicyAuthNever` presets below, or
* call this with a custom predicate.
*/
export const makeClientPolicyAuth =
(shouldAuth: (socket: Socket, client: IClient) => boolean): ClientPolicy =>
client => {
if (!client.user) {
return noop
}
const policy = makeSocketPolicyAuth({
sign: client.user.signer.sign,
shouldAuth: socket => shouldAuth(socket, client),
})
client.pool.socketPolicies.push(policy)
return () => {
const index = client.pool.socketPolicies.indexOf(policy)
if (index !== -1) {
client.pool.socketPolicies.splice(index, 1)
}
}
}
export const clientPolicyAuthNever = makeClientPolicyAuth(always(false))
export const clientPolicyAuthAlways = makeClientPolicyAuth(always(true))
export const clientPolicyAuthUnlessBlocked = makeClientPolicyAuth((socket, client) => {
if (!client.user) {
return false
}
return !client
.use(BlockedRelayLists)
.getBlockedRelays(client.user.pubkey)
.includes(socket.url)
})
/**
* Ingests every event received on any socket into the client's repository. The
* net layer doesn't do this for us, and it's how all the repository-backed
@@ -42,22 +93,10 @@ export const clientPolicyIngest: ClientPolicy = client =>
})
/**
* Wires socket activity on the client's pool into the RelayStats store.
* Listens to socket activity on the client's pool into the RelayStats store.
*/
export const clientPolicyRelayStats: ClientPolicy = client => {
const stats = client.use(RelayStats)
return client.pool.subscribe(socket => {
socket.on(SocketEvent.Send, stats.onSocketSend)
socket.on(SocketEvent.Receive, stats.onSocketReceive)
socket.on(SocketEvent.Status, stats.onSocketStatus)
return () => {
socket.off(SocketEvent.Send, stats.onSocketSend)
socket.off(SocketEvent.Receive, stats.onSocketReceive)
socket.off(SocketEvent.Status, stats.onSocketStatus)
}
})
return client.pool.subscribe(client.use(RelayStats).monitorSocket)
}
/**
@@ -80,8 +119,26 @@ export const clientPolicyGiftWraps: ClientPolicy = client => {
})
}
/**
* Forwards "message" events from the user's signer to `onMessage`. Opt-in —
* add `clientPolicyLogger(handler)` to a client's `policies`.
*/
export const makeClientPolicyLogger =
(onMessage: (message: LogMessage) => void): ClientPolicy =>
client => {
const unsubscribers: Unsubscriber[] = []
const signer = client.user?.signer
if (signer instanceof LoggingSigner) {
unsubscribers.push(on(signer, "message", onMessage))
}
return () => unsubscribers.forEach(call)
}
export const defaultClientPolicies: ClientPolicy[] = [
clientPolicyIngest,
clientPolicyRelayStats,
clientPolicyGiftWraps,
clientPolicyAuthUnlessBlocked,
]
+34 -13
View File
@@ -1,14 +1,27 @@
import {derived, readable} from "svelte/store"
import {readProfile, displayProfile, displayPubkey, PROFILE} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js"
import {RelayLists} from "./relayLists.js"
import {
readProfile,
displayProfile,
displayPubkey,
isPublishedProfile,
createProfile,
editProfile,
PROFILE,
} 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 {Network} from "./network.js"
import {Router} from "./router.js"
import {Thunks} from "./thunk.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 RepositoryCollection<ReturnType<typeof readProfile>> {
export class Profiles extends DerivedData<ReturnType<typeof readProfile>> {
constructor(ctx: IClient) {
super(ctx, {
filters: [{kinds: [PROFILE]}],
@@ -18,16 +31,24 @@ export class Profiles extends RepositoryCollection<ReturnType<typeof readProfile
}
fetch(pubkey: string, relayHints: string[] = []) {
return this.ctx.use(RelayLists).makeOutboxLoader(PROFILE)(pubkey, relayHints)
return this.ctx.use(Network).loadUsingOutbox(pubkey, {kinds: [PROFILE]}, relayHints)
}
display = (pubkey: string | undefined) =>
pubkey ? displayProfile(this.get(pubkey), displayPubkey(pubkey)) : ""
publish = (profile: Profile) => {
const router = this.ctx.use(Router)
const relays = router.merge([router.Index(), router.FromUser()]).getUrls()
const event = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile)
deriveDisplay = (pubkey: string | undefined, ...args: any[]) =>
pubkey
? derived(this.derive(pubkey, ...args), $profile =>
displayProfile($profile, displayPubkey(pubkey)),
)
: readable("")
return this.ctx.use(Thunks).publish({event, relays})
}
display = (pubkey: string | undefined, ...args: any[]): Projection<string> => {
const read = ($profile: Maybe<ReturnType<typeof readProfile>>) =>
pubkey ? displayProfile($profile, displayPubkey(pubkey)) : ""
return projection(
pubkey ? derived(this.one(pubkey, ...args), read) : readable(""),
() => read(pubkey ? this.get(pubkey) : undefined),
)
}
}
+78 -43
View File
@@ -1,26 +1,29 @@
import {chunk, first} from "@welshman/lib"
import {reject, nth, nthNe, nthEq, removeUndefined} from "@welshman/lib"
import {
RELAYS,
RelayMode,
asDecryptedEvent,
readList,
getRelaysFromList,
isPlainReplaceableKind,
sortEventsDesc,
getRelayTags,
getListTags,
getRelayTagValues,
makeList,
makeEvent,
} from "@welshman/util"
import type {Filter, TrustedEvent, PublishedList} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js"
import type {TrustedEvent, PublishedList} from "@welshman/util"
import {DerivedData} from "./clientData.js"
import {Router, addMinimalFallbacks} from "./router.js"
import {Networking} from "./networking.js"
import {Network} from "./network.js"
import {User} from "./user.js"
import {Thunks} from "./thunk.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, so it also exposes `loadUsingOutbox` /
* `makeOutboxLoader` for other collections to build their fetchers on. It and the
* Router reference each other lazily via `ctx.use`, so the cycle never bites.
* outbox-model load depends on (see `Network.loadUsingOutbox`).
*/
export class RelayLists extends RepositoryCollection<PublishedList> {
export class RelayLists extends DerivedData<PublishedList> {
constructor(ctx: IClient) {
super(ctx, {
filters: [{kinds: [RELAYS]}],
@@ -31,52 +34,84 @@ export class RelayLists extends RepositoryCollection<PublishedList> {
fetch(pubkey: string, relayHints: string[] = []) {
const filters = [{kinds: [RELAYS], authors: [pubkey], limit: 1}]
const networking = this.ctx.use(Network)
const router = this.ctx.use(Router)
return Promise.all([
this.ctx.use(Networking).load({filters, relays: router.FromRelays(relayHints).getUrls()}),
this.ctx.use(Networking).load({filters, relays: router.FromPubkey(pubkey).getUrls()}),
this.ctx.use(Networking).load({filters, relays: router.Index().getUrls()}),
networking.load({filters, relays: router.FromRelays(relayHints).getUrls()}),
networking.load({filters, relays: router.FromPubkey(pubkey).getUrls()}),
networking.load({filters, relays: router.Index().getUrls()}),
])
}
getRelaysForPubkey = (pubkey: string, mode?: RelayMode) =>
getRelaysFromList(this.get(pubkey), mode)
// Load a pubkey's events using their advertised write relays (outbox model)
// NIP-65 relay-list mutations for the client's user
loadUsingOutbox = async (kind: number, pubkey: string, filter: Filter = {}) => {
const filters: Filter[] = [{...filter, kinds: [kind], authors: [pubkey]}]
const writeRelays = getRelaysFromList(await this.load(pubkey), RelayMode.Write)
const allRelays = this.ctx
.use(Router)
.FromRelays(writeRelays)
.policy(addMinimalFallbacks)
.limit(8)
.getUrls()
addRelay = async (url: string, mode: RelayMode) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: RELAYS})
const dup = getRelayTags(getListTags(list)).find(nthEq(1, url))
const tag = removeUndefined(["r", url, dup && dup[2] !== mode ? undefined : mode])
const tags = [...list.publicTags.filter(nthNe(1, url)), tag]
const event = {kind: list.kind, content: list.event?.content || "", tags}
if (isPlainReplaceableKind(kind)) {
filters[0].limit = 1
}
for (const relays of chunk(2, allRelays)) {
const events = await this.ctx.use(Networking).load({filters, relays})
if (events.length > 0) {
return first(sortEventsDesc(events))
}
}
return this.ctx.use(Thunks).publishToOutbox({event})
}
makeOutboxLoader =
(kind: number, filter: Filter = {}) =>
async (pubkey: string, relayHints: string[] = []) => {
const filters: Filter[] = [{...filter, kinds: [kind], authors: [pubkey]}]
const relays = this.ctx.use(Router).FromRelays(relayHints).getUrls()
removeRelay = async (url: string, mode: RelayMode) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: RELAYS})
const dup = getRelayTags(getListTags(list)).find(nthEq(1, url))
const alt = mode === RelayMode.Read ? RelayMode.Write : RelayMode.Read
const tags = list.publicTags.filter(nthNe(1, url))
await Promise.all([
this.ctx.use(Networking).load({filters, relays}),
this.loadUsingOutbox(kind, pubkey, filter),
])
// 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
// removed relay of its removal
const relays = [url, ...this.ctx.use(Router).FromUser().policy(addMinimalFallbacks).getUrls()]
return this.ctx.use(Thunks).publish({event, relays})
}
setRelays = (tags: string[][]) => {
const router = this.ctx.use(Router)
const event = makeEvent(RELAYS, {tags})
const relays = router
.merge([router.Index(), router.FromRelays(getRelayTagValues(tags))])
.getUrls()
return this.ctx.use(Thunks).publish({event, relays})
}
setReadRelays = async (urls: string[]) => {
const user = User.require(this.ctx)
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.ctx.use(Thunks).publishToOutbox({event})
}
setWriteRelays = async (urls: string[]) => {
const user = User.require(this.ctx)
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.ctx.use(Thunks).publishToOutbox({event})
}
}
+21
View File
@@ -0,0 +1,21 @@
import {makeHttpAuth, sendManagementRequest} from "@welshman/util"
import type {ManagementRequest} from "@welshman/util"
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
* sends an admin request to a relay's management endpoint.
*/
export class RelayManagement {
constructor(readonly ctx: IClient) {}
post = async (url: string, request: ManagementRequest) => {
url = url.replace(/^ws/, "http")
const authTemplate = await makeHttpAuth(url, "POST", JSON.stringify(request))
const authEvent = await User.require(this.ctx).sign(authTemplate)
return sendManagementRequest(url, request, authEvent)
}
}
+17 -5
View File
@@ -1,7 +1,7 @@
import {groupBy, batch, now, uniq, ago, DAY, HOUR, MINUTE} from "@welshman/lib"
import {isOnionUrl, isLocalUrl, isIPAddress, isRelayUrl} from "@welshman/util"
import {SocketStatus} from "@welshman/net"
import type {ClientMessage, RelayMessage} from "@welshman/net"
import {SocketStatus, SocketEvent} from "@welshman/net"
import type {ClientMessage, RelayMessage, Socket} from "@welshman/net"
import {ClientData} from "./clientData.js"
import {BlockedRelayLists} from "./blockedRelayLists.js"
@@ -110,7 +110,7 @@ export class RelayStats extends ClientData<RelayStatsItem> {
}
})
onSocketSend = ([verb]: ClientMessage, url: string) => {
private onSocketSend = ([verb]: ClientMessage, url: string) => {
if (verb === "REQ") {
this.update([
url,
@@ -130,7 +130,7 @@ export class RelayStats extends ClientData<RelayStatsItem> {
}
}
onSocketReceive = ([verb, ...extra]: RelayMessage, url: string) => {
private onSocketReceive = ([verb, ...extra]: RelayMessage, url: string) => {
if (verb === "OK") {
const [, ok] = extra
@@ -161,7 +161,7 @@ export class RelayStats extends ClientData<RelayStatsItem> {
}
}
onSocketStatus = (status: string, url: string) => {
private onSocketStatus = (status: string, url: string) => {
if (status === SocketStatus.Open) {
this.update([
url,
@@ -192,4 +192,16 @@ export class RelayStats extends ClientData<RelayStatsItem> {
])
}
}
monitorSocket = (socket: Socket) => {
socket.on(SocketEvent.Send, this.onSocketSend)
socket.on(SocketEvent.Receive, this.onSocketReceive)
socket.on(SocketEvent.Status, this.onSocketStatus)
return () => {
socket.off(SocketEvent.Send, this.onSocketSend)
socket.off(SocketEvent.Receive, this.onSocketReceive)
socket.off(SocketEvent.Status, this.onSocketStatus)
}
}
}
+6 -4
View File
@@ -3,7 +3,8 @@ 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} from "./clientData.js"
import {LoadableData, projection} from "./clientData.js"
import type {Projection} from "./clientData.js"
/**
* NIP-11 relay profiles, keyed by url. A "local" loadable collection: items
@@ -36,8 +37,9 @@ export class Relays extends LoadableData<RelayProfile> {
}
}
display = (url: string) => displayRelayProfile(this.get(url), displayRelayUrl(url))
display = (url: string): Projection<string> => {
const read = ($relay: Maybe<RelayProfile>) => displayRelayProfile($relay, displayRelayUrl(url))
deriveDisplay = (url: string) =>
derived(this.derive(url), $relay => displayRelayProfile($relay, displayRelayUrl(url)))
return projection(derived(this.one(url), read), () => read(this.get(url)))
}
}
@@ -1,89 +0,0 @@
import type {Readable} from "svelte/store"
import type {Maybe} from "@welshman/lib"
import type {Filter} from "@welshman/util"
import {deriveItems, getter, makeLoadItem, makeForceLoadItem, makeDeriveItem} from "@welshman/store"
import type {EventToItem, ItemsByKey, MakeLoadItemOptions} from "@welshman/store"
import type {IClient} from "./client.js"
import {Stores} from "./stores.js"
export type RepositoryCollectionOptions<T> = {
filters: Filter[]
eventToItem: EventToItem<T>
getKey: (item: T) => string
loadOptions?: MakeLoadItemOptions
}
/**
* Base class for a reactive, keyed collection of data derived from nostr events.
* The repository is the single source of truth — the collection is a live view
* over `ctx.deriveItemsByKey`, never a duplicated map. Subclasses implement
* `fetch` (how to load an item by key from the network) and pass the
* filters/decoder via `super`.
*
* Like `ClientData`, subclasses depend only on the `IClient` seam.
*/
export abstract class RepositoryCollection<T> {
byKey: Readable<ItemsByKey<T>>
all: Readable<T[]>
subscribe: Readable<ItemsByKey<T>>["subscribe"]
get: (key: string) => Maybe<T>
getAll: () => T[]
keys: () => IterableIterator<string>
values: () => IterableIterator<T>
load: (key: string, ...args: any[]) => Promise<Maybe<T>>
forceLoad: (key: string, ...args: any[]) => Promise<Maybe<T>>
// Reactive view of a single key that also triggers a load
derive: (key?: string, ...args: any[]) => Readable<Maybe<T>>
// Reactive view of a single key that does not trigger a load
derived: (key?: string, ...args: any[]) => Readable<Maybe<T>>
private getByKey: () => ItemsByKey<T>
abstract fetch(key: string, ...args: any[]): Promise<unknown>
constructor(
protected readonly ctx: IClient,
options: RepositoryCollectionOptions<T>,
) {
const fetch = (key: string, ...args: any[]) => this.fetch(key, ...args)
this.byKey = ctx.use(Stores).deriveItemsByKey<T>({
filters: options.filters,
eventToItem: options.eventToItem,
getKey: options.getKey,
})
this.all = deriveItems(this.byKey)
this.subscribe = this.byKey.subscribe
this.getByKey = getter(this.byKey)
this.getAll = getter(this.all)
this.get = (key: string) => this.getByKey().get(key)
this.keys = () => this.getByKey().keys()
this.values = () => this.getByKey().values()
this.load = makeLoadItem(fetch, this.get, options.loadOptions)
this.forceLoad = makeForceLoadItem(fetch, this.get)
this.derive = makeDeriveItem(this.byKey, this.load)
this.derived = makeDeriveItem(this.byKey)
}
// Convenience views of the current user's own item (replaces the old
// user.ts userProfile/userFollowList/etc. derived stores)
getForUser = () => {
const pubkey = this.ctx.user?.pubkey
return pubkey ? this.get(pubkey) : undefined
}
deriveForUser = (...args: any[]) => this.derive(this.ctx.user?.pubkey, ...args)
loadForUser = (...args: any[]) => {
const pubkey = this.ctx.user?.pubkey
return pubkey ? this.load(pubkey, ...args) : Promise.resolve(undefined)
}
forceLoadForUser = (...args: any[]) => {
const pubkey = this.ctx.user?.pubkey
return pubkey ? this.forceLoad(pubkey, ...args) : Promise.resolve(undefined)
}
}
+40
View File
@@ -0,0 +1,40 @@
import {
makeRoomCreateEvent,
makeRoomDeleteEvent,
makeRoomEditEvent,
makeRoomJoinEvent,
makeRoomLeaveEvent,
makeRoomAddMemberEvent,
makeRoomRemoveMemberEvent,
} from "@welshman/util"
import type {RoomMeta} from "@welshman/util"
import {Thunks} from "./thunk.js"
import type {ThunkOptions} from "./thunk.js"
import type {IClient} from "./client.js"
/**
* NIP-29 relay-based group (room) management. Each method publishes the relevant
* room event to the given relay as the client's user.
*/
export class Rooms {
constructor(readonly ctx: IClient) {}
private publish = (url: string, event: ThunkOptions["event"]) =>
this.ctx.use(Thunks).publish({event, relays: [url]})
create = (url: string, room: RoomMeta) => this.publish(url, makeRoomCreateEvent(room))
delete = (url: string, room: RoomMeta) => this.publish(url, makeRoomDeleteEvent(room))
edit = (url: string, room: RoomMeta) => this.publish(url, makeRoomEditEvent(room))
join = (url: string, room: RoomMeta) => this.publish(url, makeRoomJoinEvent(room))
leave = (url: string, room: RoomMeta) => this.publish(url, makeRoomLeaveEvent(room))
addMember = (url: string, room: RoomMeta, pubkey: string) =>
this.publish(url, makeRoomAddMemberEvent(room, pubkey))
removeMember = (url: string, room: RoomMeta, pubkey: string) =>
this.publish(url, makeRoomRemoveMemberEvent(room, pubkey))
}
+7 -7
View File
@@ -6,9 +6,9 @@ import type {Readable} from "svelte/store"
import {dec, inc, sortBy} from "@welshman/lib"
import {PROFILE} from "@welshman/util"
import type {PublishedProfile, RelayProfile} from "@welshman/util"
import {throttled, deriveItems} from "@welshman/store"
import {throttled} from "@welshman/store"
import type {IClient} from "./client.js"
import {Networking} from "./networking.js"
import {Network} from "./network.js"
import {Router} from "./router.js"
import {Profiles} from "./profiles.js"
import {Topics} from "./topics.js"
@@ -69,7 +69,7 @@ export class Searches {
constructor(readonly ctx: IClient) {
this.profileSearch = derived(
[throttled(800, this.ctx.use(Profiles).all), throttled(800, this.ctx.use(Handles))],
[throttled(800, this.ctx.use(Profiles).all.$), throttled(800, this.ctx.use(Handles).index.$)],
([$profiles, $handlesByNip05]) => {
// Remove invalid nip05's from profiles
const options = $profiles.map(p => {
@@ -82,9 +82,9 @@ export class Searches {
onSearch: this.searchProfiles,
getValue: (profile: PublishedProfile) => profile.event.pubkey,
sortFn: ({score = 1, item}) => {
const wotScore = this.ctx.use(Wot).getWotGraph().get(item.event.pubkey) || 0
const wotScore = this.ctx.use(Wot).graph.get().get(item.event.pubkey) || 0
return dec(score) * inc(wotScore / (this.ctx.use(Wot).getMaxWot() || 1))
return dec(score) * inc(wotScore / (this.ctx.use(Wot).max.get() || 1))
},
fuseOptions: {
keys: [
@@ -107,7 +107,7 @@ export class Searches {
}),
)
this.relaySearch = derived(deriveItems(this.ctx.use(Relays)), $relays =>
this.relaySearch = derived(this.ctx.use(Relays).all.$, $relays =>
createSearch($relays, {
getValue: (relay: RelayProfile) => relay.url,
fuseOptions: {
@@ -119,7 +119,7 @@ export class Searches {
searchProfiles = debounce(500, (search: string) => {
if (search.length > 2) {
this.ctx.use(Networking).load({
this.ctx.use(Network).load({
filters: [{kinds: [PROFILE], search}],
relays: this.ctx.use(Router).Search().getUrls(),
})
+38 -5
View File
@@ -1,7 +1,18 @@
import {SEARCH_RELAYS, asDecryptedEvent, readList} from "@welshman/util"
import {
SEARCH_RELAYS,
asDecryptedEvent,
readList,
makeList,
makeEvent,
addToListPublicly,
removeFromList,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js"
import {RelayLists} from "./relayLists.js"
import {DerivedData} from "./clientData.js"
import {Network} from "./network.js"
import {Router} from "./router.js"
import {User} from "./user.js"
import {Thunks} from "./thunk.js"
import type {IClient} from "./client.js"
/**
@@ -9,7 +20,7 @@ import type {IClient} from "./client.js"
* outbox model (the author's write relays), so it depends on the relay-list
* collection.
*/
export class SearchRelayLists extends RepositoryCollection<ReturnType<typeof readList>> {
export class SearchRelayLists extends DerivedData<ReturnType<typeof readList>> {
constructor(ctx: IClient) {
super(ctx, {
filters: [{kinds: [SEARCH_RELAYS]}],
@@ -19,6 +30,28 @@ export class SearchRelayLists extends RepositoryCollection<ReturnType<typeof rea
}
fetch(pubkey: string, relayHints: string[] = []) {
return this.ctx.use(RelayLists).makeOutboxLoader(SEARCH_RELAYS)(pubkey, relayHints)
return this.ctx.use(Network).loadUsingOutbox(pubkey, {kinds: [SEARCH_RELAYS]}, relayHints)
}
addRelay = async (url: string) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: SEARCH_RELAYS})
const event = await addToListPublicly(list, ["relay", url]).reconcile(user.nip44EncryptToSelf)
return this.ctx.use(Thunks).publishToOutbox({event})
}
removeRelay = async (url: string) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: SEARCH_RELAYS})
const event = await removeFromList(list, url).reconcile(user.nip44EncryptToSelf)
return this.ctx.use(Thunks).publishToOutbox({event})
}
setRelays = (urls: string[]) =>
this.ctx.use(Thunks).publish({
event: makeEvent(SEARCH_RELAYS, {tags: urls.map(url => ["relay", url])}),
relays: this.ctx.use(Router).FromUser().getUrls(),
})
}
+64 -231
View File
@@ -1,249 +1,82 @@
import {Client as PomadeClient, PomadeSigner} from "@pomade/core"
import type {ClientOptions as PomadeClientOptions} from "@pomade/core"
import {writable} from "svelte/store"
import {randomId, append} from "@welshman/lib"
import {getPubkey} from "@welshman/util"
import {
WrappedSigner,
Nip46Broker,
Nip46Signer,
Nip07Signer,
Nip01Signer,
Nip55Signer,
} from "@welshman/signer"
import type {MaybeAsync} from "@welshman/lib"
import {Nip46Broker, Nip46Signer, Nip07Signer, Nip01Signer, Nip55Signer} from "@welshman/signer"
import type {ISigner} from "@welshman/signer"
import {User} from "./user.js"
import type {UserOptions} from "./user.js"
/**
* Session descriptors and the signer construction that turns them into a
* `User`. In the old global package these fed a multi-account registry (a single
* `sessions` map + `pubkey` pointer over one shared repository — the root of the
* merged-DM bug). In the per-client model each session becomes its own `User`
* (and thus its own `Client` with its own repository), so "multi-account" is
* just "multiple clients" and lives above this module.
*/
// ── Sessions: serializable {method, data} descriptors ──
export enum SessionMethod {
Nip01 = "nip01",
Nip07 = "nip07",
Nip46 = "nip46",
Nip55 = "nip55",
Pomade = "pomade",
Pubkey = "pubkey",
Anonymous = "anonymous",
export type Session<M extends string = string, D = unknown> = {
method: M
data: D
}
export type SessionNip01 = {
method: SessionMethod.Nip01
pubkey: string
secret: string
}
// ── Session handlers: a method string, its data shape, and how to build a signer ──
export type SessionNip07 = {
method: SessionMethod.Nip07
pubkey: string
}
export type SessionNip46 = {
method: SessionMethod.Nip46
pubkey: string
secret: string
handler: {
pubkey: string
relays: string[]
}
}
export type SessionNip55 = {
method: SessionMethod.Nip55
pubkey: string
signer: string
}
export type SessionPomade = {
method: SessionMethod.Pomade
pubkey: string
clientOptions: PomadeClientOptions
email: string
}
export type SessionPubkey = {
method: SessionMethod.Pubkey
pubkey: string
}
export type SessionAnonymous = {
method: SessionMethod.Anonymous
}
export type SessionAnyMethod =
| SessionNip01
| SessionNip07
| SessionNip46
| SessionNip55
| SessionPomade
| SessionPubkey
| SessionAnonymous
export type Session = SessionAnyMethod & Record<string, any>
// Session factories
export const makeNip01Session = (secret: string): SessionNip01 => ({
method: SessionMethod.Nip01,
secret,
pubkey: getPubkey(secret),
})
export const makeNip07Session = (pubkey: string): SessionNip07 => ({
method: SessionMethod.Nip07,
pubkey,
})
export const makeNip46Session = (
pubkey: string,
clientSecret: string,
signerPubkey: string,
relays: string[],
): SessionNip46 => ({
method: SessionMethod.Nip46,
pubkey,
secret: clientSecret,
handler: {pubkey: signerPubkey, relays},
})
export const makeNip55Session = (pubkey: string, signer: string): SessionNip55 => ({
method: SessionMethod.Nip55,
pubkey,
signer,
})
export const makePomadeSession = (
pubkey: string,
email: string,
clientOptions: PomadeClientOptions,
): SessionPomade => ({
method: SessionMethod.Pomade,
pubkey,
clientOptions,
email,
})
export const makePubkeySession = (pubkey: string): SessionPubkey => ({
method: SessionMethod.Pubkey,
pubkey,
})
// Type guards
export const isNip01Session = (session?: Session): session is SessionNip01 =>
session?.method === SessionMethod.Nip01
export const isNip07Session = (session?: Session): session is SessionNip07 =>
session?.method === SessionMethod.Nip07
export const isNip46Session = (session?: Session): session is SessionNip46 =>
session?.method === SessionMethod.Nip46
export const isNip55Session = (session?: Session): session is SessionNip55 =>
session?.method === SessionMethod.Nip55
export const isPomadeSession = (session?: Session): session is SessionPomade =>
session?.method === SessionMethod.Pomade
export const isPubkeySession = (session?: Session): session is SessionPubkey =>
session?.method === SessionMethod.Pubkey
// Signer construction
export const nip46Perms = "sign_event:22242,nip04_encrypt,nip04_decrypt,nip44_encrypt,nip44_decrypt"
export type SignerLogEntry = {
id: string
method: string
started_at: number
finished_at?: number
ok?: boolean
}
export const signerLog = writable<SignerLogEntry[]>([])
export const wrapSigner = (signer: ISigner) =>
new WrappedSigner(signer, async <T>(method: string, thunk: () => Promise<T>) => {
const id = randomId()
signerLog.update(log => append({id, method, started_at: Date.now()}, log))
try {
const result = await thunk()
signerLog.update(log =>
log.map(x => (x.id === id ? {...x, finished_at: Date.now(), ok: true} : x)),
)
return result
} catch (error: any) {
signerLog.update(log =>
log.map(x => (x.id === id ? {...x, finished_at: Date.now(), ok: false} : x)),
)
throw error
}
})
export const getSigner = (session?: Session): ISigner | undefined => {
if (isNip07Session(session)) return wrapSigner(new Nip07Signer())
if (isNip01Session(session)) return wrapSigner(new Nip01Signer(session.secret))
if (isNip55Session(session)) return wrapSigner(new Nip55Signer(session.signer, session.pubkey))
if (isPomadeSession(session))
return wrapSigner(new PomadeSigner(new PomadeClient(session.clientOptions)))
if (isNip46Session(session)) {
const {
secret: clientSecret,
handler: {relays, pubkey: signerPubkey},
} = session
const broker = new Nip46Broker({clientSecret, signerPubkey, relays})
return wrapSigner(new Nip46Signer(broker))
}
export type SessionHandler<M extends string, D> = {
method: M
getSigner: (data: D) => MaybeAsync<ISigner>
}
/**
* Build a `User` (pubkey + signer) from a session descriptor. Returns undefined
* for sessions that can't sign (e.g. read-only Pubkey or Anonymous). Pass the
* result to `new Client({user})` / `createApp({user})`.
* Define a session handler. `M` and `D` are inferred from the arguments, so
* `getSigner` is type-checked against the data shape — and the same handler is
* used to build typed sessions (`toSession`) and to reconstruct signers.
*/
export const userFromSession = (session: Session, options: UserOptions = {}): User | undefined => {
const signer = getSigner(session)
export const defineSessionHandler = <M extends string, D>(handler: SessionHandler<M, D>) => handler
return signer && typeof session.pubkey === "string"
? new User(session.pubkey, signer, options)
: undefined
/** Build a typed, serializable session from a handler and its data. */
export const toSession = <M extends string, D>(
handler: SessionHandler<M, D>,
data: D,
): Session<M, D> => ({method: handler.method, data})
// ── Built-in handlers ──
export const nip01 = defineSessionHandler({
method: "nip01",
getSigner: (data: {secret: string}) => new Nip01Signer(data.secret),
})
export const nip07 = defineSessionHandler({
method: "nip07",
getSigner: (_data: Record<string, never>) => new Nip07Signer(),
})
export const nip46 = defineSessionHandler({
method: "nip46",
getSigner: (data: {clientSecret: string; signerPubkey: string; relays: string[]}) =>
new Nip46Signer(new Nip46Broker(data)),
})
export const nip55 = defineSessionHandler({
method: "nip55",
getSigner: (data: {pubkey: string; signer: string}) => new Nip55Signer(data.signer, data.pubkey),
})
export const pomade = defineSessionHandler({
method: "pomade",
getSigner: (data: {clientOptions: PomadeClientOptions; email: string}) =>
new PomadeSigner(new PomadeClient(data.clientOptions)),
})
// ── Registry: deserialize a stored session back into a signer ──
export const sessionHandlers = new Map<string, SessionHandler<string, any>>()
export const registerSessionHandler = (handler: SessionHandler<string, any>) => {
sessionHandlers.set(handler.method, handler)
}
// Login helpers — each returns a User to build a client/app with
export const unregisterSessionHandler = (handler: SessionHandler<string, any>) => {
sessionHandlers.delete(handler.method)
}
export const loginWithNip01 = (secret: string, options?: UserOptions) =>
userFromSession(makeNip01Session(secret), options)
export const getSignerFromSession = (session: Session): MaybeAsync<ISigner> | undefined =>
sessionHandlers.get(session.method)?.getSigner(session.data)
export const loginWithNip07 = (pubkey: string, options?: UserOptions) =>
userFromSession(makeNip07Session(pubkey), options)
// ── Initialize default session handlers ──
export const loginWithNip46 = (
pubkey: string,
clientSecret: string,
signerPubkey: string,
relays: string[],
options?: UserOptions,
) => userFromSession(makeNip46Session(pubkey, clientSecret, signerPubkey, relays), options)
export const loginWithNip55 = (pubkey: string, signer: string, options?: UserOptions) =>
userFromSession(makeNip55Session(pubkey, signer), options)
export const loginWithPomade = (
pubkey: string,
email: string,
clientOptions: PomadeClientOptions,
options?: UserOptions,
) => userFromSession(makePomadeSession(pubkey, email, clientOptions), options)
for (const sessionHandler of [nip01, nip07, nip46, nip55, pomade]) {
registerSessionHandler(sessionHandler)
}
+7 -7
View File
@@ -30,29 +30,29 @@ export class Stores {
getEventsById = (options: Omit<EventsByIdOptions, "repository">) =>
getEventsById({...options, repository: this.ctx.repository})
deriveEventsById = (options: Omit<EventsByIdOptions, "repository">) =>
eventsById = (options: Omit<EventsByIdOptions, "repository">) =>
deriveEventsById({...options, repository: this.ctx.repository})
deriveEvents = (options: Omit<EventsByIdOptions, "repository">) =>
events = (options: Omit<EventsByIdOptions, "repository">) =>
deriveEvents({...options, repository: this.ctx.repository})
makeDeriveEvent = (options: Omit<EventOptions, "repository">) =>
makeEvent = (options: Omit<EventOptions, "repository">) =>
makeDeriveEvent({...options, repository: this.ctx.repository})
getEventsByIdByUrl = (options: Omit<EventsByIdByUrlOptions, "tracker" | "repository">) =>
getEventsByIdByUrl({...options, tracker: this.ctx.tracker, repository: this.ctx.repository})
deriveEventsByIdByUrl = (options: Omit<EventsByIdByUrlOptions, "tracker" | "repository">) =>
eventsByIdByUrl = (options: Omit<EventsByIdByUrlOptions, "tracker" | "repository">) =>
deriveEventsByIdByUrl({...options, tracker: this.ctx.tracker, repository: this.ctx.repository})
getEventsByIdForUrl = (options: Omit<EventsByIdForUrlOptions, "tracker" | "repository">) =>
getEventsByIdForUrl({...options, tracker: this.ctx.tracker, repository: this.ctx.repository})
deriveEventsByIdForUrl = (options: Omit<EventsByIdForUrlOptions, "tracker" | "repository">) =>
eventsByIdForUrl = (options: Omit<EventsByIdForUrlOptions, "tracker" | "repository">) =>
deriveEventsByIdForUrl({...options, tracker: this.ctx.tracker, repository: this.ctx.repository})
deriveItemsByKey = <T>(options: Omit<ItemsByKeyOptions<T>, "repository">) =>
itemsByKey = <T>(options: Omit<ItemsByKeyOptions<T>, "repository">) =>
deriveItemsByKey<T>({...options, repository: this.ctx.repository})
deriveIsDeleted = (event: TrustedEvent) => deriveIsDeleted(this.ctx.repository, event)
isDeleted = (event: TrustedEvent) => deriveIsDeleted(this.ctx.repository, event)
}
+3 -3
View File
@@ -1,7 +1,7 @@
import {isSignedEvent} from "@welshman/util"
import type {Filter, SignedEvent} from "@welshman/util"
import type {IClient} from "./client.js"
import {Networking} from "./networking.js"
import {Network} from "./network.js"
import {Relays} from "./relays.js"
export type AppSyncOpts = {
@@ -32,7 +32,7 @@ export class Sync {
}
pull = async ({relays, filters}: AppSyncOpts) => {
const net = this.ctx.use(Networking)
const net = this.ctx.use(Network)
const events = this.query(filters).filter(isSignedEvent)
await Promise.all(
@@ -45,7 +45,7 @@ export class Sync {
}
push = async ({relays, filters}: AppSyncOpts) => {
const net = this.ctx.use(Networking)
const net = this.ctx.use(Network)
const events = this.query(filters).filter(isSignedEvent)
await Promise.all(
+1 -1
View File
@@ -31,7 +31,7 @@ export class Tags {
"p",
pubkey,
this.ctx.use(Router).FromPubkey(pubkey).getUrl() || "",
this.ctx.use(Profiles).display(pubkey),
this.ctx.use(Profiles).display(pubkey).get(),
]
tagEvent = (event: TrustedEvent, url = "", mark = "") => {
+177 -176
View File
@@ -1,7 +1,7 @@
import type {Subscriber} from "svelte/store"
import {writable} from "svelte/store"
import type {Override} from "@welshman/lib"
import {append, TaskQueue, ensurePlural, remove, defer, sleep, nth, without} from "@welshman/lib"
import {append, TaskQueue, ensurePlural, remove, defer, sleep, nth, uniq, without} from "@welshman/lib"
import {
HashedEvent,
EventTemplate,
@@ -14,13 +14,13 @@ import {
import {PublishStatus, PublishResult, PublishOptions, PublishResultsByRelay} from "@welshman/net"
import {Nip01Signer, Nip59} from "@welshman/signer"
import type {IClient} from "./client.js"
import {Networking} from "./networking.js"
import type {User} from "./user.js"
import {Network} from "./network.js"
import {Router, addMinimalFallbacks} from "./router.js"
import {User} from "./user.js"
export type ThunkOptions = Override<
PublishOptions,
{
user: User
client: IClient
event: EventTemplate
recipient?: string
@@ -29,21 +29,111 @@ export type ThunkOptions = Override<
}
>
export class Thunk {
_subs: Subscriber<Thunk>[] = []
event: HashedEvent
/**
* Shared base for `Thunk` and `MergedThunk`: a subscribable bag of per-relay
* publish `results`.
*/
export abstract class BaseThunk {
_subs: Subscriber<any>[] = []
results: PublishResultsByRelay = {}
abstract abort(): void
_notify() {
for (const subscriber of this._subs) {
subscriber(this)
}
}
subscribe(subscriber: Subscriber<this>) {
this._subs.push(subscriber)
subscriber(this)
return () => {
this._subs = remove(subscriber, this._subs)
}
}
getUrlsWithStatus(statuses: PublishStatus | PublishStatus[]) {
const matches = ensurePlural(statuses)
return Object.entries(this.results)
.filter(([_, {status}]) => matches.includes(status))
.map(nth(0)) as string[]
}
getCompleteUrls() {
return this.getUrlsWithStatus(
without([PublishStatus.Sending, PublishStatus.Pending], Object.values(PublishStatus)),
)
}
getIncompleteUrls() {
return this.getUrlsWithStatus([PublishStatus.Sending, PublishStatus.Pending])
}
getFailedUrls() {
return this.getUrlsWithStatus([PublishStatus.Failure, PublishStatus.Timeout])
}
hasStatus(statuses: PublishStatus | PublishStatus[]) {
return this.getUrlsWithStatus(statuses).length > 0
}
isComplete() {
return !this.hasStatus([PublishStatus.Sending, PublishStatus.Pending])
}
getError() {
for (const [_, {status, detail}] of Object.entries(this.results)) {
if (status === PublishStatus.Failure) {
return detail
}
}
if (this.isComplete()) {
return ""
}
}
waitForError() {
return new Promise<string>(resolve => {
this.subscribe(thunk => {
const error = thunk.getError()
if (error !== undefined) {
resolve(error)
}
})
})
}
waitForCompletion() {
return new Promise<void>(resolve => {
this.subscribe(thunk => {
if (thunk.isComplete()) {
resolve()
}
})
})
}
}
export class Thunk extends BaseThunk {
event: HashedEvent
complete = defer<void>()
controller = new AbortController()
wrap?: SignedEvent
constructor(readonly options: ThunkOptions) {
super()
if (!options.recipient && WRAPPED_KINDS.includes(options.event.kind)) {
throw new Error(`Attempted to publish a kind ${options.event.kind} without wrapping it`)
}
this.event = prep(options.event, this.options.user.pubkey)
this.event = prep(options.event, this.user.pubkey)
for (const relay of options.relays) {
this.results[relay] = {
@@ -64,10 +154,8 @@ export class Thunk {
})
}
_notify() {
for (const subscriber of this._subs) {
subscriber(this)
}
get user() {
return User.require(this.options.client)
}
_fail(detail: string) {
@@ -112,7 +200,7 @@ export class Thunk {
}
// Send it off
await this.options.client.use(Networking).publish({
await this.options.client.use(Network).publish({
...this.options,
event,
onSuccess: (result: PublishResult) => {
@@ -151,7 +239,7 @@ export class Thunk {
// If we're sending it privately, wrap the event using nip 59
if (recipient) {
const wrapper = Nip01Signer.ephemeral()
const nip59 = new Nip59(this.options.user.signer, wrapper)
const nip59 = new Nip59(this.user.signer, wrapper)
this.wrap = await nip59.wrap(recipient, this.event)
@@ -183,7 +271,7 @@ export class Thunk {
this.event = await makePow(this.event, this.options.pow).result
}
const signedEvent = await this.options.user.signer.sign(this.event, {
const signedEvent = await this.user.signer.sign(this.event, {
signal: AbortSignal.timeout(30_000),
})
@@ -205,205 +293,118 @@ export class Thunk {
}
}
enqueue() {
thunkQueue.push(this)
for (const url of this.options.relays) {
this.options.client.tracker.track(this.event.id, url)
}
this.options.client.repository.publish(this.event)
thunks.update($thunks => append(this, $thunks))
this.controller.signal.addEventListener("abort", () => {
if (this.wrap) {
this.options.client.wrapManager.remove(this.wrap.id)
} else {
this.options.client.repository.removeEvent(this.event.id)
}
thunks.update($thunks => remove(this, $thunks))
})
}
subscribe(subscriber: Subscriber<Thunk>) {
this._subs.push(subscriber)
subscriber(this)
return () => {
this._subs = remove(subscriber, this._subs)
}
abort() {
this.controller.abort()
}
}
export class MergedThunk {
_subs: Subscriber<MergedThunk>[] = []
results: PublishResultsByRelay = {}
export class MergedThunk extends BaseThunk {
constructor(readonly thunks: Thunk[]) {
super()
const {Aborted, Failure, Timeout, Pending, Sending, Success} = PublishStatus
const relays = new Set(thunks.flatMap(thunk => thunk.options.relays))
for (const thunk of thunks) {
thunk.subscribe($thunk => {
thunk.subscribe(() => {
this.results = {}
for (const relay of relays) {
for (const status of [Aborted, Failure, Timeout, Pending, Sending, Success]) {
const thunk = thunks.find(t => t.results[relay]?.status === status)
const match = thunks.find(t => t.results[relay]?.status === status)
if (thunk) {
this.results[relay] = thunk.results[relay]!
if (match) {
this.results[relay] = match.results[relay]!
}
}
}
this._notify()
if (thunks.every(thunkIsComplete)) {
if (thunks.every(t => t.isComplete())) {
this._subs = []
}
})
}
}
_notify() {
for (const subscriber of this._subs) {
subscriber(this)
}
}
subscribe(subscriber: Subscriber<MergedThunk>) {
this._subs.push(subscriber)
subscriber(this)
return () => {
this._subs = remove(subscriber, this._subs)
}
abort() {
this.thunks.forEach(thunk => thunk.abort())
}
}
export type AbstractThunk = Thunk | MergedThunk
/**
* Per-client thunk manager — the publish-side counterpart of `Network`. Owns
* the client's optimistic-publish `history` store and the `queue` that paces
* publishing. Reach it via `client.use(Thunks)`; `publish` fills in the client
* (the acting user is derived from it), enqueues the thunk (optimistically
* writing it to the repository), and returns it.
*/
export class Thunks {
history = writable<Thunk[]>([])
export const isThunk = (thunk: AbstractThunk): thunk is Thunk => thunk instanceof Thunk
export const isMergedThunk = (thunk: AbstractThunk): thunk is MergedThunk =>
thunk instanceof MergedThunk
// Thunk status urls
export const getThunkUrlsWithStatus = (
statuses: PublishStatus | PublishStatus[],
thunk: AbstractThunk,
) => {
statuses = ensurePlural(statuses)
return Object.entries(thunk.results)
.filter(([_, {status}]) => statuses.includes(status))
.map(nth(0)) as string[]
}
export const getCompleteThunkUrls = (thunk: AbstractThunk) =>
getThunkUrlsWithStatus(
without([PublishStatus.Sending, PublishStatus.Pending], Object.values(PublishStatus)),
thunk,
)
export const getIncompleteThunkUrls = (thunk: AbstractThunk) =>
getThunkUrlsWithStatus([PublishStatus.Sending, PublishStatus.Pending], thunk)
export const getFailedThunkUrls = (thunk: AbstractThunk) =>
getThunkUrlsWithStatus([PublishStatus.Failure, PublishStatus.Timeout], thunk)
// Thunk status checks
export const thunkHasStatus = (statuses: PublishStatus | PublishStatus[], thunk: AbstractThunk) =>
getThunkUrlsWithStatus(statuses, thunk).length > 0
export const thunkIsComplete = (thunk: AbstractThunk) =>
!thunkHasStatus([PublishStatus.Sending, PublishStatus.Pending], thunk)
// Thunk errors
export const getThunkError = (thunk: Thunk) => {
for (const [_, {status, detail}] of Object.entries(thunk.results)) {
if (status === PublishStatus.Failure) {
return detail
}
}
if (thunkIsComplete(thunk)) {
return ""
}
}
// Thunk utilities that return promises
export const waitForThunkError = (thunk: Thunk) =>
new Promise<string>(resolve => {
thunk.subscribe($thunk => {
const error = getThunkError($thunk)
if (error !== undefined) {
resolve(error)
}
})
queue = new TaskQueue<Thunk>({
batchSize: 10,
batchDelay: 100,
processItem: (thunk: Thunk) => {
thunk.publish()
},
})
export const waitForThunkCompletion = (thunk: Thunk) =>
new Promise<void>(resolve => {
thunk.subscribe($thunk => {
if (thunkIsComplete($thunk)) {
resolve()
constructor(readonly ctx: IClient) {}
enqueue(thunk: Thunk) {
this.queue.push(thunk)
for (const url of thunk.options.relays) {
this.ctx.tracker.track(thunk.event.id, url)
}
this.ctx.repository.publish(thunk.event)
this.history.update($history => append(thunk, $history))
thunk.controller.signal.addEventListener("abort", () => {
if (thunk.wrap) {
this.ctx.wrapManager.remove(thunk.wrap.id)
} else {
this.ctx.repository.removeEvent(thunk.event.id)
}
this.history.update($history => remove(thunk, $history))
})
})
}
// Thunk state
publish = (options: Omit<ThunkOptions, "client">) => {
const thunk = new Thunk({...options, client: this.ctx})
export const thunks = writable<Thunk[]>([])
this.enqueue(thunk)
export const thunkQueue = new TaskQueue<Thunk>({
batchSize: 10,
batchDelay: 100,
processItem: (thunk: Thunk) => {
thunk.publish()
},
})
return thunk
}
// Other thunk utilities
// Publish as the user to their outbox (write) relays
publishToOutbox = (options: Omit<ThunkOptions, "client" | "relays">) =>
this.publish({
...options,
relays: this.ctx.use(Router).FromUser().policy(addMinimalFallbacks).getUrls(),
})
export const mergeThunks = (thunks: AbstractThunk[]) =>
new MergedThunk(Array.from(flattenThunks(thunks)))
retry = (thunk: BaseThunk) =>
thunk instanceof MergedThunk
? new MergedThunk(thunk.thunks.map(t => this.publish(t.options)))
: this.publish((thunk as Thunk).options)
export function* flattenThunks(thunks: AbstractThunk[]): Iterable<Thunk> {
for (const thunk of thunks) {
if (isMergedThunk(thunk)) {
yield* flattenThunks(thunk.thunks)
} else {
yield thunk
merge(thunks: BaseThunk[]) {
return new MergedThunk(Array.from(this.flatten(thunks)))
}
*flatten(thunks: BaseThunk[]): Iterable<Thunk> {
for (const thunk of thunks) {
if (thunk instanceof MergedThunk) {
yield* this.flatten(thunk.thunks)
} else if (thunk instanceof Thunk) {
yield thunk
}
}
}
}
export const publishThunk = (options: ThunkOptions) => {
const thunk = new Thunk(options)
thunk.enqueue()
return thunk
}
export const abortThunk = (thunk: AbstractThunk) => {
for (const child of flattenThunks([thunk])) {
child.controller.abort()
}
}
export const retryThunk = (thunk: AbstractThunk) =>
isMergedThunk(thunk)
? mergeThunks(thunk.thunks.map(t => publishThunk(t.options)))
: publishThunk(thunk.options)
+2 -1
View File
@@ -2,6 +2,7 @@ import {readable} from "svelte/store"
import type {Readable} from "svelte/store"
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"
@@ -38,7 +39,7 @@ export class Topics {
}
this.byName = readable(topicsByName, set =>
on(ctx.repository, "update", ({added}: {added: {tags: string[][]}[]}) => {
on(ctx.repository, "update", ({added}: RepositoryUpdate) => {
let dirty = false
for (const event of added) {
+34 -14
View File
@@ -1,11 +1,9 @@
import {makeSocketPolicyAuth} from "@welshman/net"
import type {Socket} from "@welshman/net"
import type {StampedEvent} from "@welshman/util"
import type {ISigner} from "@welshman/signer"
export type UserOptions = {
shouldAuth?: (socket: Socket) => boolean
}
import {LoggingSigner} from "./logging.js"
import {getSignerFromSession} from "./session.js"
import type {Session} from "./session.js"
import type {IClient} from "./client.js"
/**
* A single identity: a pubkey plus the signer that proves it. A `Client` is
@@ -16,20 +14,42 @@ export class User {
constructor(
readonly pubkey: string,
readonly signer: ISigner,
readonly options: UserOptions = {},
) {}
static async fromSigner(signer: ISigner, options: UserOptions = {}) {
static async fromSigner(signer: ISigner) {
if (!(signer instanceof LoggingSigner)) {
signer = new LoggingSigner(signer)
}
const pubkey = await signer.getPubkey()
return new User(pubkey, signer, options)
return new User(pubkey, signer)
}
makeSocketPolicyAuth = () =>
makeSocketPolicyAuth({
sign: this.signer.sign,
shouldAuth: this.options.shouldAuth,
})
/**
* Reconstruct a signing user from a persisted session, using the registered
* session handlers to find the one for the session's method. The signer is
* wrapped in a `LoggingSigner` (observe it with `clientPolicyLogger`) and the
* pubkey is derived from it. Returns undefined when no handler is registered
* for the session's method.
*/
static async fromSession(session: Session): Promise<User | undefined> {
const signer = await getSignerFromSession(session)
return signer ? User.fromSigner(signer) : undefined
}
/**
* Return the client's signed-in user, throwing if there isn't one — the entry
* point for actions that can only run as a user (publishing, signing).
*/
static require(ctx: IClient): User {
if (!ctx.user) {
throw new Error("This action requires a signed-in user")
}
return ctx.user
}
sign = (event: StampedEvent) => this.signer.sign(event)
+176 -89
View File
@@ -1,132 +1,219 @@
import {derived, writable} from "svelte/store"
import type {Readable, Writable} from "svelte/store"
import {readable, derived} from "svelte/store"
import {max, throttle, addToMapKey, inc, dec} from "@welshman/lib"
import {getListTags, getPubkeyTagValues} from "@welshman/util"
import {throttled, getter} from "@welshman/store"
import type {List} from "@welshman/util"
import type {IClient} from "./client.js"
import {projection} from "./clientData.js"
import type {Projection} from "./clientData.js"
import {FollowLists} from "./follows.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
* built from the perspective of the client's user (or, with no user, the union
* of every known follow list) and updated reactively as lists change.
*
* The aggregate `*ByPubkey`/`graph`/`max` fields and the parameterized methods
* (`follows`, `wotScore`, …) are all `Projection`s — subscribe via `.$`, snapshot
* via `.get()`.
*/
export class Wot {
followersByPubkey: Readable<Map<string, Set<string>>>
mutersByPubkey: Readable<Map<string, Set<string>>>
wotGraph: Writable<Map<string, number>>
maxWot: Readable<number | undefined>
private getFollowersByPubkeyStore: () => Map<string, Set<string>>
private getMutersByPubkeyStore: () => Map<string, Set<string>>
private getWotGraphStore: () => Map<string, number>
private getMaxWotStore: () => number | undefined
followersByPubkey: Projection<Map<string, Set<string>>>
mutersByPubkey: Projection<Map<string, Set<string>>>
graph: Projection<Map<string, number>>
max: Projection<number | undefined>
constructor(readonly ctx: IClient) {
const followLists = this.ctx.use(FollowLists)
const muteLists = this.ctx.use(MuteLists)
const followersByPubkeyStore = readable(new Map<string, Set<string>>(), set =>
this.ctx.use(FollowLists).index.$.subscribe(
throttle(1000, lists => {
const $followersByPubkey = new Map<string, Set<string>>()
this.followersByPubkey = derived(throttled(1000, followLists.all), lists => {
const $followersByPubkey = new Map<string, Set<string>>()
for (const list of lists.values()) {
for (const pubkey of getPubkeyTagValues(getListTags(list))) {
addToMapKey($followersByPubkey, pubkey, list.event.pubkey)
}
}
for (const list of lists) {
for (const pubkey of getPubkeyTagValues(getListTags(list))) {
addToMapKey($followersByPubkey, pubkey, list.event.pubkey)
set($followersByPubkey)
}),
),
)
const mutersByPubkeyStore = readable(new Map<string, Set<string>>(), set =>
this.ctx.use(MuteLists).index.$.subscribe(
throttle(1000, lists => {
const $mutersByPubkey = new Map<string, Set<string>>()
for (const list of lists.values()) {
for (const pubkey of getPubkeyTagValues(getListTags(list))) {
addToMapKey($mutersByPubkey, pubkey, list.event.pubkey)
}
}
set($mutersByPubkey)
}),
),
)
const graphStore = readable(new Map<string, number>(), set => {
const rebuild = throttle(1000, () => {
const $followLists = this.ctx.use(FollowLists).index.get()
const $muteLists = this.ctx.use(MuteLists).index.get()
const $pubkey = this.ctx.user?.pubkey
const $graph = new Map<string, number>()
const roots = $pubkey ? listPubkeys($followLists.get($pubkey)) : Array.from($followLists.keys())
for (const follow of roots) {
for (const pubkey of listPubkeys($followLists.get(follow))) {
$graph.set(pubkey, inc($graph.get(pubkey)))
}
for (const pubkey of listPubkeys($muteLists.get(follow))) {
$graph.set(pubkey, dec($graph.get(pubkey)))
}
}
}
return $followersByPubkey
set($graph)
})
const unsubscribers = [
this.ctx.use(FollowLists).index.$.subscribe(rebuild),
this.ctx.use(MuteLists).index.$.subscribe(rebuild),
]
return () => unsubscribers.forEach(unsubscribe => unsubscribe())
})
this.mutersByPubkey = derived(throttled(1000, muteLists.all), lists => {
const $mutersByPubkey = new Map<string, Set<string>>()
const maxStore = derived(graphStore, $g => max(Array.from($g.values())))
for (const list of lists) {
for (const pubkey of getPubkeyTagValues(getListTags(list))) {
addToMapKey($mutersByPubkey, pubkey, list.event.pubkey)
this.followersByPubkey = projection(followersByPubkeyStore)
this.mutersByPubkey = projection(mutersByPubkeyStore)
this.graph = projection(graphStore)
this.max = projection(maxStore)
}
follows = (pubkey: string): Projection<string[]> => {
const read = ($lists: ReadonlyMap<string, List>) => 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[]> => {
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>) => {
const pubkeys = new Set(listPubkeys($lists.get(pubkey)))
const network = new Set<string>()
for (const follow of pubkeys) {
for (const tpk of listPubkeys($lists.get(follow))) {
if (!pubkeys.has(tpk)) {
network.add(tpk)
}
}
}
return $mutersByPubkey
})
this.wotGraph = writable(new Map<string, number>())
this.maxWot = derived(this.wotGraph, $g => max(Array.from($g.values())))
this.getFollowersByPubkeyStore = getter(this.followersByPubkey)
this.getMutersByPubkeyStore = getter(this.mutersByPubkey)
this.getWotGraphStore = getter(this.wotGraph)
this.getMaxWotStore = getter(this.maxWot)
followLists.subscribe(this.buildGraph)
muteLists.subscribe(this.buildGraph)
}
getFollows = (pubkey: string) =>
getPubkeyTagValues(getListTags(this.ctx.use(FollowLists).get(pubkey)))
getMutes = (pubkey: string) =>
getPubkeyTagValues(getListTags(this.ctx.use(MuteLists).get(pubkey)))
getNetwork = (pubkey: string) => {
const pubkeys = new Set(this.getFollows(pubkey))
const network = new Set<string>()
for (const follow of pubkeys) {
for (const tpk of this.getFollows(follow)) {
if (!pubkeys.has(tpk)) {
network.add(tpk)
}
}
return Array.from(network)
}
return Array.from(network)
return projection(derived(this.ctx.use(FollowLists).index.$, read), () =>
read(this.ctx.use(FollowLists).index.get()),
)
}
getFollowersByPubkey = () => this.getFollowersByPubkeyStore()
followers = (pubkey: string): Projection<string[]> => {
const read = ($followers: ReadonlyMap<string, Set<string>>) =>
Array.from($followers.get(pubkey) || [])
getMutersByPubkey = () => this.getMutersByPubkeyStore()
return projection(derived(this.followersByPubkey.$, read), () =>
read(this.followersByPubkey.get()),
)
}
getFollowers = (pubkey: string) => Array.from(this.getFollowersByPubkey().get(pubkey) || [])
muters = (pubkey: string): Projection<string[]> => {
const read = ($muters: ReadonlyMap<string, Set<string>>) =>
Array.from($muters.get(pubkey) || [])
getMuters = (pubkey: string) => Array.from(this.getMutersByPubkey().get(pubkey) || [])
return projection(derived(this.mutersByPubkey.$, read), () => read(this.mutersByPubkey.get()))
}
getFollowsWhoFollow = (pubkey: string, target: string) =>
this.getFollows(pubkey).filter(other => this.getFollows(other).includes(target))
followsWhoFollow = (pubkey: string, target: string): Projection<string[]> => {
const read = ($lists: ReadonlyMap<string, List>) =>
listPubkeys($lists.get(pubkey)).filter(other =>
listPubkeys($lists.get(other)).includes(target),
)
getFollowsWhoMute = (pubkey: string, target: string) =>
this.getFollows(pubkey).filter(other => this.getMutes(other).includes(target))
return projection(derived(this.ctx.use(FollowLists).index.$, read), () =>
read(this.ctx.use(FollowLists).index.get()),
)
}
getWotGraph = () => this.getWotGraphStore()
followsWhoMute = (pubkey: string, target: string): Projection<string[]> => {
const read = ($follows: ReadonlyMap<string, List>, $mutes: ReadonlyMap<string, List>) =>
listPubkeys($follows.get(pubkey)).filter(other =>
listPubkeys($mutes.get(other)).includes(target),
)
getMaxWot = () => this.getMaxWotStore()
return projection(
derived(
[this.ctx.use(FollowLists).index.$, this.ctx.use(MuteLists).index.$],
([$follows, $mutes]) => read($follows, $mutes),
),
() => read(this.ctx.use(FollowLists).index.get(), this.ctx.use(MuteLists).index.get()),
)
}
buildGraph = throttle(1000, () => {
const $pubkey = this.ctx.user?.pubkey
const $graph = new Map<string, number>()
const $follows = $pubkey
? this.getFollows($pubkey)
: Array.from(this.ctx.use(FollowLists).keys())
wotScore = (pubkey: string, target: string): Projection<number> => {
const read = (
$follows: ReadonlyMap<string, List>,
$mutes: ReadonlyMap<string, List>,
$followers: ReadonlyMap<string, Set<string>>,
$muters: ReadonlyMap<string, Set<string>>,
) => {
let follows: string[]
let mutes: string[]
for (const follow of $follows) {
for (const pubkey of this.getFollows(follow)) {
$graph.set(pubkey, inc($graph.get(pubkey)))
if (pubkey) {
const theirFollows = listPubkeys($follows.get(pubkey))
follows = theirFollows.filter(other => listPubkeys($follows.get(other)).includes(target))
mutes = theirFollows.filter(other => listPubkeys($mutes.get(other)).includes(target))
} else {
follows = Array.from($followers.get(target) || [])
mutes = Array.from($muters.get(target) || [])
}
for (const pubkey of this.getMutes(follow)) {
$graph.set(pubkey, dec($graph.get(pubkey)))
}
return follows.length - mutes.length
}
this.wotGraph.set($graph)
})
getWotScore = (pubkey: string, target: string) => {
const follows = pubkey ? this.getFollowsWhoFollow(pubkey, target) : this.getFollowers(target)
const mutes = pubkey ? this.getFollowsWhoMute(pubkey, target) : this.getMuters(target)
return follows.length - mutes.length
return projection(
derived(
[
this.ctx.use(FollowLists).index.$,
this.ctx.use(MuteLists).index.$,
this.followersByPubkey.$,
this.mutersByPubkey.$,
],
([$follows, $mutes, $followers, $muters]) => read($follows, $mutes, $followers, $muters),
),
() =>
read(
this.ctx.use(FollowLists).index.get(),
this.ctx.use(MuteLists).index.get(),
this.followersByPubkey.get(),
this.mutersByPubkey.get(),
),
)
}
}
+68 -43
View File
@@ -1,4 +1,4 @@
import {writable} from "svelte/store"
import type {Readable} from "svelte/store"
import {
removeUndefined,
fetchJson,
@@ -8,10 +8,12 @@ import {
batcher,
postJson,
} from "@welshman/lib"
import {getTagValues, zapFromEvent} from "@welshman/util"
import type {Maybe} from "@welshman/lib"
import {getTagValue, getZapSplits, zapFromEvent} from "@welshman/util"
import type {Zapper, Zap, TrustedEvent} from "@welshman/util"
import {deriveDeduplicated} from "@welshman/store"
import {LoadableData} from "./clientData.js"
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 {Profiles} from "./profiles.js"
@@ -22,10 +24,6 @@ import {Profiles} from "./profiles.js"
* profiles collection to resolve a pubkey's lnurl.
*/
export class Zappers extends LoadableData<Zapper> {
constructor(ctx: IClient) {
super(ctx)
}
fetch = batcher(800, async (lnurls: string[]) => {
const result = new Map<string, Zapper>()
const valid = lnurls.filter(lnurl => lnurl.startsWith("lnurl1"))
@@ -40,7 +38,6 @@ export class Zappers extends LoadableData<Zapper> {
}
}
// Use dufflepud if it's set up to protect user privacy, otherwise fetch directly
if (this.ctx.config.dufflepudUrl) {
const hexUrls = valid.map(bech32ToHex)
const res: any = await tryCatch(
@@ -72,55 +69,83 @@ export class Zappers extends LoadableData<Zapper> {
return $profile?.lnurl ? this.load($profile.lnurl) : undefined
}
deriveForPubkey = (pubkey: string, relays: string[] = []) => {
forPubkey = (pubkey: string, relays: string[] = []): Projection<Maybe<Zapper>> => {
this.loadForPubkey(pubkey, relays)
return deriveDeduplicated(
[this.index, this.ctx.use(Profiles).derive(pubkey, relays)],
([$zappersByLnurl, $profile]) =>
$profile?.lnurl ? $zappersByLnurl.get($profile.lnurl) : undefined,
const read = ([$zappersByLnurl, $profile]: [ReadonlyMap<string, Zapper>, Maybe<{lnurl?: string}>]) =>
$profile?.lnurl ? $zappersByLnurl.get($profile.lnurl) : undefined
return projection(
deriveDeduplicated([this.index.$, this.ctx.use(Profiles).one(pubkey, relays)], read),
() => read([this.index.get(), this.ctx.use(Profiles).get(pubkey)]),
)
}
getLnUrlsForEvent = async (event: TrustedEvent) => {
const pubkeys = getTagValues("zap", event.tags)
/**
* Resolve the zapper a zap receipt should be validated against. A receipt's
* `p` tag is the recipient (copied from the zap request), so we honor only
* receipts addressed to one of the parent's designated split recipients and
* load *that* recipient's zapper. The old lookup always used the first
* recipient's lnurl, which silently dropped legitimate zaps to any of the
* other split recipients.
*/
loadZapperForZap = async (zapReceipt: TrustedEvent, parent: TrustedEvent) => {
const recipient = getTagValue("p", zapReceipt.tags)
const split = getZapSplits(parent).find(split => split.pubkey === recipient)
if (pubkeys.length > 0) {
const profiles = await Promise.all(pubkeys.map(pubkey => this.ctx.use(Profiles).load(pubkey)))
const lnurls = removeUndefined(profiles.map(profile => profile?.lnurl))
if (!split) return
if (lnurls.length > 0) {
return lnurls
}
return this.loadForPubkey(split.pubkey, removeUndefined([split.relay]))
}
validateZapReceipt = async (zapReceipt: TrustedEvent, parent: TrustedEvent) => {
const zapper = await this.loadZapperForZap(zapReceipt, parent)
return zapper ? zapFromEvent(zapReceipt, zapper) : undefined
}
validateZapReceipts = async (zapReceipts: TrustedEvent[], parent: TrustedEvent) =>
removeUndefined(
await Promise.all(zapReceipts.map(zapReceipt => this.validateZapReceipt(zapReceipt, parent))),
)
validZapReceipts = (zapReceipts: TrustedEvent[], parent: TrustedEvent): Projection<Zap[]> => {
const splits = getZapSplits(parent)
const profiles = this.ctx.use(Profiles)
// Ensure each recipient's profile (-> lnurl) and zapper are being loaded.
for (const split of splits) {
this.loadForPubkey(split.pubkey, removeUndefined([split.relay]))
}
const profile = await this.ctx.use(Profiles).load(event.pubkey)
const read = (values: any[]) => {
const $zappersByLnurl = values[0] as Map<string, Zapper>
const $profiles = values.slice(1) as Array<{lnurl?: string} | undefined>
return removeUndefined([profile?.lnurl])
}
const zapperByPubkey = new Map<string, Zapper>()
getZapperForZap = async (zap: TrustedEvent, parent: TrustedEvent) => {
const lnurls = await this.getLnUrlsForEvent(parent)
splits.forEach((split, i) => {
const lnurl = $profiles[i]?.lnurl
const zapper = lnurl ? $zappersByLnurl.get(lnurl) : undefined
return lnurls.length > 0 ? this.load(lnurls[0]) : undefined
}
if (zapper) zapperByPubkey.set(split.pubkey, zapper)
})
getValidZap = async (zap: TrustedEvent, parent: TrustedEvent) => {
const zapper = await this.getZapperForZap(zap, parent)
return removeUndefined(
zapReceipts.map(zapReceipt => {
const recipient = getTagValue("p", zapReceipt.tags)
const zapper = recipient ? zapperByPubkey.get(recipient) : undefined
return zapper ? zapFromEvent(zap, zapper) : undefined
}
return zapper ? zapFromEvent(zapReceipt, zapper) : undefined
}),
)
}
getValidZaps = async (zaps: TrustedEvent[], parent: TrustedEvent) =>
removeUndefined(await Promise.all(zaps.map(zap => this.getValidZap(zap, parent))))
const stores: Readable<any>[] = [this.index.$, ...splits.map(split => profiles.one(split.pubkey))]
deriveValidZaps = (zaps: TrustedEvent[], parent: TrustedEvent) => {
const store = writable<Zap[]>([])
this.getValidZaps(zaps, parent).then(validZaps => {
store.set(validZaps)
})
return store
return projection(
deriveDeduplicatedByValue(stores, read),
() => read([this.index.get(), ...splits.map(split => profiles.get(split.pubkey))]),
)
}
}
+11
View File
@@ -1635,6 +1635,17 @@ export const member =
(x: T) =>
Array.from(xs).includes(x)
/** Returns a function that checks whether all predicates pass */
export const allPass =
<T>(...predicates: ((x: T) => unknown)[]) =>
(x: T) => predicates.every(predicate => predicate(x))
/** Returns a function that checks whether some predicate passes */
export const somePass =
<T>(...predicates: ((x: T) => unknown)[]) =>
(x: T) => predicates.some(predicate => predicate(x))
// ----------------------------------------------------------------------------
// Sets
// ----------------------------------------------------------------------------
+2 -15
View File
@@ -5,28 +5,15 @@ import {defaultSocketPolicies} from "./policy.js"
export type PoolSubscription = (socket: Socket) => void
export type PoolOptions = {
makeSocket?: (url: string) => Socket
}
export class Pool {
socketPolicies = [...defaultSocketPolicies]
_data = new Map<string, Socket>()
_subs: PoolSubscription[] = []
constructor(readonly options: PoolOptions = {}) {}
has(url: string) {
return this._data.has(normalizeRelayUrl(url))
}
makeSocket(url: string) {
if (this.options.makeSocket) {
return this.options.makeSocket(url)
}
return new Socket(url, defaultSocketPolicies)
}
get(_url: string): Socket {
const url = normalizeRelayUrl(_url)
const socket = this._data.get(url)
@@ -35,7 +22,7 @@ export class Pool {
return socket
}
const newSocket = this.makeSocket(url)
const newSocket = new Socket(url, this.socketPolicies)
this._data.set(url, newSocket)
+1 -1
View File
@@ -345,4 +345,4 @@ export const makeLoader = (options: LoaderOptions) =>
return allRequests.map(r => resultsByRequest.get(r) || [])
}) as Loader
export const load = makeLoader({delay: 200, timeout: 3000, threshold: 0.5})
export const load = makeLoader({delay: 30, timeout: 3000, threshold: 0.5})
+47
View File
@@ -0,0 +1,47 @@
import {fetchJson, last} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
/**
* NIP-05: mapping nostr public keys to DNS-based internet identifiers (e.g.
* `name@example.com`), resolved via each domain's `/.well-known/nostr.json`.
*/
export type Handle = {
nip05: string
pubkey?: string
nip46?: string[]
relays?: string[]
}
export async function queryProfile(nip05: string): Promise<Maybe<Handle>> {
const parts = nip05.split("@")
const name = parts.length > 1 ? parts[0] : "_"
const domain = last(parts)
try {
const {
names,
relays = {},
nip46 = {},
} = await fetchJson(`https://${domain}/.well-known/nostr.json?name=${name}`)
const pubkey = names[name]
if (!pubkey) {
return undefined
}
return {
nip05,
pubkey,
nip46: nip46[pubkey],
relays: relays[pubkey],
}
} catch (_e) {
return undefined
}
}
export const displayNip05 = (nip05: string) =>
nip05?.startsWith("_@") ? last(nip05.split("@")) : nip05
export const displayHandle = (handle: Handle) => displayNip05(handle.nip05)
+1 -1
View File
@@ -123,7 +123,7 @@ export const ROOM_JOIN = 9021
export const ROOM_LEAVE = 9022
export const ZAP_GOAL = 9041
export const ZAP_REQUEST = 9734
export const ZAP_RESPONSE = 9735
export const ZAP_RECEIPT = 9735
export const HIGHLIGHT = 9802
export const MUTES = 10000
export const PINS = 10001
+76 -3
View File
@@ -1,5 +1,5 @@
import {now, tryCatch, fetchJson, hexToBech32, fromPairs} from "@welshman/lib"
import {ZAP_RESPONSE, ZAP_REQUEST} from "./Kinds.js"
import {now, tryCatch, fetchJson, hexToBech32, fromPairs, sum, allPass, nthEq, nth} from "@welshman/lib"
import {ZAP_RECEIPT, ZAP_REQUEST} from "./Kinds.js"
import {getTagValue} from "./Tags.js"
import type {Filter} from "./Filters.js"
import type {TrustedEvent, SignedEvent} from "./Events.js"
@@ -138,6 +138,79 @@ export const zapFromEvent = (response: TrustedEvent, zapper: Zapper | undefined)
return zap
}
/**
* A single recipient of an event's zaps, parsed from a NIP-57 Appendix G `zap`
* tag of the form `["zap", <pubkey>, <relay hint>, <weight>]`.
*/
export type ZapSplit = {
pubkey: string
relay?: string
weight: number
}
export type ZapSplitAmount = ZapSplit & {amount: number}
const parseWeight = (weight: string | undefined) => {
const n = weight === undefined ? NaN : parseFloat(weight)
return Number.isFinite(n) && n > 0 ? n : 0
}
/**
* Resolve an event's zap-split recipients per NIP-57 Appendix G:
*
* - With no `zap` tags the whole zap goes to the event's author.
* - If no recipient carries a weight, the zap is split equally (weight 1 each).
* - If weights are only partially present, unweighted recipients drop to weight
* 0 (i.e. they should not be zapped).
*
* Weight-0 recipients are still returned so callers have the full recipient set;
* they simply receive 0 from `splitZapAmount`.
*/
export const getZapSplits = (event: TrustedEvent): ZapSplit[] => {
const zapTags = event.tags.filter(allPass(nthEq(0, "zap"), nth(1)))
if (zapTags.length === 0) {
return [{pubkey: event.pubkey, weight: 1}]
}
const anyWeighted = zapTags.some(nth(3))
return zapTags.map(([, pubkey, relay, weight]) => ({
pubkey,
relay: relay || undefined,
weight: anyWeighted ? parseWeight(weight) : 1,
}))
}
/**
* Divide `total` (in any integer unit, e.g. millisats) across an event's
* zap-split recipients proportionally to their weights. Any rounding remainder
* is handed to the highest-weighted recipient so the parts sum back to exactly
* `total`. If every weight is 0, nobody is zapped.
*/
export const splitZapAmount = (event: TrustedEvent, total: number): ZapSplitAmount[] => {
const splits = getZapSplits(event)
const totalWeight = sum(splits.map(split => split.weight))
if (totalWeight === 0) {
return splits.map(split => ({...split, amount: 0}))
}
const amounts = splits.map(split => Math.floor((total * split.weight) / totalWeight))
let maxIndex = 0
splits.forEach((split, i) => {
if (split.weight > splits[maxIndex].weight) {
maxIndex = i
}
})
amounts[maxIndex] += total - sum(amounts)
return splits.map((split, i) => ({...split, amount: amounts[i]}))
}
export type ZapRequestParams = {
msats: number
zapper: Zapper
@@ -201,7 +274,7 @@ export const getZapResponseFilter = ({zapper, pubkey, eventId}: ZapResponseFilte
}
const filter: Filter = {
kinds: [ZAP_RESPONSE],
kinds: [ZAP_RECEIPT],
authors: [zapper.nostrPubkey],
since: now() - 30,
"#p": [pubkey],
+1
View File
@@ -4,6 +4,7 @@ export * from "./Encryptable.js"
export * from "./Events.js"
export * from "./Filters.js"
export * from "./Handler.js"
export * from "./Handles.js"
export * from "./Keys.js"
export * from "./Kinds.js"
export * from "./Links.js"