Compare commits
16 Commits
master
...
72ab746254
| Author | SHA1 | Date | |
|---|---|---|---|
| 72ab746254 | |||
| aae201414d | |||
| f5124a6c4e | |||
| 28219eb64f | |||
| bc728c680e | |||
| abb9f20747 | |||
| 163d2dc355 | |||
| 9094d30b89 | |||
| f8130da2bb | |||
| 2e12010e26 | |||
| 87d8a0832d | |||
| 34065a18cf | |||
| 96b0116c9b | |||
| ea9cc0bf26 | |||
| 28339976b9 | |||
| e0e9ad5834 |
@@ -2,4 +2,5 @@ node_modules
|
|||||||
docs
|
docs
|
||||||
docs/reference
|
docs/reference
|
||||||
docs/.vitepress/cache
|
docs/.vitepress/cache
|
||||||
|
dist
|
||||||
build
|
build
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ results
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.vscode
|
.vscode
|
||||||
docs/**/*.html
|
docs/**/*.html
|
||||||
|
.local
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
build
|
||||||
|
normalize-url
|
||||||
|
__tests__
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "@welshman/client",
|
||||||
|
"version": "0.8.13",
|
||||||
|
"author": "hodlbod",
|
||||||
|
"license": "MIT",
|
||||||
|
"description": "An instance-based, composable client for building nostr applications",
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/client/src/index.js",
|
||||||
|
"types": "dist/client/src/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "pnpm run clean && pnpm run compile --force",
|
||||||
|
"clean": "rimraf ./dist",
|
||||||
|
"compile": "tsc -b tsconfig.build.json",
|
||||||
|
"prepublishOnly": "pnpm run build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"fuse.js": "^7.0.0",
|
||||||
|
"throttle-debounce": "^5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@pomade/core": "^0.2.1",
|
||||||
|
"@welshman/feeds": "workspace:*",
|
||||||
|
"@welshman/lib": "workspace:*",
|
||||||
|
"@welshman/net": "workspace:*",
|
||||||
|
"@welshman/router": "workspace:*",
|
||||||
|
"@welshman/signer": "workspace:*",
|
||||||
|
"@welshman/store": "workspace:*",
|
||||||
|
"@welshman/util": "workspace:*",
|
||||||
|
"svelte": "^4.0.0 || ^5.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"rimraf": "~6.0.0",
|
||||||
|
"typescript": "~5.8.0",
|
||||||
|
"@pomade/core": "^0.2.1",
|
||||||
|
"@types/throttle-debounce": "^5.0.2",
|
||||||
|
"@welshman/feeds": "workspace:*",
|
||||||
|
"@welshman/lib": "workspace:*",
|
||||||
|
"@welshman/net": "workspace:*",
|
||||||
|
"@welshman/router": "workspace:*",
|
||||||
|
"@welshman/signer": "workspace:*",
|
||||||
|
"@welshman/store": "workspace:*",
|
||||||
|
"@welshman/util": "workspace:*",
|
||||||
|
"svelte": "^5.39.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import {
|
||||||
|
BLOCKED_RELAYS,
|
||||||
|
asDecryptedEvent,
|
||||||
|
readList,
|
||||||
|
getRelaysFromList,
|
||||||
|
makeList,
|
||||||
|
makeEvent,
|
||||||
|
addToListPublicly,
|
||||||
|
removeFromList,
|
||||||
|
} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
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"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kind-10006 blocked-relay lists, keyed by pubkey. Loaded via the outbox model,
|
||||||
|
* so it depends on the relay-list collection. Feeds `RelayStats.getQuality` so
|
||||||
|
* blocked relays are never selected.
|
||||||
|
*/
|
||||||
|
export class BlockedRelayLists extends DerivedData<ReturnType<typeof readList>> {
|
||||||
|
constructor(ctx: IClient) {
|
||||||
|
super(ctx, {
|
||||||
|
filters: [{kinds: [BLOCKED_RELAYS]}],
|
||||||
|
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
||||||
|
getKey: list => list.event.pubkey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(pubkey: string, relayHints: string[] = []) {
|
||||||
|
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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import {BLOSSOM_SERVERS, asDecryptedEvent, readList} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
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 DerivedData<ReturnType<typeof readList>> {
|
||||||
|
constructor(ctx: IClient) {
|
||||||
|
super(ctx, {
|
||||||
|
filters: [{kinds: [BLOSSOM_SERVERS]}],
|
||||||
|
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
||||||
|
getKey: list => list.event.pubkey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(pubkey: string, relayHints: string[] = []) {
|
||||||
|
return this.ctx.use(Network).loadUsingOutbox(pubkey, {kinds: [BLOSSOM_SERVERS]}, relayHints)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import type {Unsubscriber} from "svelte/store"
|
||||||
|
import {call} from "@welshman/lib"
|
||||||
|
import {Pool, Tracker, Repository, WrapManager} from "@welshman/net"
|
||||||
|
import type {NetContext, AdapterFactory} from "@welshman/net"
|
||||||
|
import type {User} from "./user.js"
|
||||||
|
import type {ClientPolicy} from "./policies.js"
|
||||||
|
|
||||||
|
export type ClientConfig = {
|
||||||
|
dufflepudUrl?: string
|
||||||
|
getDefaultRelays?: () => string[]
|
||||||
|
getIndexerRelays?: () => string[]
|
||||||
|
getSearchRelays?: () => string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClientOptions = {
|
||||||
|
user?: User
|
||||||
|
config?: ClientConfig
|
||||||
|
getAdapter?: AdapterFactory
|
||||||
|
policies?: ClientPolicy[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IClient {
|
||||||
|
user?: User
|
||||||
|
config: ClientConfig
|
||||||
|
use: <T>(Ctor: new (ctx: IClient) => T) => T
|
||||||
|
netContext: NetContext
|
||||||
|
pool: Pool
|
||||||
|
tracker: Tracker
|
||||||
|
repository: Repository
|
||||||
|
wrapManager: WrapManager
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The core of an application instance. Owns the primitives a single identity
|
||||||
|
* needs (so data never bleeds across sessions) — a private repository, a socket
|
||||||
|
* pool, a tracker, a wrap manager — and a `use` registry that resolves data
|
||||||
|
* modules (including net/store helpers) on demand.
|
||||||
|
*/
|
||||||
|
export class Client implements IClient {
|
||||||
|
user?: User
|
||||||
|
config: ClientConfig
|
||||||
|
netContext: NetContext
|
||||||
|
pool: Pool
|
||||||
|
tracker: Tracker
|
||||||
|
repository: Repository
|
||||||
|
wrapManager: WrapManager
|
||||||
|
|
||||||
|
private singletons = new Map<Function, unknown>()
|
||||||
|
private unsubscribers: Unsubscriber[] = []
|
||||||
|
|
||||||
|
constructor(options: ClientOptions = {}) {
|
||||||
|
this.user = options.user
|
||||||
|
this.config = options.config ?? {}
|
||||||
|
this.pool = new Pool()
|
||||||
|
this.tracker = new Tracker()
|
||||||
|
this.repository = new Repository()
|
||||||
|
this.wrapManager = new WrapManager({
|
||||||
|
tracker: this.tracker,
|
||||||
|
repository: this.repository,
|
||||||
|
})
|
||||||
|
this.netContext = {
|
||||||
|
pool: this.pool,
|
||||||
|
repository: this.repository,
|
||||||
|
getAdapter: options.getAdapter,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const policy of options.policies ?? []) {
|
||||||
|
this.unsubscribers.push(policy(this))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the per-client singleton of a data module, constructing it on first
|
||||||
|
// use. This is how modules reach their dependencies (e.g. ctx.use(RelayLists)),
|
||||||
|
// replacing constructor injection and letting cycles resolve lazily.
|
||||||
|
use = <T>(Ctor: new (ctx: IClient) => T): T => {
|
||||||
|
let instance = this.singletons.get(Ctor) as T | undefined
|
||||||
|
|
||||||
|
if (!instance) {
|
||||||
|
this.singletons.set(Ctor, (instance = new Ctor(this)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return instance
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
this.unsubscribers.forEach(call)
|
||||||
|
this.pool.clear()
|
||||||
|
this.tracker.clear()
|
||||||
|
this.repository.clear()
|
||||||
|
this.wrapManager.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import {writable} from "svelte/store"
|
||||||
|
import type {Readable, Unsubscriber} from "svelte/store"
|
||||||
|
import type {Maybe} from "@welshman/lib"
|
||||||
|
import type {Filter} from "@welshman/util"
|
||||||
|
import {deriveItems, getter, makeDeriveItem, makeLoadItem, makeForceLoadItem} from "@welshman/store"
|
||||||
|
import type {EventToItem, ItemsByKey, MakeLoadItemOptions} from "@welshman/store"
|
||||||
|
import type {IClient} from "./client.js"
|
||||||
|
import {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.
|
||||||
|
*
|
||||||
|
* `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 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.index = projection(this.store)
|
||||||
|
this.all = projection(deriveItems(this.store))
|
||||||
|
this.one = makeDeriveItem(this.store)
|
||||||
|
}
|
||||||
|
|
||||||
|
keys = () => this.index.get().keys()
|
||||||
|
|
||||||
|
values = () => this.index.get().values()
|
||||||
|
|
||||||
|
get = (key: string) => this.index.get().get(key)
|
||||||
|
|
||||||
|
set = (key: string, value: T) => {
|
||||||
|
this.store.update($items => {
|
||||||
|
$items.set(key, value)
|
||||||
|
|
||||||
|
return $items
|
||||||
|
})
|
||||||
|
|
||||||
|
this.emitItem(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete = (key: string) => {
|
||||||
|
this.store.update($items => {
|
||||||
|
$items.delete(key)
|
||||||
|
|
||||||
|
return $items
|
||||||
|
})
|
||||||
|
|
||||||
|
this.emitItem(key, undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
clear = () => {
|
||||||
|
const keys = Array.from(this.index.get().keys())
|
||||||
|
|
||||||
|
this.store.set(new Map())
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
this.emitItem(key, undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onItem = (subscriber: (key: string, value: Maybe<T>) => void): Unsubscriber => {
|
||||||
|
this.subs.push(subscriber)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const i = this.subs.indexOf(subscriber)
|
||||||
|
|
||||||
|
if (i !== -1) this.subs.splice(i, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected emitItem = (key: string, value: Maybe<T>) => {
|
||||||
|
for (const subscriber of this.subs) {
|
||||||
|
subscriber(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `ClientData` collection that knows how to lazily load items by key from the
|
||||||
|
* network. Subclasses implement `fetch`; `load`/`forceLoad`/`one` are derived
|
||||||
|
* from it (with per-key caching and backoff via `makeLoadItem`).
|
||||||
|
*/
|
||||||
|
export abstract class LoadableData<T> extends ClientData<T> {
|
||||||
|
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(ctx: IClient, options: MakeLoadItemOptions = {}) {
|
||||||
|
super(ctx)
|
||||||
|
|
||||||
|
// Subclasses implement `fetch` as an arrow field, whose initializer runs
|
||||||
|
// *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, 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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import {Client} from "./client.js"
|
||||||
|
import type {ClientOptions} from "./client.js"
|
||||||
|
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(FollowLists)`, etc.
|
||||||
|
*
|
||||||
|
* For a bare client (no default side effects) construct `new Client(...)`
|
||||||
|
* directly, or pass your own `policies`.
|
||||||
|
*/
|
||||||
|
export const createApp = (options: ClientOptions = {}) =>
|
||||||
|
new Client({...options, policies: options.policies ?? defaultClientPolicies})
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import {Scope, FeedController} from "@welshman/feeds"
|
||||||
|
import type {FeedControllerOptions, Feed} from "@welshman/feeds"
|
||||||
|
import type {AdapterContext} from "@welshman/net"
|
||||||
|
import type {IClient} from "./client.js"
|
||||||
|
import {Router} from "./router.js"
|
||||||
|
import {Wot} from "./wot.js"
|
||||||
|
|
||||||
|
export type MakeFeedControllerOptions = Partial<Omit<FeedControllerOptions, "feed">> & {feed: Feed}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds `FeedController`s wired to this client. Scope/WOT pubkey resolution is
|
||||||
|
* delegated to `Wot`, and feeds fetch through THIS client's net context (pool +
|
||||||
|
* repository) rather than the global one.
|
||||||
|
*/
|
||||||
|
export class Feeds {
|
||||||
|
constructor(readonly ctx: IClient) {}
|
||||||
|
|
||||||
|
getPubkeysForScope = (scope: Scope): string[] => {
|
||||||
|
const $pubkey = this.ctx.user?.pubkey
|
||||||
|
|
||||||
|
if (!$pubkey) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (scope) {
|
||||||
|
case Scope.Self:
|
||||||
|
return [$pubkey]
|
||||||
|
case Scope.Follows:
|
||||||
|
return this.ctx.use(Wot).follows($pubkey).get()
|
||||||
|
case Scope.Network:
|
||||||
|
return this.ctx.use(Wot).network($pubkey).get()
|
||||||
|
case Scope.Followers:
|
||||||
|
return this.ctx.use(Wot).followers($pubkey).get()
|
||||||
|
default:
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getPubkeysForWOTRange = (min: number, max: number): string[] => {
|
||||||
|
const pubkeys = []
|
||||||
|
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).graph.get().entries()) {
|
||||||
|
if (score >= thresholdMin && score <= thresholdMax) {
|
||||||
|
pubkeys.push(tpk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pubkeys
|
||||||
|
}
|
||||||
|
|
||||||
|
// The net seam: route feed requests through this client's pool/repository so
|
||||||
|
// feeds fetch through THIS client rather than the global net context.
|
||||||
|
get netContext(): AdapterContext {
|
||||||
|
return {pool: this.ctx.pool, repository: this.ctx.repository}
|
||||||
|
}
|
||||||
|
|
||||||
|
makeFeedController = (options: MakeFeedControllerOptions) =>
|
||||||
|
new FeedController({
|
||||||
|
router: this.ctx.use(Router),
|
||||||
|
getPubkeysForScope: this.getPubkeysForScope,
|
||||||
|
getPubkeysForWOTRange: this.getPubkeysForWOTRange,
|
||||||
|
signer: this.ctx.user?.signer,
|
||||||
|
context: this.netContext,
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import {
|
||||||
|
FOLLOWS,
|
||||||
|
asDecryptedEvent,
|
||||||
|
readList,
|
||||||
|
makeList,
|
||||||
|
addToListPublicly,
|
||||||
|
removeFromList,
|
||||||
|
} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
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 DerivedData<ReturnType<typeof readList>> {
|
||||||
|
constructor(ctx: IClient) {
|
||||||
|
super(ctx, {
|
||||||
|
filters: [{kinds: [FOLLOWS]}],
|
||||||
|
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
||||||
|
getKey: followList => followList.event.pubkey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(pubkey: string, relayHints: string[] = []) {
|
||||||
|
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})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import {get, writable} from "svelte/store"
|
||||||
|
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
|
||||||
|
* messages into its own repository — which is what keeps DM history from being
|
||||||
|
* merged across identities. The repository subscription that feeds it lives in
|
||||||
|
* `clientPolicyGiftWraps`.
|
||||||
|
*/
|
||||||
|
export class GiftWraps {
|
||||||
|
failedUnwraps = new Set<string>()
|
||||||
|
queue: TaskQueue<TrustedEvent>
|
||||||
|
|
||||||
|
constructor(readonly ctx: IClient) {
|
||||||
|
this.queue = new TaskQueue<TrustedEvent>({
|
||||||
|
batchSize: 50,
|
||||||
|
batchDelay: 30,
|
||||||
|
processItem: async (wrap: TrustedEvent) => {
|
||||||
|
const signer = this.ctx.user?.signer
|
||||||
|
const recipient = this.ctx.user?.pubkey
|
||||||
|
|
||||||
|
// Only unwrap messages addressed to our user
|
||||||
|
if (!signer || !recipient || !getPubkeyTagValues(wrap.tags).includes(recipient)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rumor = await Nip59.fromSigner(signer).unwrap(wrap as SignedEvent)
|
||||||
|
|
||||||
|
this.ctx.wrapManager.add({wrap: wrap as SignedEvent, rumor, recipient})
|
||||||
|
} catch (e) {
|
||||||
|
this.failedUnwraps.add(wrap.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueue = (wrap: TrustedEvent) => {
|
||||||
|
if (this.failedUnwraps.has(wrap.id)) return
|
||||||
|
if (this.ctx.wrapManager.getRumor(wrap.id)) return
|
||||||
|
|
||||||
|
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})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
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, projection} from "./clientData.js"
|
||||||
|
import type {Projection} from "./clientData.js"
|
||||||
|
import type {IClient} from "./client.js"
|
||||||
|
import {Profiles} from "./profiles.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-05 handles, keyed by nip05 identifier. A "local" loadable collection:
|
||||||
|
* items aren't nostr events, they're fetched over HTTP (either directly from
|
||||||
|
* each domain's `.well-known/nostr.json`, or via a dufflepud proxy to protect
|
||||||
|
* user privacy). Depends on the profiles collection to resolve a pubkey's
|
||||||
|
* handle.
|
||||||
|
*/
|
||||||
|
export class Handles extends LoadableData<Handle> {
|
||||||
|
constructor(ctx: IClient) {
|
||||||
|
super(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch = batcher(800, async (nip05s: string[]) => {
|
||||||
|
const result = new Map<string, Handle>()
|
||||||
|
|
||||||
|
// Use dufflepud if it's set up to protect user privacy, otherwise fetch directly
|
||||||
|
if (this.ctx.config.dufflepudUrl) {
|
||||||
|
const res: any = await tryCatch(
|
||||||
|
async () =>
|
||||||
|
await postJson(`${this.ctx.config.dufflepudUrl}/handle/info`, {handles: nip05s}),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const {handle: nip05, info} of res?.data || []) {
|
||||||
|
if (info) {
|
||||||
|
result.set(nip05, {...info, nip05})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const results = await Promise.all(
|
||||||
|
nip05s.map(async nip05 => ({
|
||||||
|
nip05,
|
||||||
|
info: await tryCatch(async () => await queryProfile(nip05)),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const {nip05, info} of results) {
|
||||||
|
if (info) {
|
||||||
|
result.set(nip05, {...info, nip05})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [nip05, info] of result) {
|
||||||
|
this.set(nip05, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nip05s.map(nip05 => result.get(nip05))
|
||||||
|
})
|
||||||
|
|
||||||
|
loadForPubkey = async (pubkey: string, relays: string[] = []) => {
|
||||||
|
const $profile = await this.ctx.use(Profiles).load(pubkey, relays)
|
||||||
|
|
||||||
|
return $profile?.nip05 ? this.load($profile.nip05) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
forPubkey = (pubkey: string, relays: string[] = []): Projection<Maybe<Handle>> => {
|
||||||
|
this.loadForPubkey(pubkey, relays)
|
||||||
|
|
||||||
|
const read = ([$handlesByNip05, $profile]: [ReadonlyMap<string, Handle>, Maybe<{nip05?: string}>]) => {
|
||||||
|
if (!$profile?.nip05) return undefined
|
||||||
|
|
||||||
|
const handle = $handlesByNip05.get($profile.nip05)
|
||||||
|
|
||||||
|
if (handle?.pubkey !== pubkey) return undefined
|
||||||
|
|
||||||
|
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)]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
display = (nip05: string) => displayNip05(nip05)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export * from "./client.js"
|
||||||
|
export * from "./policies.js"
|
||||||
|
export * from "./network.js"
|
||||||
|
export * from "./stores.js"
|
||||||
|
export * from "./clientData.js"
|
||||||
|
export * from "./user.js"
|
||||||
|
export * from "./router.js"
|
||||||
|
export * from "./relays.js"
|
||||||
|
export * from "./relayStats.js"
|
||||||
|
export * from "./relayLists.js"
|
||||||
|
export * from "./blockedRelayLists.js"
|
||||||
|
export * from "./plaintext.js"
|
||||||
|
export * from "./profiles.js"
|
||||||
|
export * from "./follows.js"
|
||||||
|
export * from "./mutes.js"
|
||||||
|
export * from "./pins.js"
|
||||||
|
export * from "./blossom.js"
|
||||||
|
export * from "./messagingRelayLists.js"
|
||||||
|
export * from "./searchRelayLists.js"
|
||||||
|
export * from "./handles.js"
|
||||||
|
export * from "./zappers.js"
|
||||||
|
export * from "./topics.js"
|
||||||
|
export * from "./tags.js"
|
||||||
|
export * from "./session.js"
|
||||||
|
export * from "./logging.js"
|
||||||
|
export * from "./wot.js"
|
||||||
|
export * from "./feeds.js"
|
||||||
|
export * from "./search.js"
|
||||||
|
export * from "./sync.js"
|
||||||
|
export * from "./giftWraps.js"
|
||||||
|
export * from "./rooms.js"
|
||||||
|
export * from "./relayManagement.js"
|
||||||
|
export * from "./thunk.js"
|
||||||
|
export * from "./createApp.js"
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
MESSAGING_RELAYS,
|
||||||
|
asDecryptedEvent,
|
||||||
|
readList,
|
||||||
|
makeList,
|
||||||
|
makeEvent,
|
||||||
|
addToListPublicly,
|
||||||
|
removeFromList,
|
||||||
|
} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
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"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kind-10050 messaging relay lists (NIP-17), keyed by pubkey. Loaded via the
|
||||||
|
* outbox model (the author's write relays), so it depends on the relay-list
|
||||||
|
* collection.
|
||||||
|
*/
|
||||||
|
export class MessagingRelayLists extends DerivedData<ReturnType<typeof readList>> {
|
||||||
|
constructor(ctx: IClient) {
|
||||||
|
super(ctx, {
|
||||||
|
filters: [{kinds: [MESSAGING_RELAYS]}],
|
||||||
|
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
||||||
|
getKey: list => list.event.pubkey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(pubkey: string, relayHints: string[] = []) {
|
||||||
|
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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import {
|
||||||
|
MUTES,
|
||||||
|
asDecryptedEvent,
|
||||||
|
readList,
|
||||||
|
makeList,
|
||||||
|
addToListPublicly,
|
||||||
|
addToListPrivately,
|
||||||
|
removeFromList,
|
||||||
|
updateList,
|
||||||
|
} from "@welshman/util"
|
||||||
|
import type {TrustedEvent, PublishedList} from "@welshman/util"
|
||||||
|
import {DerivedData} from "./clientData.js"
|
||||||
|
import type {IClient} from "./client.js"
|
||||||
|
import {Network} from "./network.js"
|
||||||
|
import {Thunks} from "./thunk.js"
|
||||||
|
import {Plaintext} from "./plaintext.js"
|
||||||
|
import {User} from "./user.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kind-10000 mute lists, keyed by pubkey. Mute lists carry private entries in
|
||||||
|
* encrypted content, so decoding goes through the plaintext cache.
|
||||||
|
*/
|
||||||
|
export class MuteLists extends DerivedData<PublishedList> {
|
||||||
|
constructor(ctx: IClient) {
|
||||||
|
super(ctx, {
|
||||||
|
filters: [{kinds: [MUTES]}],
|
||||||
|
eventToItem: async (event: TrustedEvent) => {
|
||||||
|
const content = await ctx.use(Plaintext).ensure(event)
|
||||||
|
|
||||||
|
return readList(asDecryptedEvent(event, {content}))
|
||||||
|
},
|
||||||
|
getKey: mute => mute.event.pubkey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(pubkey: string, relayHints: string[] = []) {
|
||||||
|
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})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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,
|
||||||
|
LoaderOptions,
|
||||||
|
RequestOptions,
|
||||||
|
PublishOptions,
|
||||||
|
DiffOptions,
|
||||||
|
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(Network)`; `load` is a shared, batched loader.
|
||||||
|
*/
|
||||||
|
export class Network {
|
||||||
|
load: Loader
|
||||||
|
|
||||||
|
constructor(readonly ctx: IClient) {
|
||||||
|
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})
|
||||||
|
|
||||||
|
publish = (options: Omit<PublishOptions, "context">) =>
|
||||||
|
publish({...options, context: this.ctx.netContext})
|
||||||
|
|
||||||
|
diff = (options: Omit<DiffOptions, "context">) => diff({...options, context: this.ctx.netContext})
|
||||||
|
|
||||||
|
pull = (options: Omit<PullOptions, "context">) => pull({...options, context: this.ctx.netContext})
|
||||||
|
|
||||||
|
push = (options: Omit<PushOptions, "context">) => push({...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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import {
|
||||||
|
PINS,
|
||||||
|
asDecryptedEvent,
|
||||||
|
readList,
|
||||||
|
makeList,
|
||||||
|
addToListPublicly,
|
||||||
|
removeFromList,
|
||||||
|
} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
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 DerivedData<ReturnType<typeof readList>> {
|
||||||
|
constructor(ctx: IClient) {
|
||||||
|
super(ctx, {
|
||||||
|
filters: [{kinds: [PINS]}],
|
||||||
|
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
||||||
|
getKey: pins => pins.event.pubkey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(pubkey: string, relayHints: string[] = []) {
|
||||||
|
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})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import {decrypt} from "@welshman/signer"
|
||||||
|
import type {Maybe} from "@welshman/lib"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {ClientData} from "./clientData.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A cache of decrypted event content, keyed by event id.
|
||||||
|
*/
|
||||||
|
export class Plaintext extends ClientData<string> {
|
||||||
|
ensure = async (event: TrustedEvent): Promise<Maybe<string>> => {
|
||||||
|
if (this.ctx.user?.pubkey !== event.pubkey) return
|
||||||
|
|
||||||
|
let result = this.get(event.id)
|
||||||
|
if (event.content && result === undefined) {
|
||||||
|
try {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import type {Unsubscriber} from "svelte/store"
|
||||||
|
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, 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,
|
||||||
|
* returning a cleanup function — directly analogous to a socket policy. Policies
|
||||||
|
* own everything that subscribes or links components together (event ingestion,
|
||||||
|
* stats collection, gift-wrap unwrapping), so the data classes themselves stay
|
||||||
|
* pure and free of subscriptions, and teardown is centralized in `cleanup()`.
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
* collections (and gift-wrap unwrapping) get populated.
|
||||||
|
*/
|
||||||
|
export const clientPolicyIngest: ClientPolicy = client =>
|
||||||
|
client.pool.subscribe(socket => {
|
||||||
|
const onReceive = (message: RelayMessage) => {
|
||||||
|
if (!isRelayEvent(message)) return
|
||||||
|
|
||||||
|
const event = message[2]
|
||||||
|
|
||||||
|
if (isDVMKind(event.kind) || isEphemeralKind(event.kind)) return
|
||||||
|
if (!verifyEvent(event)) return
|
||||||
|
|
||||||
|
client.tracker.track(event.id, socket.url)
|
||||||
|
client.repository.publish(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on(SocketEvent.Receive, onReceive)
|
||||||
|
|
||||||
|
return () => socket.off(SocketEvent.Receive, onReceive)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listens to socket activity on the client's pool into the RelayStats store.
|
||||||
|
*/
|
||||||
|
export const clientPolicyRelayStats: ClientPolicy = client => {
|
||||||
|
return client.pool.subscribe(client.use(RelayStats).monitorSocket)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watches the client's repository for gift wraps (existing and incoming) and
|
||||||
|
* feeds them to the unwrap queue.
|
||||||
|
*/
|
||||||
|
export const clientPolicyGiftWraps: ClientPolicy = client => {
|
||||||
|
const giftWraps = client.use(GiftWraps)
|
||||||
|
|
||||||
|
for (const wrap of client.repository.query([{kinds: [WRAP]}])) {
|
||||||
|
giftWraps.enqueue(wrap)
|
||||||
|
}
|
||||||
|
|
||||||
|
return on(client.repository, "update", ({added}: {added: TrustedEvent[]}) => {
|
||||||
|
for (const event of added) {
|
||||||
|
if (event.kind === WRAP) {
|
||||||
|
giftWraps.enqueue(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
]
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import {derived, readable} from "svelte/store"
|
||||||
|
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 DerivedData<ReturnType<typeof readProfile>> {
|
||||||
|
constructor(ctx: IClient) {
|
||||||
|
super(ctx, {
|
||||||
|
filters: [{kinds: [PROFILE]}],
|
||||||
|
eventToItem: readProfile,
|
||||||
|
getKey: profile => profile.event.pubkey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(pubkey: string, relayHints: string[] = []) {
|
||||||
|
return this.ctx.use(Network).loadUsingOutbox(pubkey, {kinds: [PROFILE]}, relayHints)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import {reject, nth, nthNe, nthEq, removeUndefined} from "@welshman/lib"
|
||||||
|
import {
|
||||||
|
RELAYS,
|
||||||
|
RelayMode,
|
||||||
|
asDecryptedEvent,
|
||||||
|
readList,
|
||||||
|
getRelaysFromList,
|
||||||
|
getRelayTags,
|
||||||
|
getListTags,
|
||||||
|
getRelayTagValues,
|
||||||
|
makeList,
|
||||||
|
makeEvent,
|
||||||
|
} from "@welshman/util"
|
||||||
|
import type {TrustedEvent, PublishedList} from "@welshman/util"
|
||||||
|
import {DerivedData} from "./clientData.js"
|
||||||
|
import {Router, addMinimalFallbacks} from "./router.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 (see `Network.loadUsingOutbox`).
|
||||||
|
*/
|
||||||
|
export class RelayLists extends DerivedData<PublishedList> {
|
||||||
|
constructor(ctx: IClient) {
|
||||||
|
super(ctx, {
|
||||||
|
filters: [{kinds: [RELAYS]}],
|
||||||
|
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
||||||
|
getKey: (list: PublishedList) => list.event.pubkey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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([
|
||||||
|
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)
|
||||||
|
|
||||||
|
// NIP-65 relay-list mutations for the client's user
|
||||||
|
|
||||||
|
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}
|
||||||
|
|
||||||
|
return this.ctx.use(Thunks).publishToOutbox({event})
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
// 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})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import {groupBy, batch, now, uniq, ago, DAY, HOUR, MINUTE} from "@welshman/lib"
|
||||||
|
import {isOnionUrl, isLocalUrl, isIPAddress, isRelayUrl} from "@welshman/util"
|
||||||
|
import {SocketStatus, SocketEvent} from "@welshman/net"
|
||||||
|
import type {ClientMessage, RelayMessage, Socket} from "@welshman/net"
|
||||||
|
import {ClientData} from "./clientData.js"
|
||||||
|
import {BlockedRelayLists} from "./blockedRelayLists.js"
|
||||||
|
|
||||||
|
export type RelayStatsUpdate = [string, (stats: RelayStatsItem) => void]
|
||||||
|
|
||||||
|
export type RelayStatsItem = {
|
||||||
|
url: string
|
||||||
|
first_seen: number
|
||||||
|
recent_errors: number[]
|
||||||
|
open_count: number
|
||||||
|
close_count: number
|
||||||
|
publish_count: number
|
||||||
|
request_count: number
|
||||||
|
event_count: number
|
||||||
|
last_open: number
|
||||||
|
last_close: number
|
||||||
|
last_error: number
|
||||||
|
last_publish: number
|
||||||
|
last_request: number
|
||||||
|
last_event: number
|
||||||
|
last_auth: number
|
||||||
|
publish_success_count: number
|
||||||
|
publish_failure_count: number
|
||||||
|
eose_count: number
|
||||||
|
notice_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeRelayStatsItem = (url: string): RelayStatsItem => ({
|
||||||
|
url,
|
||||||
|
first_seen: now(),
|
||||||
|
recent_errors: [],
|
||||||
|
open_count: 0,
|
||||||
|
close_count: 0,
|
||||||
|
publish_count: 0,
|
||||||
|
request_count: 0,
|
||||||
|
event_count: 0,
|
||||||
|
last_open: 0,
|
||||||
|
last_close: 0,
|
||||||
|
last_error: 0,
|
||||||
|
last_publish: 0,
|
||||||
|
last_request: 0,
|
||||||
|
last_event: 0,
|
||||||
|
last_auth: 0,
|
||||||
|
publish_success_count: 0,
|
||||||
|
publish_failure_count: 0,
|
||||||
|
eose_count: 0,
|
||||||
|
notice_count: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-relay connection statistics, keyed by url, plus the `getQuality` heuristic
|
||||||
|
* the router uses to rank relays. A pure store — the socket wiring that fills it
|
||||||
|
* lives in `clientPolicyRelayStats`.
|
||||||
|
*/
|
||||||
|
export class RelayStats extends ClientData<RelayStatsItem> {
|
||||||
|
getQuality = (url: string) => {
|
||||||
|
// Skip non-relays entirely
|
||||||
|
if (!isRelayUrl(url)) return 0
|
||||||
|
|
||||||
|
// Skip relays the user has blocked
|
||||||
|
const pubkey = this.ctx.user?.pubkey
|
||||||
|
|
||||||
|
if (pubkey && this.ctx.use(BlockedRelayLists).getBlockedRelays(pubkey).includes(url)) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = this.get(url)
|
||||||
|
|
||||||
|
// If we have recent errors, skip it
|
||||||
|
if (stats) {
|
||||||
|
if (stats.recent_errors.filter(n => n > ago(MINUTE)).length > 0) return 0
|
||||||
|
if (stats.recent_errors.filter(n => n > ago(HOUR)).length > 3) return 0
|
||||||
|
if (stats.recent_errors.filter(n => n > ago(DAY)).length > 10) return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer stuff we're connected to
|
||||||
|
if (this.ctx.pool.has(url)) return 1
|
||||||
|
|
||||||
|
// Prefer stuff we've connected to in the past
|
||||||
|
if (stats) return 0.9
|
||||||
|
|
||||||
|
// If it's not a weird url give it an ok score
|
||||||
|
if (!isIPAddress(url) && !isLocalUrl(url) && !isOnionUrl(url) && !url.startsWith("ws://")) {
|
||||||
|
return 0.8
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to a "meh" score
|
||||||
|
return 0.7
|
||||||
|
}
|
||||||
|
|
||||||
|
private update = batch(150, (batched: RelayStatsUpdate[]) => {
|
||||||
|
for (const [url, updates] of groupBy(([url]) => url, batched)) {
|
||||||
|
if (!url || !isRelayUrl(url)) {
|
||||||
|
console.warn(`Attempted to update stats for an invalid relay url: ${url}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const prev = this.get(url)
|
||||||
|
const next = prev ? {...prev} : makeRelayStatsItem(url)
|
||||||
|
|
||||||
|
for (const [, update] of updates) {
|
||||||
|
update(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.set(url, next)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
private onSocketSend = ([verb]: ClientMessage, url: string) => {
|
||||||
|
if (verb === "REQ") {
|
||||||
|
this.update([
|
||||||
|
url,
|
||||||
|
stats => {
|
||||||
|
stats.request_count++
|
||||||
|
stats.last_request = now()
|
||||||
|
},
|
||||||
|
])
|
||||||
|
} else if (verb === "EVENT") {
|
||||||
|
this.update([
|
||||||
|
url,
|
||||||
|
stats => {
|
||||||
|
stats.publish_count++
|
||||||
|
stats.last_publish = now()
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onSocketReceive = ([verb, ...extra]: RelayMessage, url: string) => {
|
||||||
|
if (verb === "OK") {
|
||||||
|
const [, ok] = extra
|
||||||
|
|
||||||
|
this.update([
|
||||||
|
url,
|
||||||
|
stats => {
|
||||||
|
if (ok) {
|
||||||
|
stats.publish_success_count++
|
||||||
|
} else {
|
||||||
|
stats.publish_failure_count++
|
||||||
|
}
|
||||||
|
},
|
||||||
|
])
|
||||||
|
} else if (verb === "AUTH") {
|
||||||
|
this.update([url, stats => (stats.last_auth = now())])
|
||||||
|
} else if (verb === "EVENT") {
|
||||||
|
this.update([
|
||||||
|
url,
|
||||||
|
stats => {
|
||||||
|
stats.event_count++
|
||||||
|
stats.last_event = now()
|
||||||
|
},
|
||||||
|
])
|
||||||
|
} else if (verb === "EOSE") {
|
||||||
|
this.update([url, stats => stats.eose_count++])
|
||||||
|
} else if (verb === "NOTICE") {
|
||||||
|
this.update([url, stats => stats.notice_count++])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onSocketStatus = (status: string, url: string) => {
|
||||||
|
if (status === SocketStatus.Open) {
|
||||||
|
this.update([
|
||||||
|
url,
|
||||||
|
stats => {
|
||||||
|
stats.last_open = now()
|
||||||
|
stats.open_count++
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === SocketStatus.Closed) {
|
||||||
|
this.update([
|
||||||
|
url,
|
||||||
|
stats => {
|
||||||
|
stats.last_close = now()
|
||||||
|
stats.close_count++
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === SocketStatus.Error) {
|
||||||
|
this.update([
|
||||||
|
url,
|
||||||
|
stats => {
|
||||||
|
stats.last_error = now()
|
||||||
|
stats.recent_errors = uniq(stats.recent_errors.concat(now())).slice(-10)
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import {derived} from "svelte/store"
|
||||||
|
import {fetchJson} from "@welshman/lib"
|
||||||
|
import type {Maybe} from "@welshman/lib"
|
||||||
|
import {displayRelayUrl, displayRelayProfile} from "@welshman/util"
|
||||||
|
import type {RelayProfile} from "@welshman/util"
|
||||||
|
import {LoadableData, projection} from "./clientData.js"
|
||||||
|
import type {Projection} from "./clientData.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-11 relay profiles, keyed by url. A "local" loadable collection: items
|
||||||
|
* aren't nostr events, they're fetched over HTTP from each relay.
|
||||||
|
*/
|
||||||
|
export class Relays extends LoadableData<RelayProfile> {
|
||||||
|
fetch = async (url: string): Promise<Maybe<RelayProfile>> => {
|
||||||
|
try {
|
||||||
|
const json = await fetchJson(url.replace(/^ws/, "http"), {
|
||||||
|
headers: {
|
||||||
|
Accept: "application/nostr+json",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (json) {
|
||||||
|
const info = {...json, url} as RelayProfile
|
||||||
|
|
||||||
|
if (!Array.isArray(info.supported_nips)) {
|
||||||
|
info.supported_nips = []
|
||||||
|
}
|
||||||
|
|
||||||
|
info.supported_nips = info.supported_nips.map(String)
|
||||||
|
|
||||||
|
this.set(url, info)
|
||||||
|
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// pass
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
display = (url: string): Projection<string> => {
|
||||||
|
const read = ($relay: Maybe<RelayProfile>) => displayRelayProfile($relay, displayRelayUrl(url))
|
||||||
|
|
||||||
|
return projection(derived(this.one(url), read), () => read(this.get(url)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import {Router as BaseRouter} from "@welshman/router"
|
||||||
|
import {RelayLists} from "./relayLists.js"
|
||||||
|
import {RelayStats} from "./relayStats.js"
|
||||||
|
import type {IClient} from "./client.js"
|
||||||
|
|
||||||
|
// Re-export the upstream router surface (scenarios, fallback policies,
|
||||||
|
// makeSelection, getFilterSelections, types). The local `Router` below shadows
|
||||||
|
// the upstream `Router` in this re-export.
|
||||||
|
export * from "@welshman/router"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The upstream `@welshman/router` Router, wired to this client: relay lists come
|
||||||
|
* from the `RelayLists` collection, quality from `RelayStats`, and the user
|
||||||
|
* pubkey + relay-getters from the client (via `ctx.config`). Reach it via
|
||||||
|
* `client.use(Router)`. This replaces the old forked copy — one source of truth,
|
||||||
|
* no global `routerContext`/`Router.get()`.
|
||||||
|
*/
|
||||||
|
export class Router extends BaseRouter {
|
||||||
|
constructor(ctx: IClient) {
|
||||||
|
super({
|
||||||
|
getUserPubkey: () => ctx.user?.pubkey,
|
||||||
|
getPubkeyRelays: (pubkey, mode) => ctx.use(RelayLists).getRelaysForPubkey(pubkey, mode),
|
||||||
|
getRelayQuality: url => ctx.use(RelayStats).getQuality(url),
|
||||||
|
getDefaultRelays: ctx.config.getDefaultRelays,
|
||||||
|
getIndexerRelays: ctx.config.getIndexerRelays,
|
||||||
|
getSearchRelays: ctx.config.getSearchRelays,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import Fuse from "fuse.js"
|
||||||
|
import type {IFuseOptions, FuseResult} from "fuse.js"
|
||||||
|
import {debounce} from "throttle-debounce"
|
||||||
|
import {derived} from "svelte/store"
|
||||||
|
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} from "@welshman/store"
|
||||||
|
import type {IClient} from "./client.js"
|
||||||
|
import {Network} from "./network.js"
|
||||||
|
import {Router} from "./router.js"
|
||||||
|
import {Profiles} from "./profiles.js"
|
||||||
|
import {Topics} from "./topics.js"
|
||||||
|
import type {Topic} from "./topics.js"
|
||||||
|
import {Relays} from "./relays.js"
|
||||||
|
import {Handles} from "./handles.js"
|
||||||
|
import {Wot} from "./wot.js"
|
||||||
|
|
||||||
|
export type SearchOptions<V, T> = {
|
||||||
|
getValue: (item: T) => V
|
||||||
|
fuseOptions?: IFuseOptions<T>
|
||||||
|
onSearch?: (term: string) => void
|
||||||
|
sortFn?: (items: FuseResult<T>) => any
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Search<V, T> = {
|
||||||
|
options: T[]
|
||||||
|
getValue: (item: T) => V
|
||||||
|
getOption: (value: V) => T | undefined
|
||||||
|
searchOptions: (term: string) => T[]
|
||||||
|
searchValues: (term: string) => V[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createSearch = <V, T>(options: T[], opts: SearchOptions<V, T>): Search<V, T> => {
|
||||||
|
const fuse = new Fuse(options, {...opts.fuseOptions, includeScore: true})
|
||||||
|
const map = new Map<V, T>(options.map(item => [opts.getValue(item), item]))
|
||||||
|
|
||||||
|
const search = (term: string) => {
|
||||||
|
opts.onSearch?.(term)
|
||||||
|
|
||||||
|
let results = term ? fuse.search(term) : options.map(item => ({item}) as FuseResult<T>)
|
||||||
|
|
||||||
|
if (opts.sortFn) {
|
||||||
|
results = sortBy(opts.sortFn, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.map(result => result.item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
options,
|
||||||
|
getValue: opts.getValue,
|
||||||
|
getOption: (value: V) => map.get(value),
|
||||||
|
searchOptions: (term: string) => search(term),
|
||||||
|
searchValues: (term: string) => search(term).map(opts.getValue),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive fuzzy searches over the client's profiles, topics, and relays.
|
||||||
|
* `profileSearch` blends fuse scores with web-of-trust weight (via `Wot`) and
|
||||||
|
* fires a debounced NIP-50 network search through the client's loader.
|
||||||
|
*/
|
||||||
|
export class Searches {
|
||||||
|
profileSearch: Readable<Search<string, PublishedProfile>>
|
||||||
|
topicSearch: Readable<Search<string, Topic>>
|
||||||
|
relaySearch: Readable<Search<string, RelayProfile>>
|
||||||
|
|
||||||
|
constructor(readonly ctx: IClient) {
|
||||||
|
this.profileSearch = derived(
|
||||||
|
[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 => {
|
||||||
|
const isNip05Valid = !p.nip05 || $handlesByNip05.get(p.nip05)?.pubkey === p.event.pubkey
|
||||||
|
|
||||||
|
return isNip05Valid ? p : {...p, nip05: ""}
|
||||||
|
})
|
||||||
|
|
||||||
|
return createSearch(options, {
|
||||||
|
onSearch: this.searchProfiles,
|
||||||
|
getValue: (profile: PublishedProfile) => profile.event.pubkey,
|
||||||
|
sortFn: ({score = 1, item}) => {
|
||||||
|
const wotScore = this.ctx.use(Wot).graph.get().get(item.event.pubkey) || 0
|
||||||
|
|
||||||
|
return dec(score) * inc(wotScore / (this.ctx.use(Wot).max.get() || 1))
|
||||||
|
},
|
||||||
|
fuseOptions: {
|
||||||
|
keys: [
|
||||||
|
"nip05",
|
||||||
|
{name: "name", weight: 0.8},
|
||||||
|
{name: "display_name", weight: 0.5},
|
||||||
|
{name: "about", weight: 0.3},
|
||||||
|
],
|
||||||
|
threshold: 0.3,
|
||||||
|
shouldSort: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
this.topicSearch = derived(this.ctx.use(Topics).all, $topics =>
|
||||||
|
createSearch($topics, {
|
||||||
|
getValue: (topic: Topic) => topic.name,
|
||||||
|
fuseOptions: {keys: ["name"]},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
this.relaySearch = derived(this.ctx.use(Relays).all.$, $relays =>
|
||||||
|
createSearch($relays, {
|
||||||
|
getValue: (relay: RelayProfile) => relay.url,
|
||||||
|
fuseOptions: {
|
||||||
|
keys: ["url", "name", {name: "description", weight: 0.3}],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
searchProfiles = debounce(500, (search: string) => {
|
||||||
|
if (search.length > 2) {
|
||||||
|
this.ctx.use(Network).load({
|
||||||
|
filters: [{kinds: [PROFILE], search}],
|
||||||
|
relays: this.ctx.use(Router).Search().getUrls(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
SEARCH_RELAYS,
|
||||||
|
asDecryptedEvent,
|
||||||
|
readList,
|
||||||
|
makeList,
|
||||||
|
makeEvent,
|
||||||
|
addToListPublicly,
|
||||||
|
removeFromList,
|
||||||
|
} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
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"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-51 search relay lists (kind 10007), keyed by pubkey. Loaded via the
|
||||||
|
* outbox model (the author's write relays), so it depends on the relay-list
|
||||||
|
* collection.
|
||||||
|
*/
|
||||||
|
export class SearchRelayLists extends DerivedData<ReturnType<typeof readList>> {
|
||||||
|
constructor(ctx: IClient) {
|
||||||
|
super(ctx, {
|
||||||
|
filters: [{kinds: [SEARCH_RELAYS]}],
|
||||||
|
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
||||||
|
getKey: searchRelayList => searchRelayList.event.pubkey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(pubkey: string, relayHints: string[] = []) {
|
||||||
|
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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import {Client as PomadeClient, PomadeSigner} from "@pomade/core"
|
||||||
|
import type {ClientOptions as PomadeClientOptions} from "@pomade/core"
|
||||||
|
import type {MaybeAsync} from "@welshman/lib"
|
||||||
|
import {Nip46Broker, Nip46Signer, Nip07Signer, Nip01Signer, Nip55Signer} from "@welshman/signer"
|
||||||
|
import type {ISigner} from "@welshman/signer"
|
||||||
|
|
||||||
|
// ── Sessions: serializable {method, data} descriptors ──
|
||||||
|
|
||||||
|
export type Session<M extends string = string, D = unknown> = {
|
||||||
|
method: M
|
||||||
|
data: D
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Session handlers: a method string, its data shape, and how to build a signer ──
|
||||||
|
|
||||||
|
export type SessionHandler<M extends string, D> = {
|
||||||
|
method: M
|
||||||
|
getSigner: (data: D) => MaybeAsync<ISigner>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 defineSessionHandler = <M extends string, D>(handler: SessionHandler<M, D>) => handler
|
||||||
|
|
||||||
|
/** 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const unregisterSessionHandler = (handler: SessionHandler<string, any>) => {
|
||||||
|
sessionHandlers.delete(handler.method)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSignerFromSession = (session: Session): MaybeAsync<ISigner> | undefined =>
|
||||||
|
sessionHandlers.get(session.method)?.getSigner(session.data)
|
||||||
|
|
||||||
|
// ── Initialize default session handlers ──
|
||||||
|
|
||||||
|
for (const sessionHandler of [nip01, nip07, nip46, nip55, pomade]) {
|
||||||
|
registerSessionHandler(sessionHandler)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
getEventsById,
|
||||||
|
deriveEventsById,
|
||||||
|
deriveEvents,
|
||||||
|
makeDeriveEvent,
|
||||||
|
getEventsByIdByUrl,
|
||||||
|
deriveEventsByIdByUrl,
|
||||||
|
getEventsByIdForUrl,
|
||||||
|
deriveEventsByIdForUrl,
|
||||||
|
deriveItemsByKey,
|
||||||
|
deriveIsDeleted,
|
||||||
|
} from "@welshman/store"
|
||||||
|
import type {
|
||||||
|
EventsByIdOptions,
|
||||||
|
EventOptions,
|
||||||
|
EventsByIdByUrlOptions,
|
||||||
|
EventsByIdForUrlOptions,
|
||||||
|
ItemsByKeyOptions,
|
||||||
|
} from "@welshman/store"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import type {IClient} from "./client.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store/derivation utilities bound to the client's repository and tracker. Reach
|
||||||
|
* it via `client.use(Stores)`.
|
||||||
|
*/
|
||||||
|
export class Stores {
|
||||||
|
constructor(readonly ctx: IClient) {}
|
||||||
|
|
||||||
|
getEventsById = (options: Omit<EventsByIdOptions, "repository">) =>
|
||||||
|
getEventsById({...options, repository: this.ctx.repository})
|
||||||
|
|
||||||
|
eventsById = (options: Omit<EventsByIdOptions, "repository">) =>
|
||||||
|
deriveEventsById({...options, repository: this.ctx.repository})
|
||||||
|
|
||||||
|
events = (options: Omit<EventsByIdOptions, "repository">) =>
|
||||||
|
deriveEvents({...options, repository: this.ctx.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})
|
||||||
|
|
||||||
|
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})
|
||||||
|
|
||||||
|
eventsByIdForUrl = (options: Omit<EventsByIdForUrlOptions, "tracker" | "repository">) =>
|
||||||
|
deriveEventsByIdForUrl({...options, tracker: this.ctx.tracker, repository: this.ctx.repository})
|
||||||
|
|
||||||
|
itemsByKey = <T>(options: Omit<ItemsByKeyOptions<T>, "repository">) =>
|
||||||
|
deriveItemsByKey<T>({...options, repository: this.ctx.repository})
|
||||||
|
|
||||||
|
isDeleted = (event: TrustedEvent) => deriveIsDeleted(this.ctx.repository, event)
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import {isSignedEvent} from "@welshman/util"
|
||||||
|
import type {Filter, SignedEvent} from "@welshman/util"
|
||||||
|
import type {IClient} from "./client.js"
|
||||||
|
import {Network} from "./network.js"
|
||||||
|
import {Relays} from "./relays.js"
|
||||||
|
|
||||||
|
export type AppSyncOpts = {
|
||||||
|
relays: string[]
|
||||||
|
filters: Filter[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Negentropy-aware sync. Pulls/pushes events between the local repository and a
|
||||||
|
* set of relays, using NIP-77 reconciliation where the relay supports it and
|
||||||
|
* falling back to plain request/publish otherwise. Reads NIP-11 relay profiles
|
||||||
|
* from the `Relays` collection to detect negentropy support.
|
||||||
|
*/
|
||||||
|
export class Sync {
|
||||||
|
constructor(readonly ctx: IClient) {}
|
||||||
|
|
||||||
|
query = (filters: Filter[]) =>
|
||||||
|
this.ctx.repository.query(filters, {shouldSort: filters.every(f => f.limit === undefined)})
|
||||||
|
|
||||||
|
hasNegentropy = (url: string) => {
|
||||||
|
const relay = this.ctx.use(Relays).get(url)
|
||||||
|
|
||||||
|
if (relay?.negentropy) return true
|
||||||
|
if (relay?.supported_nips?.includes?.("77")) return true
|
||||||
|
if (relay?.software?.includes?.("strfry") && !relay?.version?.match(/^0\./)) return true
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
pull = async ({relays, filters}: AppSyncOpts) => {
|
||||||
|
const net = this.ctx.use(Network)
|
||||||
|
const events = this.query(filters).filter(isSignedEvent)
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
relays.map(async relay => {
|
||||||
|
await (this.hasNegentropy(relay)
|
||||||
|
? net.pull({filters, events, relays: [relay]})
|
||||||
|
: net.request({filters, relays: [relay], autoClose: true}))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
push = async ({relays, filters}: AppSyncOpts) => {
|
||||||
|
const net = this.ctx.use(Network)
|
||||||
|
const events = this.query(filters).filter(isSignedEvent)
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
relays.map(async relay => {
|
||||||
|
await (this.hasNegentropy(relay)
|
||||||
|
? net.push({filters, events, relays: [relay]})
|
||||||
|
: Promise.all(events.map((event: SignedEvent) => net.publish({event, relays: [relay]}))))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import {uniq, remove} from "@welshman/lib"
|
||||||
|
import {
|
||||||
|
getAddress,
|
||||||
|
isReplaceable,
|
||||||
|
getReplyTags,
|
||||||
|
getPubkeyTagValues,
|
||||||
|
isReplaceableKind,
|
||||||
|
isShareableRelayUrl,
|
||||||
|
} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {Router} from "./router.js"
|
||||||
|
import {Profiles} from "./profiles.js"
|
||||||
|
import type {IClient} from "./client.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builders for nostr tags (p/e/a/q/zap/reply/comment/reaction). Needs the router
|
||||||
|
* for relay hints, the profiles collection for display names, and the client's
|
||||||
|
* user to avoid self-tagging.
|
||||||
|
*/
|
||||||
|
export class Tags {
|
||||||
|
constructor(readonly ctx: IClient) {}
|
||||||
|
|
||||||
|
tagZapSplit = (pubkey: string, split = 1) => [
|
||||||
|
"zap",
|
||||||
|
pubkey,
|
||||||
|
this.ctx.use(Router).FromPubkey(pubkey).getUrl() || "",
|
||||||
|
String(split),
|
||||||
|
]
|
||||||
|
|
||||||
|
tagPubkey = (pubkey: string) => [
|
||||||
|
"p",
|
||||||
|
pubkey,
|
||||||
|
this.ctx.use(Router).FromPubkey(pubkey).getUrl() || "",
|
||||||
|
this.ctx.use(Profiles).display(pubkey).get(),
|
||||||
|
]
|
||||||
|
|
||||||
|
tagEvent = (event: TrustedEvent, url = "", mark = "") => {
|
||||||
|
if (!url) {
|
||||||
|
url = this.ctx.use(Router).Event(event).getUrl() || ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = [["e", event.id, url, mark, event.pubkey]]
|
||||||
|
|
||||||
|
if (isReplaceable(event)) {
|
||||||
|
tags.push(["a", getAddress(event), url, mark, event.pubkey])
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
tagEventPubkeys = (event: TrustedEvent) =>
|
||||||
|
uniq(
|
||||||
|
remove(this.ctx.user?.pubkey ?? "", [event.pubkey, ...getPubkeyTagValues(event.tags)]),
|
||||||
|
).map(pubkey => this.tagPubkey(pubkey))
|
||||||
|
|
||||||
|
tagEventForQuote = (event: TrustedEvent, relay?: string) => {
|
||||||
|
const hint = relay || this.ctx.use(Router).Event(event).getUrl() || ""
|
||||||
|
|
||||||
|
return ["q", event.id, hint, event.pubkey]
|
||||||
|
}
|
||||||
|
|
||||||
|
tagEventForReply = (event: TrustedEvent, relay?: string) => {
|
||||||
|
const tags = this.tagEventPubkeys(event)
|
||||||
|
const {roots, replies} = getReplyTags(event.tags)
|
||||||
|
const parents = roots.length > 0 ? roots : replies
|
||||||
|
const mark = parents.length > 0 ? "reply" : "root"
|
||||||
|
const hint = relay || this.ctx.use(Router).Event(event).getUrl() || ""
|
||||||
|
|
||||||
|
// If the parent included roots use them, otherwise use replies as a fallback
|
||||||
|
for (const [k, id, originalHint = "", _, pubkey = ""] of parents) {
|
||||||
|
const hint = isShareableRelayUrl(originalHint)
|
||||||
|
? originalHint
|
||||||
|
: this.ctx.use(Router).EventRoots(event).getUrl()
|
||||||
|
|
||||||
|
tags.push([k, id, hint || "", "root", pubkey])
|
||||||
|
}
|
||||||
|
|
||||||
|
// e-tag the event
|
||||||
|
tags.push(["e", event.id, hint, mark, event.pubkey])
|
||||||
|
|
||||||
|
// a-tag the event
|
||||||
|
if (isReplaceable(event)) {
|
||||||
|
tags.push(["a", getAddress(event), hint, mark, event.pubkey])
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
tagEventForComment = (event: TrustedEvent, relay?: string) => {
|
||||||
|
const pubkeyHint = this.ctx.use(Router).FromPubkey(event.pubkey).getUrl() || ""
|
||||||
|
const eventHint = relay || this.ctx.use(Router).Event(event).getUrl() || ""
|
||||||
|
const address = getAddress(event)
|
||||||
|
const seenRoots = new Set<string>()
|
||||||
|
const tags: string[][] = []
|
||||||
|
|
||||||
|
for (const [t, ...tag] of event.tags) {
|
||||||
|
if (["K", "E", "A", "I", "P"].includes(t)) {
|
||||||
|
tags.push([t, ...tag])
|
||||||
|
seenRoots.add(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seenRoots.size === 0) {
|
||||||
|
tags.push(["K", String(event.kind)])
|
||||||
|
tags.push(["P", event.pubkey, pubkeyHint])
|
||||||
|
tags.push(["E", event.id, eventHint, event.pubkey])
|
||||||
|
|
||||||
|
if (isReplaceableKind(event.kind)) {
|
||||||
|
tags.push(["A", address, eventHint, event.pubkey])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tags.push(["k", String(event.kind)])
|
||||||
|
tags.push(["p", event.pubkey, pubkeyHint])
|
||||||
|
tags.push(["e", event.id, eventHint, event.pubkey])
|
||||||
|
|
||||||
|
if (isReplaceableKind(event.kind)) {
|
||||||
|
tags.push(["a", address, eventHint, event.pubkey])
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
tagEventForReaction = (event: TrustedEvent, relay?: string) => {
|
||||||
|
const hint = relay || this.ctx.use(Router).Event(event).getUrl() || ""
|
||||||
|
const tags: string[][] = []
|
||||||
|
|
||||||
|
// Mention the event's author
|
||||||
|
if (event.pubkey !== this.ctx.user?.pubkey) {
|
||||||
|
tags.push(this.tagPubkey(event.pubkey))
|
||||||
|
}
|
||||||
|
|
||||||
|
tags.push(["k", String(event.kind)])
|
||||||
|
tags.push(["e", event.id, hint])
|
||||||
|
|
||||||
|
if (isReplaceable(event)) {
|
||||||
|
tags.push(["a", getAddress(event), hint])
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
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, uniq, without} from "@welshman/lib"
|
||||||
|
import {
|
||||||
|
HashedEvent,
|
||||||
|
EventTemplate,
|
||||||
|
SignedEvent,
|
||||||
|
isSignedEvent,
|
||||||
|
WRAPPED_KINDS,
|
||||||
|
prep,
|
||||||
|
makePow,
|
||||||
|
} from "@welshman/util"
|
||||||
|
import {PublishStatus, PublishResult, PublishOptions, PublishResultsByRelay} from "@welshman/net"
|
||||||
|
import {Nip01Signer, Nip59} from "@welshman/signer"
|
||||||
|
import type {IClient} from "./client.js"
|
||||||
|
import {Network} from "./network.js"
|
||||||
|
import {Router, addMinimalFallbacks} from "./router.js"
|
||||||
|
import {User} from "./user.js"
|
||||||
|
|
||||||
|
export type ThunkOptions = Override<
|
||||||
|
PublishOptions,
|
||||||
|
{
|
||||||
|
client: IClient
|
||||||
|
event: EventTemplate
|
||||||
|
recipient?: string
|
||||||
|
delay?: number
|
||||||
|
pow?: number
|
||||||
|
}
|
||||||
|
>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.user.pubkey)
|
||||||
|
|
||||||
|
for (const relay of options.relays) {
|
||||||
|
this.results[relay] = {
|
||||||
|
relay,
|
||||||
|
status: PublishStatus.Sending,
|
||||||
|
detail: "sending...",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.controller.signal.addEventListener("abort", () => {
|
||||||
|
for (const relay of options.relays) {
|
||||||
|
this._setAborted({
|
||||||
|
relay,
|
||||||
|
status: PublishStatus.Aborted,
|
||||||
|
detail: "aborted",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
get user() {
|
||||||
|
return User.require(this.options.client)
|
||||||
|
}
|
||||||
|
|
||||||
|
_fail(detail: string) {
|
||||||
|
for (const relay of this.options.relays) {
|
||||||
|
this.results[relay] = {
|
||||||
|
relay,
|
||||||
|
status: PublishStatus.Failure,
|
||||||
|
detail: detail,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
_setPending = (result: PublishResult) => {
|
||||||
|
this.options.onPending?.(result)
|
||||||
|
this.results[result.relay] = result
|
||||||
|
this._notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
_setTimeout = (result: PublishResult) => {
|
||||||
|
this.options.onTimeout?.(result)
|
||||||
|
this.results[result.relay] = result
|
||||||
|
this._notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
_setAborted = (result: PublishResult) => {
|
||||||
|
this.options.onAborted?.(result)
|
||||||
|
this.results[result.relay] = result
|
||||||
|
this._notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
async _publish(event: SignedEvent) {
|
||||||
|
// Wait if the thunk is to be delayed
|
||||||
|
if (this.options.delay) {
|
||||||
|
await sleep(this.options.delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip publishing if aborted
|
||||||
|
if (this.controller.signal.aborted) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send it off
|
||||||
|
await this.options.client.use(Network).publish({
|
||||||
|
...this.options,
|
||||||
|
event,
|
||||||
|
onSuccess: (result: PublishResult) => {
|
||||||
|
this.options.onSuccess?.(result)
|
||||||
|
this.results[result.relay] = result
|
||||||
|
this._notify()
|
||||||
|
},
|
||||||
|
onFailure: (result: PublishResult) => {
|
||||||
|
this.options.onFailure?.(result)
|
||||||
|
this.results[result.relay] = result
|
||||||
|
this._notify()
|
||||||
|
},
|
||||||
|
onPending: this._setPending,
|
||||||
|
onTimeout: this._setTimeout,
|
||||||
|
onAborted: this._setAborted,
|
||||||
|
onComplete: (result: PublishResult) => {
|
||||||
|
if (result.status !== PublishStatus.Success) {
|
||||||
|
this.options.client.tracker.removeRelay(event.id, result.relay)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.options.onComplete?.(result)
|
||||||
|
this._subs = []
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Notify the caller that we're done
|
||||||
|
this.complete.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
async publish() {
|
||||||
|
// Handle abort immediately if possible
|
||||||
|
if (this.controller.signal.aborted) return
|
||||||
|
|
||||||
|
const {recipient} = this.options
|
||||||
|
|
||||||
|
// If we're sending it privately, wrap the event using nip 59
|
||||||
|
if (recipient) {
|
||||||
|
const wrapper = Nip01Signer.ephemeral()
|
||||||
|
const nip59 = new Nip59(this.user.signer, wrapper)
|
||||||
|
|
||||||
|
this.wrap = await nip59.wrap(recipient, this.event)
|
||||||
|
|
||||||
|
// If we're calculating pow, update the hash and re-sign
|
||||||
|
if (this.options.pow) {
|
||||||
|
this.wrap = await wrapper.sign(await makePow(this.wrap, this.options.pow).result, {
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.options.client.wrapManager.add({recipient, wrap: this.wrap, rumor: this.event})
|
||||||
|
|
||||||
|
return this._publish(this.wrap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the event has been signed, we're good to go
|
||||||
|
if (isSignedEvent(this.event)) {
|
||||||
|
if (this.options.pow) {
|
||||||
|
console.warn("Event is already signed, skipping proof of work calculation")
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._publish(this.event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow for lazily signing/powing events in order to decrease apparent latency in the UI
|
||||||
|
// that results from waiting for remote signers
|
||||||
|
try {
|
||||||
|
if (this.options.pow) {
|
||||||
|
this.event = await makePow(this.event, this.options.pow).result
|
||||||
|
}
|
||||||
|
|
||||||
|
const signedEvent = await this.user.signer.sign(this.event, {
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update tracker and repository with the signed event since the id will have changed
|
||||||
|
if (this.options.pow) {
|
||||||
|
for (const url of this.options.relays) {
|
||||||
|
this.options.client.tracker.removeRelay(this.event.id, url)
|
||||||
|
this.options.client.tracker.track(signedEvent.id, url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.options.client.repository.removeEvent(this.event.id)
|
||||||
|
this.options.client.repository.publish(signedEvent)
|
||||||
|
|
||||||
|
return this._publish(signedEvent)
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("Failed to sign event", e)
|
||||||
|
return this._fail(String(e || "Failed to sign event"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abort() {
|
||||||
|
this.controller.abort()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
this.results = {}
|
||||||
|
|
||||||
|
for (const relay of relays) {
|
||||||
|
for (const status of [Aborted, Failure, Timeout, Pending, Sending, Success]) {
|
||||||
|
const match = thunks.find(t => t.results[relay]?.status === status)
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
this.results[relay] = match.results[relay]!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._notify()
|
||||||
|
|
||||||
|
if (thunks.every(t => t.isComplete())) {
|
||||||
|
this._subs = []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abort() {
|
||||||
|
this.thunks.forEach(thunk => thunk.abort())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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[]>([])
|
||||||
|
|
||||||
|
queue = new TaskQueue<Thunk>({
|
||||||
|
batchSize: 10,
|
||||||
|
batchDelay: 100,
|
||||||
|
processItem: (thunk: Thunk) => {
|
||||||
|
thunk.publish()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
publish = (options: Omit<ThunkOptions, "client">) => {
|
||||||
|
const thunk = new Thunk({...options, client: this.ctx})
|
||||||
|
|
||||||
|
this.enqueue(thunk)
|
||||||
|
|
||||||
|
return thunk
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(),
|
||||||
|
})
|
||||||
|
|
||||||
|
retry = (thunk: BaseThunk) =>
|
||||||
|
thunk instanceof MergedThunk
|
||||||
|
? new MergedThunk(thunk.thunks.map(t => this.publish(t.options)))
|
||||||
|
: this.publish((thunk as Thunk).options)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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"
|
||||||
|
|
||||||
|
export type Topic = {
|
||||||
|
name: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hashtag topics with occurrence counts, derived live from the client's
|
||||||
|
* repository tag index.
|
||||||
|
*/
|
||||||
|
export class Topics {
|
||||||
|
byName: Readable<Map<string, Topic>>
|
||||||
|
all: Readable<Topic[]>
|
||||||
|
|
||||||
|
constructor(readonly ctx: IClient) {
|
||||||
|
const topicsByName = new Map<string, Topic>()
|
||||||
|
|
||||||
|
const addTopic = (name: string) => {
|
||||||
|
const topic = topicsByName.get(name)
|
||||||
|
|
||||||
|
if (topic) {
|
||||||
|
topic.count++
|
||||||
|
} else {
|
||||||
|
topicsByName.set(name, {name, count: 1})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tagString of ctx.repository.eventsByTag.keys()) {
|
||||||
|
if (tagString.startsWith("t:")) {
|
||||||
|
addTopic(tagString.slice(2).toLowerCase())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.byName = readable(topicsByName, set =>
|
||||||
|
on(ctx.repository, "update", ({added}: RepositoryUpdate) => {
|
||||||
|
let dirty = false
|
||||||
|
|
||||||
|
for (const event of added) {
|
||||||
|
for (const name of getTopicTagValues(event.tags)) {
|
||||||
|
addTopic(name)
|
||||||
|
dirty = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dirty) {
|
||||||
|
set(topicsByName)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
this.all = deriveItems(this.byName)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type {StampedEvent} from "@welshman/util"
|
||||||
|
import type {ISigner} from "@welshman/signer"
|
||||||
|
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
|
||||||
|
* centered on (at most) one `User`, since the data a user can access depends
|
||||||
|
* entirely on who they are.
|
||||||
|
*/
|
||||||
|
export class User {
|
||||||
|
constructor(
|
||||||
|
readonly pubkey: string,
|
||||||
|
readonly signer: ISigner,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
static async fromSigner(signer: ISigner) {
|
||||||
|
if (!(signer instanceof LoggingSigner)) {
|
||||||
|
signer = new LoggingSigner(signer)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pubkey = await signer.getPubkey()
|
||||||
|
|
||||||
|
return new User(pubkey, signer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
|
||||||
|
nip44EncryptToSelf = (payload: string) => this.signer.nip44.encrypt(this.pubkey, payload)
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import {readable, derived} from "svelte/store"
|
||||||
|
import {max, throttle, addToMapKey, inc, dec} from "@welshman/lib"
|
||||||
|
import {getListTags, getPubkeyTagValues} from "@welshman/util"
|
||||||
|
import type {List} from "@welshman/util"
|
||||||
|
import type {IClient} from "./client.js"
|
||||||
|
import {projection} from "./clientData.js"
|
||||||
|
import type {Projection} from "./clientData.js"
|
||||||
|
import {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: 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 followersByPubkeyStore = readable(new Map<string, Set<string>>(), set =>
|
||||||
|
this.ctx.use(FollowLists).index.$.subscribe(
|
||||||
|
throttle(1000, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set($graph)
|
||||||
|
})
|
||||||
|
|
||||||
|
const unsubscribers = [
|
||||||
|
this.ctx.use(FollowLists).index.$.subscribe(rebuild),
|
||||||
|
this.ctx.use(MuteLists).index.$.subscribe(rebuild),
|
||||||
|
]
|
||||||
|
|
||||||
|
return () => unsubscribers.forEach(unsubscribe => unsubscribe())
|
||||||
|
})
|
||||||
|
|
||||||
|
const maxStore = derived(graphStore, $g => max(Array.from($g.values())))
|
||||||
|
|
||||||
|
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 Array.from(network)
|
||||||
|
}
|
||||||
|
|
||||||
|
return projection(derived(this.ctx.use(FollowLists).index.$, read), () =>
|
||||||
|
read(this.ctx.use(FollowLists).index.get()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
followers = (pubkey: string): Projection<string[]> => {
|
||||||
|
const read = ($followers: ReadonlyMap<string, Set<string>>) =>
|
||||||
|
Array.from($followers.get(pubkey) || [])
|
||||||
|
|
||||||
|
return projection(derived(this.followersByPubkey.$, read), () =>
|
||||||
|
read(this.followersByPubkey.get()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
muters = (pubkey: string): Projection<string[]> => {
|
||||||
|
const read = ($muters: ReadonlyMap<string, Set<string>>) =>
|
||||||
|
Array.from($muters.get(pubkey) || [])
|
||||||
|
|
||||||
|
return projection(derived(this.mutersByPubkey.$, read), () => read(this.mutersByPubkey.get()))
|
||||||
|
}
|
||||||
|
|
||||||
|
followsWhoFollow = (pubkey: string, target: string): Projection<string[]> => {
|
||||||
|
const read = ($lists: ReadonlyMap<string, List>) =>
|
||||||
|
listPubkeys($lists.get(pubkey)).filter(other =>
|
||||||
|
listPubkeys($lists.get(other)).includes(target),
|
||||||
|
)
|
||||||
|
|
||||||
|
return projection(derived(this.ctx.use(FollowLists).index.$, read), () =>
|
||||||
|
read(this.ctx.use(FollowLists).index.get()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
followsWhoMute = (pubkey: string, target: string): Projection<string[]> => {
|
||||||
|
const read = ($follows: ReadonlyMap<string, List>, $mutes: ReadonlyMap<string, List>) =>
|
||||||
|
listPubkeys($follows.get(pubkey)).filter(other =>
|
||||||
|
listPubkeys($mutes.get(other)).includes(target),
|
||||||
|
)
|
||||||
|
|
||||||
|
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()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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[]
|
||||||
|
|
||||||
|
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) || [])
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import type {Readable} from "svelte/store"
|
||||||
|
import {
|
||||||
|
removeUndefined,
|
||||||
|
fetchJson,
|
||||||
|
bech32ToHex,
|
||||||
|
hexToBech32,
|
||||||
|
tryCatch,
|
||||||
|
batcher,
|
||||||
|
postJson,
|
||||||
|
} from "@welshman/lib"
|
||||||
|
import type {Maybe} from "@welshman/lib"
|
||||||
|
import {getTagValue, getZapSplits, zapFromEvent} from "@welshman/util"
|
||||||
|
import type {Zapper, Zap, TrustedEvent} from "@welshman/util"
|
||||||
|
import {deriveDeduplicated, deriveDeduplicatedByValue} from "@welshman/store"
|
||||||
|
import {LoadableData, projection} from "./clientData.js"
|
||||||
|
import type {Projection} from "./clientData.js"
|
||||||
|
import type {IClient} from "./client.js"
|
||||||
|
import {Profiles} from "./profiles.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightning zapper info, keyed by lnurl. A "local" loadable collection: items
|
||||||
|
* aren't nostr events, they're fetched over HTTP (either directly from each
|
||||||
|
* lnurl, or via a dufflepud proxy to protect user privacy). Depends on the
|
||||||
|
* profiles collection to resolve a pubkey's lnurl.
|
||||||
|
*/
|
||||||
|
export class Zappers extends LoadableData<Zapper> {
|
||||||
|
fetch = batcher(800, async (lnurls: string[]) => {
|
||||||
|
const result = new Map<string, Zapper>()
|
||||||
|
const valid = lnurls.filter(lnurl => lnurl.startsWith("lnurl1"))
|
||||||
|
|
||||||
|
const addZapper = (lnurl: string, info: any) => {
|
||||||
|
if (info) {
|
||||||
|
try {
|
||||||
|
result.set(lnurl, {...info, lnurl})
|
||||||
|
} catch (_e) {
|
||||||
|
// pass
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.ctx.config.dufflepudUrl) {
|
||||||
|
const hexUrls = valid.map(bech32ToHex)
|
||||||
|
const res: any = await tryCatch(
|
||||||
|
async () =>
|
||||||
|
await postJson(`${this.ctx.config.dufflepudUrl}/zapper/info`, {lnurls: hexUrls}),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const {lnurl, info} of res?.data || []) {
|
||||||
|
addZapper(hexToBech32("lnurl", lnurl), info)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await Promise.all(
|
||||||
|
valid.map(async lnurl => {
|
||||||
|
addZapper(lnurl, await tryCatch(async () => await fetchJson(bech32ToHex(lnurl))))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [lnurl, zapper] of result) {
|
||||||
|
this.set(lnurl, zapper)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lnurls.map(lnurl => result.get(lnurl))
|
||||||
|
})
|
||||||
|
|
||||||
|
loadForPubkey = async (pubkey: string, relays: string[] = []) => {
|
||||||
|
const $profile = await this.ctx.use(Profiles).load(pubkey, relays)
|
||||||
|
|
||||||
|
return $profile?.lnurl ? this.load($profile.lnurl) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
forPubkey = (pubkey: string, relays: string[] = []): Projection<Maybe<Zapper>> => {
|
||||||
|
this.loadForPubkey(pubkey, relays)
|
||||||
|
|
||||||
|
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)]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (!split) return
|
||||||
|
|
||||||
|
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 read = (values: any[]) => {
|
||||||
|
const $zappersByLnurl = values[0] as Map<string, Zapper>
|
||||||
|
const $profiles = values.slice(1) as Array<{lnurl?: string} | undefined>
|
||||||
|
|
||||||
|
const zapperByPubkey = new Map<string, Zapper>()
|
||||||
|
|
||||||
|
splits.forEach((split, i) => {
|
||||||
|
const lnurl = $profiles[i]?.lnurl
|
||||||
|
const zapper = lnurl ? $zappersByLnurl.get(lnurl) : undefined
|
||||||
|
|
||||||
|
if (zapper) zapperByPubkey.set(split.pubkey, zapper)
|
||||||
|
})
|
||||||
|
|
||||||
|
return removeUndefined(
|
||||||
|
zapReceipts.map(zapReceipt => {
|
||||||
|
const recipient = getTagValue("p", zapReceipt.tags)
|
||||||
|
const zapper = recipient ? zapperByPubkey.get(recipient) : undefined
|
||||||
|
|
||||||
|
return zapper ? zapFromEvent(zapReceipt, zapper) : undefined
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stores: Readable<any>[] = [this.index.$, ...splits.map(split => profiles.one(split.pubkey))]
|
||||||
|
|
||||||
|
return projection(
|
||||||
|
deriveDeduplicatedByValue(stores, read),
|
||||||
|
() => read([this.index.get(), ...splits.map(split => profiles.get(split.pubkey))]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.build.json",
|
||||||
|
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"paths": {
|
||||||
|
"@welshman/feeds": ["../feeds/src/index.js"],
|
||||||
|
"@welshman/lib": ["../lib/src/index.js"],
|
||||||
|
"@welshman/net": ["../net/src/index.js"],
|
||||||
|
"@welshman/router": ["../router/src/index.js"],
|
||||||
|
"@welshman/signer": ["../signer/src/index.js"],
|
||||||
|
"@welshman/store": ["../store/src/index.js"],
|
||||||
|
"@welshman/util": ["../util/src/index.js"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"include": [
|
||||||
|
"src/**/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json"
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "@welshman/util"
|
} from "@welshman/util"
|
||||||
import {ISigner} from "@welshman/signer"
|
import {ISigner} from "@welshman/signer"
|
||||||
import {AdapterContext} from "@welshman/net"
|
import {AdapterContext} from "@welshman/net"
|
||||||
|
import type {Router} from "@welshman/router"
|
||||||
import {
|
import {
|
||||||
CreatedAtItem,
|
CreatedAtItem,
|
||||||
RequestItem,
|
RequestItem,
|
||||||
@@ -25,6 +26,7 @@ import {getFeedArgs, feedsFromTags} from "./utils.js"
|
|||||||
import {requestPage, requestDVM} from "./request.js"
|
import {requestPage, requestDVM} from "./request.js"
|
||||||
|
|
||||||
export type FeedCompilerOptions = {
|
export type FeedCompilerOptions = {
|
||||||
|
router: Router
|
||||||
signer?: ISigner
|
signer?: ISigner
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
context?: AdapterContext
|
context?: AdapterContext
|
||||||
@@ -157,6 +159,7 @@ export class FeedCompiler {
|
|||||||
items.map(({mappings, ...request}) =>
|
items.map(({mappings, ...request}) =>
|
||||||
requestDVM({
|
requestDVM({
|
||||||
...request,
|
...request,
|
||||||
|
router: this.options.router,
|
||||||
signer: this.options.signer,
|
signer: this.options.signer,
|
||||||
context: this.options.context,
|
context: this.options.context,
|
||||||
onResult: async (e: TrustedEvent) => {
|
onResult: async (e: TrustedEvent) => {
|
||||||
@@ -267,6 +270,7 @@ export class FeedCompiler {
|
|||||||
const eventsByAddress = new Map<string, TrustedEvent>()
|
const eventsByAddress = new Map<string, TrustedEvent>()
|
||||||
|
|
||||||
await requestPage({
|
await requestPage({
|
||||||
|
router: this.options.router,
|
||||||
autoClose: true,
|
autoClose: true,
|
||||||
signal: this.options.signal,
|
signal: this.options.signal,
|
||||||
context: this.options.context,
|
context: this.options.context,
|
||||||
@@ -304,6 +308,7 @@ export class FeedCompiler {
|
|||||||
labelItems.map(({mappings, relays, ...filter}) =>
|
labelItems.map(({mappings, relays, ...filter}) =>
|
||||||
requestPage({
|
requestPage({
|
||||||
relays,
|
relays,
|
||||||
|
router: this.options.router,
|
||||||
autoClose: true,
|
autoClose: true,
|
||||||
signal: this.options.signal,
|
signal: this.options.signal,
|
||||||
context: this.options.context,
|
context: this.options.context,
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ export class FeedController {
|
|||||||
await requestPage(
|
await requestPage(
|
||||||
omitVals([undefined], {
|
omitVals([undefined], {
|
||||||
relays,
|
relays,
|
||||||
|
router: this.options.router,
|
||||||
autoClose: true,
|
autoClose: true,
|
||||||
filters: trimFilters(requestFilters),
|
filters: trimFilters(requestFilters),
|
||||||
signal: this.options.signal,
|
signal: this.options.signal,
|
||||||
@@ -360,6 +361,7 @@ export class FeedController {
|
|||||||
requestPage(
|
requestPage(
|
||||||
omitVals([undefined], {
|
omitVals([undefined], {
|
||||||
relays,
|
relays,
|
||||||
|
router: this.options.router,
|
||||||
signal,
|
signal,
|
||||||
onEvent: (event: TrustedEvent) => onEvent?.(event),
|
onEvent: (event: TrustedEvent) => onEvent?.(event),
|
||||||
filters: trimFilters(requestFilters),
|
filters: trimFilters(requestFilters),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {LOCAL_RELAY_URL, Tracker, AdapterContext, request, publish} from "@welsh
|
|||||||
|
|
||||||
export type RequestPageOptions = {
|
export type RequestPageOptions = {
|
||||||
filters: Filter[]
|
filters: Filter[]
|
||||||
|
router: Router
|
||||||
onEvent: (event: TrustedEvent) => void
|
onEvent: (event: TrustedEvent) => void
|
||||||
relays?: string[]
|
relays?: string[]
|
||||||
tracker?: Tracker
|
tracker?: Tracker
|
||||||
@@ -25,6 +26,7 @@ export type RequestPageOptions = {
|
|||||||
|
|
||||||
export const requestPage = async ({
|
export const requestPage = async ({
|
||||||
filters,
|
filters,
|
||||||
|
router,
|
||||||
onEvent,
|
onEvent,
|
||||||
relays = [],
|
relays = [],
|
||||||
tracker = new Tracker(),
|
tracker = new Tracker(),
|
||||||
@@ -49,14 +51,14 @@ export const requestPage = async ({
|
|||||||
threshold: 0.1,
|
threshold: 0.1,
|
||||||
autoClose,
|
autoClose,
|
||||||
filters: withSearch,
|
filters: withSearch,
|
||||||
relays: Router.get().Search().getUrls(),
|
relays: router.Search().getUrls(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (withoutSearch.length > 0) {
|
if (withoutSearch.length > 0) {
|
||||||
promises.push(
|
promises.push(
|
||||||
...getFilterSelections(filters).flatMap(({relays, filters}) =>
|
...getFilterSelections(filters, router).flatMap(({relays, filters}) =>
|
||||||
request({
|
request({
|
||||||
tracker,
|
tracker,
|
||||||
signal,
|
signal,
|
||||||
@@ -91,6 +93,7 @@ export const requestPage = async ({
|
|||||||
|
|
||||||
export type RequestDVMOptions = {
|
export type RequestDVMOptions = {
|
||||||
kind: number
|
kind: number
|
||||||
|
router: Router
|
||||||
tags?: string[][]
|
tags?: string[][]
|
||||||
relays?: string[]
|
relays?: string[]
|
||||||
signer?: ISigner
|
signer?: ISigner
|
||||||
@@ -100,6 +103,7 @@ export type RequestDVMOptions = {
|
|||||||
|
|
||||||
export const requestDVM = async ({
|
export const requestDVM = async ({
|
||||||
kind,
|
kind,
|
||||||
|
router,
|
||||||
onResult,
|
onResult,
|
||||||
tags = [],
|
tags = [],
|
||||||
relays = [],
|
relays = [],
|
||||||
@@ -110,10 +114,10 @@ export const requestDVM = async ({
|
|||||||
const events = await request({
|
const events = await request({
|
||||||
autoClose: true,
|
autoClose: true,
|
||||||
filters: [{kinds: [RELAYS], authors: getPubkeyTagValues(tags)}],
|
filters: [{kinds: [RELAYS], authors: getPubkeyTagValues(tags)}],
|
||||||
relays: Router.get().Index().policy(addMinimalFallbacks).getUrls(),
|
relays: router.Index().policy(addMinimalFallbacks).getUrls(),
|
||||||
})
|
})
|
||||||
|
|
||||||
relays = Router.get()
|
relays = router
|
||||||
.FromRelays(events.flatMap(e => getRelaysFromList(readList(asDecryptedEvent(e)))))
|
.FromRelays(events.flatMap(e => getRelaysFromList(readList(asDecryptedEvent(e)))))
|
||||||
.policy(addMinimalFallbacks)
|
.policy(addMinimalFallbacks)
|
||||||
.getUrls()
|
.getUrls()
|
||||||
|
|||||||
@@ -1635,6 +1635,17 @@ export const member =
|
|||||||
(x: T) =>
|
(x: T) =>
|
||||||
Array.from(xs).includes(x)
|
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
|
// Sets
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import EventEmitter from "events"
|
import EventEmitter from "events"
|
||||||
import {call, sleep, mergeRight, on} from "@welshman/lib"
|
import {call, sleep, on} from "@welshman/lib"
|
||||||
import {isRelayUrl, matchFilters, Filter} from "@welshman/util"
|
import {isRelayUrl, matchFilters, Filter} from "@welshman/util"
|
||||||
import {LOCAL_RELAY_URL, Repository} from "./repository.js"
|
import {LOCAL_RELAY_URL, Repository} from "./repository.js"
|
||||||
import {
|
import {
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from "./message.js"
|
} from "./message.js"
|
||||||
import {Socket, SocketEvent} from "./socket.js"
|
import {Socket, SocketEvent} from "./socket.js"
|
||||||
import {Unsubscriber} from "./util.js"
|
import {Unsubscriber} from "./util.js"
|
||||||
import {netContext, NetContext} from "./context.js"
|
import type {NetContext} from "./context.js"
|
||||||
|
|
||||||
export enum AdapterEvent {
|
export enum AdapterEvent {
|
||||||
Receive = "receive",
|
Receive = "receive",
|
||||||
@@ -150,9 +150,7 @@ export class MockAdapter extends AbstractAdapter {
|
|||||||
|
|
||||||
export type AdapterContext = Partial<NetContext>
|
export type AdapterContext = Partial<NetContext>
|
||||||
|
|
||||||
export const getAdapter = (url: string, adapterContext: AdapterContext = {}) => {
|
export const getAdapter = (url: string, context: AdapterContext = {}) => {
|
||||||
const context = mergeRight(netContext, adapterContext as any)
|
|
||||||
|
|
||||||
if (context.getAdapter) {
|
if (context.getAdapter) {
|
||||||
const adapter = context.getAdapter(url, context)
|
const adapter = context.getAdapter(url, context)
|
||||||
|
|
||||||
@@ -162,10 +160,18 @@ export const getAdapter = (url: string, adapterContext: AdapterContext = {}) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (url === LOCAL_RELAY_URL) {
|
if (url === LOCAL_RELAY_URL) {
|
||||||
|
if (!context.repository) {
|
||||||
|
throw new Error("LOCAL_RELAY_URL cannot be used without context.repository")
|
||||||
|
}
|
||||||
|
|
||||||
return new LocalAdapter(context.repository)
|
return new LocalAdapter(context.repository)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRelayUrl(url)) {
|
if (isRelayUrl(url)) {
|
||||||
|
if (!context.pool) {
|
||||||
|
throw new Error("Unable to connect to relays without context.pool")
|
||||||
|
}
|
||||||
|
|
||||||
return new SocketAdapter(context.pool.get(url))
|
return new SocketAdapter(context.pool.get(url))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,11 @@
|
|||||||
import {verifyEvent, TrustedEvent} from "@welshman/util"
|
|
||||||
import {AbstractAdapter} from "./adapter.js"
|
import {AbstractAdapter} from "./adapter.js"
|
||||||
import {Repository} from "./repository.js"
|
import {Repository} from "./repository.js"
|
||||||
import {Pool} from "./pool.js"
|
import {Pool} from "./pool.js"
|
||||||
|
|
||||||
export type NetContext = {
|
export type AdapterFactory = (url: string, context: NetContext) => AbstractAdapter
|
||||||
pool: Pool
|
|
||||||
repository: Repository
|
|
||||||
isEventValid: (event: TrustedEvent, url: string) => boolean
|
|
||||||
isEventDeleted: (event: TrustedEvent, url: string) => boolean
|
|
||||||
getAdapter?: (url: string, context: NetContext) => AbstractAdapter
|
|
||||||
}
|
|
||||||
|
|
||||||
export const netContext: NetContext = {
|
export type NetContext = {
|
||||||
pool: Pool.get(),
|
pool?: Pool
|
||||||
repository: Repository.get(),
|
repository?: Repository
|
||||||
isEventValid: (event, url) => verifyEvent(event),
|
getAdapter?: AdapterFactory
|
||||||
isEventDeleted: (event, url) => netContext.repository.isDeleted(event),
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,38 +5,15 @@ import {defaultSocketPolicies} from "./policy.js"
|
|||||||
|
|
||||||
export type PoolSubscription = (socket: Socket) => void
|
export type PoolSubscription = (socket: Socket) => void
|
||||||
|
|
||||||
export type PoolOptions = {
|
|
||||||
makeSocket?: (url: string) => Socket
|
|
||||||
}
|
|
||||||
|
|
||||||
export let poolSingleton: Pool
|
|
||||||
|
|
||||||
export class Pool {
|
export class Pool {
|
||||||
|
socketPolicies = [...defaultSocketPolicies]
|
||||||
_data = new Map<string, Socket>()
|
_data = new Map<string, Socket>()
|
||||||
_subs: PoolSubscription[] = []
|
_subs: PoolSubscription[] = []
|
||||||
|
|
||||||
static get() {
|
|
||||||
if (!poolSingleton) {
|
|
||||||
poolSingleton = new Pool()
|
|
||||||
}
|
|
||||||
|
|
||||||
return poolSingleton
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(readonly options: PoolOptions = {}) {}
|
|
||||||
|
|
||||||
has(url: string) {
|
has(url: string) {
|
||||||
return this._data.has(normalizeRelayUrl(url))
|
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 {
|
get(_url: string): Socket {
|
||||||
const url = normalizeRelayUrl(_url)
|
const url = normalizeRelayUrl(_url)
|
||||||
const socket = this._data.get(url)
|
const socket = this._data.get(url)
|
||||||
@@ -45,7 +22,7 @@ export class Pool {
|
|||||||
return socket
|
return socket
|
||||||
}
|
}
|
||||||
|
|
||||||
const newSocket = this.makeSocket(url)
|
const newSocket = new Socket(url, this.socketPolicies)
|
||||||
|
|
||||||
this._data.set(url, newSocket)
|
this._data.set(url, newSocket)
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,6 @@ export const LOCAL_RELAY_URL = "local://welshman.relay/"
|
|||||||
|
|
||||||
const getDay = (ts: number) => Math.floor(ts / DAY)
|
const getDay = (ts: number) => Math.floor(ts / DAY)
|
||||||
|
|
||||||
export let repositorySingleton: Repository
|
|
||||||
|
|
||||||
export type RepositoryUpdate = {
|
export type RepositoryUpdate = {
|
||||||
added: TrustedEvent[]
|
added: TrustedEvent[]
|
||||||
removed: Set<string>
|
removed: Set<string>
|
||||||
@@ -61,14 +59,6 @@ export class Repository extends Emitter {
|
|||||||
deletes = new Map<string, {created_at: number; pubkey: string}[]>()
|
deletes = new Map<string, {created_at: number; pubkey: string}[]>()
|
||||||
expired = new Map<string, number>()
|
expired = new Map<string, number>()
|
||||||
|
|
||||||
static get() {
|
|
||||||
if (!repositorySingleton) {
|
|
||||||
repositorySingleton = new Repository()
|
|
||||||
}
|
|
||||||
|
|
||||||
return repositorySingleton
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
|
|
||||||
@@ -81,9 +71,7 @@ export class Repository extends Emitter {
|
|||||||
return Array.from(this.eventsById.values())
|
return Array.from(this.eventsById.values())
|
||||||
}
|
}
|
||||||
|
|
||||||
load = (events: TrustedEvent[]) => {
|
clear = () => {
|
||||||
const stale = new Set(this.eventsById.keys())
|
|
||||||
|
|
||||||
this.eventsById.clear()
|
this.eventsById.clear()
|
||||||
this.eventsByAddress.clear()
|
this.eventsByAddress.clear()
|
||||||
this.eventsByTag.clear()
|
this.eventsByTag.clear()
|
||||||
@@ -92,6 +80,13 @@ export class Repository extends Emitter {
|
|||||||
this.eventsByKind.clear()
|
this.eventsByKind.clear()
|
||||||
this.deletes.clear()
|
this.deletes.clear()
|
||||||
this.expired.clear()
|
this.expired.clear()
|
||||||
|
this.emit("clear")
|
||||||
|
}
|
||||||
|
|
||||||
|
load = (events: TrustedEvent[]) => {
|
||||||
|
const stale = new Set(this.eventsById.keys())
|
||||||
|
|
||||||
|
this.clear()
|
||||||
|
|
||||||
const added = []
|
const added = []
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
unionFilters,
|
unionFilters,
|
||||||
matchFilters,
|
matchFilters,
|
||||||
TrustedEvent,
|
TrustedEvent,
|
||||||
|
verifyEvent,
|
||||||
deduplicateEvents,
|
deduplicateEvents,
|
||||||
getFilterResultCardinality,
|
getFilterResultCardinality,
|
||||||
} from "@welshman/util"
|
} from "@welshman/util"
|
||||||
@@ -27,7 +28,6 @@ import {
|
|||||||
} from "./message.js"
|
} from "./message.js"
|
||||||
import {getAdapter, AdapterContext, AdapterEvent} from "./adapter.js"
|
import {getAdapter, AdapterContext, AdapterEvent} from "./adapter.js"
|
||||||
import {SocketEvent, SocketStatus} from "./socket.js"
|
import {SocketEvent, SocketStatus} from "./socket.js"
|
||||||
import {netContext} from "./context.js"
|
|
||||||
import {Tracker} from "./tracker.js"
|
import {Tracker} from "./tracker.js"
|
||||||
|
|
||||||
export type BaseRequestOptions = {
|
export type BaseRequestOptions = {
|
||||||
@@ -36,7 +36,6 @@ export type BaseRequestOptions = {
|
|||||||
context?: AdapterContext
|
context?: AdapterContext
|
||||||
autoClose?: boolean
|
autoClose?: boolean
|
||||||
isEventValid?: (event: TrustedEvent, url: string) => boolean
|
isEventValid?: (event: TrustedEvent, url: string) => boolean
|
||||||
isEventDeleted?: (event: TrustedEvent, url: string) => boolean
|
|
||||||
onEvent?: (event: TrustedEvent, url: string) => void
|
onEvent?: (event: TrustedEvent, url: string) => void
|
||||||
onDeleted?: (event: unknown, url: string) => void
|
onDeleted?: (event: unknown, url: string) => void
|
||||||
onInvalid?: (event: unknown, url: string) => void
|
onInvalid?: (event: unknown, url: string) => void
|
||||||
@@ -60,8 +59,8 @@ export const requestOne = (options: RequestOneOptions) => {
|
|||||||
const deferred = defer<TrustedEvent[]>()
|
const deferred = defer<TrustedEvent[]>()
|
||||||
const tracker = options.tracker || new Tracker()
|
const tracker = options.tracker || new Tracker()
|
||||||
const adapter = getAdapter(options.relay, options.context)
|
const adapter = getAdapter(options.relay, options.context)
|
||||||
const isEventValid = options.isEventValid || netContext.isEventValid
|
const isEventValid: (event: TrustedEvent, url: string) => boolean =
|
||||||
const isEventDeleted = options.isEventDeleted || netContext.isEventDeleted
|
options.isEventValid || (event => verifyEvent(event))
|
||||||
|
|
||||||
let closed = false
|
let closed = false
|
||||||
|
|
||||||
@@ -88,7 +87,7 @@ export const requestOne = (options: RequestOneOptions) => {
|
|||||||
if (ids.has(id)) {
|
if (ids.has(id)) {
|
||||||
if (tracker.track(event.id, url)) {
|
if (tracker.track(event.id, url)) {
|
||||||
options.onDuplicate?.(event, url)
|
options.onDuplicate?.(event, url)
|
||||||
} else if (isEventDeleted(event, url)) {
|
} else if (options.context?.repository?.isDeleted(event)) {
|
||||||
options.onDeleted?.(event, url)
|
options.onDeleted?.(event, url)
|
||||||
} else if (!isEventValid(event, url)) {
|
} else if (!isEventValid(event, url)) {
|
||||||
options.onInvalid?.(event, url)
|
options.onInvalid?.(event, url)
|
||||||
@@ -216,7 +215,6 @@ export type LoaderOptions = {
|
|||||||
threshold?: number
|
threshold?: number
|
||||||
context?: AdapterContext
|
context?: AdapterContext
|
||||||
isEventValid?: (event: TrustedEvent, url: string) => boolean
|
isEventValid?: (event: TrustedEvent, url: string) => boolean
|
||||||
isEventDeleted?: (event: TrustedEvent, url: string) => boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LoadOptions = {
|
export type LoadOptions = {
|
||||||
@@ -320,7 +318,6 @@ export const makeLoader = (options: LoaderOptions) =>
|
|||||||
signal: signalsByRelay.get(relay),
|
signal: signalsByRelay.get(relay),
|
||||||
context: options.context,
|
context: options.context,
|
||||||
isEventValid: options.isEventValid,
|
isEventValid: options.isEventValid,
|
||||||
isEventDeleted: options.isEventDeleted,
|
|
||||||
onEvent: (event: TrustedEvent, url: string) => {
|
onEvent: (event: TrustedEvent, url: string) => {
|
||||||
for (const request of getOpenRequests()) {
|
for (const request of getOpenRequests()) {
|
||||||
if (matchFilters(request.filters, event)) {
|
if (matchFilters(request.filters, event)) {
|
||||||
@@ -348,4 +345,4 @@ export const makeLoader = (options: LoaderOptions) =>
|
|||||||
return allRequests.map(r => resultsByRequest.get(r) || [])
|
return allRequests.map(r => resultsByRequest.get(r) || [])
|
||||||
}) as Loader
|
}) as Loader
|
||||||
|
|
||||||
export const load = makeLoader({delay: 200, timeout: 3000, threshold: 0.5})
|
export const load = makeLoader({delay: 30, timeout: 3000, threshold: 0.5})
|
||||||
|
|||||||
@@ -41,10 +41,15 @@ export class WrapManager extends Emitter {
|
|||||||
|
|
||||||
// Adding/importing
|
// Adding/importing
|
||||||
|
|
||||||
load = (wrapItems: WrapItem[]) => {
|
clear = () => {
|
||||||
this._wrapIndex.clear()
|
this._wrapIndex.clear()
|
||||||
this._rumorIndex.clear()
|
this._rumorIndex.clear()
|
||||||
this._recipientIndex.clear()
|
this._recipientIndex.clear()
|
||||||
|
this.emit("load")
|
||||||
|
}
|
||||||
|
|
||||||
|
load = (wrapItems: WrapItem[]) => {
|
||||||
|
this.clear()
|
||||||
|
|
||||||
for (const wrapItem of wrapItems) {
|
for (const wrapItem of wrapItems) {
|
||||||
this._add(wrapItem)
|
this._add(wrapItem)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
nth,
|
nth,
|
||||||
uniq,
|
uniq,
|
||||||
intersection,
|
intersection,
|
||||||
mergeLeft,
|
|
||||||
first,
|
first,
|
||||||
clamp,
|
clamp,
|
||||||
sortBy,
|
sortBy,
|
||||||
@@ -28,14 +27,10 @@ import {
|
|||||||
normalizeRelayUrl,
|
normalizeRelayUrl,
|
||||||
TrustedEvent,
|
TrustedEvent,
|
||||||
Filter,
|
Filter,
|
||||||
readList,
|
|
||||||
getAncestorTags,
|
getAncestorTags,
|
||||||
asDecryptedEvent,
|
|
||||||
getRelaysFromList,
|
|
||||||
getPubkeyTags,
|
getPubkeyTags,
|
||||||
RelayMode,
|
RelayMode,
|
||||||
} from "@welshman/util"
|
} from "@welshman/util"
|
||||||
import {Repository} from "@welshman/net"
|
|
||||||
|
|
||||||
export const INDEXED_KINDS = [PROFILE, RELAYS, MESSAGING_RELAYS, FOLLOWS]
|
export const INDEXED_KINDS = [PROFILE, RELAYS, MESSAGING_RELAYS, FOLLOWS]
|
||||||
|
|
||||||
@@ -114,30 +109,8 @@ export const addMaximalFallbacks = (count: number, limit: number) => limit - cou
|
|||||||
|
|
||||||
// Router class
|
// Router class
|
||||||
|
|
||||||
export const routerContext: RouterOptions = {
|
|
||||||
getPubkeyRelays: (pubkey: string, mode?: RelayMode) => {
|
|
||||||
return uniq(
|
|
||||||
Repository.get()
|
|
||||||
.query([{kinds: [RELAYS], authors: [pubkey]}])
|
|
||||||
.flatMap(event => getRelaysFromList(readList(asDecryptedEvent(event)), mode)),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Router {
|
export class Router {
|
||||||
readonly options: RouterOptions
|
constructor(readonly options: RouterOptions) {}
|
||||||
|
|
||||||
static configure(options: RouterOptions) {
|
|
||||||
Object.assign(routerContext, options)
|
|
||||||
}
|
|
||||||
|
|
||||||
static get() {
|
|
||||||
return new Router(routerContext)
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(options: RouterOptions) {
|
|
||||||
this.options = mergeLeft(options, routerContext)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Utilities derived from options
|
// Utilities derived from options
|
||||||
|
|
||||||
@@ -357,58 +330,58 @@ export class RouterScenario {
|
|||||||
|
|
||||||
type FilterScenario = {filter: Filter; scenario: RouterScenario}
|
type FilterScenario = {filter: Filter; scenario: RouterScenario}
|
||||||
|
|
||||||
type FilterSelectionRule = (filter: Filter) => FilterScenario[]
|
type FilterSelectionRule = (filter: Filter, router: Router) => FilterScenario[]
|
||||||
|
|
||||||
export const getFilterSelectionsForSearch = (filter: Filter) => {
|
export const getFilterSelectionsForSearch = (filter: Filter, router: Router) => {
|
||||||
if (!filter.search) return []
|
if (!filter.search) return []
|
||||||
|
|
||||||
const relays = routerContext.getSearchRelays?.() || []
|
const relays = router.options.getSearchRelays?.() || []
|
||||||
|
|
||||||
return [{filter, scenario: Router.get().FromRelays(relays).weight(10)}]
|
return [{filter, scenario: router.FromRelays(relays).weight(10)}]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getFilterSelectionsForWraps = (filter: Filter) => {
|
export const getFilterSelectionsForWraps = (filter: Filter, router: Router) => {
|
||||||
if (!filter.kinds?.includes(WRAP) || filter.authors) return []
|
if (!filter.kinds?.includes(WRAP) || filter.authors) return []
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
filter: {...filter, kinds: [WRAP]},
|
filter: {...filter, kinds: [WRAP]},
|
||||||
scenario: Router.get().MessagesForUser(),
|
scenario: router.MessagesForUser(),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getFilterSelectionsForIndexedKinds = (filter: Filter) => {
|
export const getFilterSelectionsForIndexedKinds = (filter: Filter, router: Router) => {
|
||||||
const kinds = intersection(INDEXED_KINDS, filter.kinds || [])
|
const kinds = intersection(INDEXED_KINDS, filter.kinds || [])
|
||||||
|
|
||||||
if (kinds.length === 0) return []
|
if (kinds.length === 0) return []
|
||||||
|
|
||||||
const relays = routerContext.getIndexerRelays?.() || []
|
const relays = router.options.getIndexerRelays?.() || []
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
filter: {...filter, kinds},
|
filter: {...filter, kinds},
|
||||||
scenario: Router.get().FromRelays(relays),
|
scenario: router.FromRelays(relays),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getFilterSelectionsForAuthors = (filter: Filter) => {
|
export const getFilterSelectionsForAuthors = (filter: Filter, router: Router) => {
|
||||||
if (!filter.authors) return []
|
if (!filter.authors) return []
|
||||||
|
|
||||||
const chunkCount = clamp([1, 30], Math.round(filter.authors.length / 30))
|
const chunkCount = clamp([1, 30], Math.round(filter.authors.length / 30))
|
||||||
|
|
||||||
return chunks(chunkCount, filter.authors).map(authors => ({
|
return chunks(chunkCount, filter.authors).map(authors => ({
|
||||||
filter: {...filter, authors},
|
filter: {...filter, authors},
|
||||||
scenario: Router.get().FromPubkeys(authors),
|
scenario: router.FromPubkeys(authors),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getFilterSelectionsForUser = (filter: Filter) => [
|
export const getFilterSelectionsForUser = (filter: Filter, router: Router) => [
|
||||||
{filter, scenario: Router.get().ForUser().weight(0.2)},
|
{filter, scenario: router.ForUser().weight(0.2)},
|
||||||
]
|
]
|
||||||
|
|
||||||
export const defaultFilterSelectionRules = [
|
export const defaultFilterSelectionRules: FilterSelectionRule[] = [
|
||||||
getFilterSelectionsForSearch,
|
getFilterSelectionsForSearch,
|
||||||
getFilterSelectionsForWraps,
|
getFilterSelectionsForWraps,
|
||||||
getFilterSelectionsForIndexedKinds,
|
getFilterSelectionsForIndexedKinds,
|
||||||
@@ -418,13 +391,14 @@ export const defaultFilterSelectionRules = [
|
|||||||
|
|
||||||
export const getFilterSelections = (
|
export const getFilterSelections = (
|
||||||
filters: Filter[],
|
filters: Filter[],
|
||||||
|
router: Router,
|
||||||
rules: FilterSelectionRule[] = defaultFilterSelectionRules,
|
rules: FilterSelectionRule[] = defaultFilterSelectionRules,
|
||||||
): RelaysAndFilters[] => {
|
): RelaysAndFilters[] => {
|
||||||
const filtersById = new Map<string, Filter>()
|
const filtersById = new Map<string, Filter>()
|
||||||
const scenariosById = new Map<string, RouterScenario[]>()
|
const scenariosById = new Map<string, RouterScenario[]>()
|
||||||
|
|
||||||
for (const filter of filters) {
|
for (const filter of filters) {
|
||||||
for (const filterScenario of rules.flatMap(rule => rule(filter))) {
|
for (const filterScenario of rules.flatMap(rule => rule(filter, router))) {
|
||||||
const id = getFilterId(filterScenario.filter)
|
const id = getFilterId(filterScenario.filter)
|
||||||
|
|
||||||
filtersById.set(id, filterScenario.filter)
|
filtersById.set(id, filterScenario.filter)
|
||||||
@@ -435,9 +409,7 @@ export const getFilterSelections = (
|
|||||||
const result = []
|
const result = []
|
||||||
|
|
||||||
for (const [id, filter] of filtersById.entries()) {
|
for (const [id, filter] of filtersById.entries()) {
|
||||||
const scenario = Router.get()
|
const scenario = router.merge(scenariosById.get(id) || []).policy(addMinimalFallbacks)
|
||||||
.merge(scenariosById.get(id) || [])
|
|
||||||
.policy(addMinimalFallbacks)
|
|
||||||
|
|
||||||
result.push({filters: [filter], relays: scenario.getUrls()})
|
result.push({filters: [filter], relays: scenario.getUrls()})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -123,7 +123,7 @@ export const ROOM_JOIN = 9021
|
|||||||
export const ROOM_LEAVE = 9022
|
export const ROOM_LEAVE = 9022
|
||||||
export const ZAP_GOAL = 9041
|
export const ZAP_GOAL = 9041
|
||||||
export const ZAP_REQUEST = 9734
|
export const ZAP_REQUEST = 9734
|
||||||
export const ZAP_RESPONSE = 9735
|
export const ZAP_RECEIPT = 9735
|
||||||
export const HIGHLIGHT = 9802
|
export const HIGHLIGHT = 9802
|
||||||
export const MUTES = 10000
|
export const MUTES = 10000
|
||||||
export const PINS = 10001
|
export const PINS = 10001
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {now, tryCatch, fetchJson, hexToBech32, fromPairs} from "@welshman/lib"
|
import {now, tryCatch, fetchJson, hexToBech32, fromPairs, sum, allPass, nthEq, nth} from "@welshman/lib"
|
||||||
import {ZAP_RESPONSE, ZAP_REQUEST} from "./Kinds.js"
|
import {ZAP_RECEIPT, ZAP_REQUEST} from "./Kinds.js"
|
||||||
import {getTagValue} from "./Tags.js"
|
import {getTagValue} from "./Tags.js"
|
||||||
import type {Filter} from "./Filters.js"
|
import type {Filter} from "./Filters.js"
|
||||||
import type {TrustedEvent, SignedEvent} from "./Events.js"
|
import type {TrustedEvent, SignedEvent} from "./Events.js"
|
||||||
@@ -138,6 +138,79 @@ export const zapFromEvent = (response: TrustedEvent, zapper: Zapper | undefined)
|
|||||||
return zap
|
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 = {
|
export type ZapRequestParams = {
|
||||||
msats: number
|
msats: number
|
||||||
zapper: Zapper
|
zapper: Zapper
|
||||||
@@ -201,7 +274,7 @@ export const getZapResponseFilter = ({zapper, pubkey, eventId}: ZapResponseFilte
|
|||||||
}
|
}
|
||||||
|
|
||||||
const filter: Filter = {
|
const filter: Filter = {
|
||||||
kinds: [ZAP_RESPONSE],
|
kinds: [ZAP_RECEIPT],
|
||||||
authors: [zapper.nostrPubkey],
|
authors: [zapper.nostrPubkey],
|
||||||
since: now() - 30,
|
since: now() - 30,
|
||||||
"#p": [pubkey],
|
"#p": [pubkey],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export * from "./Encryptable.js"
|
|||||||
export * from "./Events.js"
|
export * from "./Events.js"
|
||||||
export * from "./Filters.js"
|
export * from "./Filters.js"
|
||||||
export * from "./Handler.js"
|
export * from "./Handler.js"
|
||||||
|
export * from "./Handles.js"
|
||||||
export * from "./Keys.js"
|
export * from "./Keys.js"
|
||||||
export * from "./Kinds.js"
|
export * from "./Kinds.js"
|
||||||
export * from "./Links.js"
|
export * from "./Links.js"
|
||||||
|
|||||||
Generated
+46
@@ -110,6 +110,52 @@ importers:
|
|||||||
specifier: ~5.8.0
|
specifier: ~5.8.0
|
||||||
version: 5.8.2
|
version: 5.8.2
|
||||||
|
|
||||||
|
packages/client:
|
||||||
|
dependencies:
|
||||||
|
fuse.js:
|
||||||
|
specifier: ^7.0.0
|
||||||
|
version: 7.1.0
|
||||||
|
throttle-debounce:
|
||||||
|
specifier: ^5.0.2
|
||||||
|
version: 5.0.2
|
||||||
|
devDependencies:
|
||||||
|
'@pomade/core':
|
||||||
|
specifier: ^0.2.1
|
||||||
|
version: 0.2.1(@frostr/bifrost@1.0.7(typescript@5.8.2))(@noble/hashes@2.0.1)(@welshman/lib@packages+lib)(@welshman/net@packages+net)(@welshman/signer@packages+signer)(@welshman/util@packages+util)(nostr-tools@2.19.4(typescript@5.8.2))
|
||||||
|
'@types/throttle-debounce':
|
||||||
|
specifier: ^5.0.2
|
||||||
|
version: 5.0.2
|
||||||
|
'@welshman/feeds':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../feeds
|
||||||
|
'@welshman/lib':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../lib
|
||||||
|
'@welshman/net':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../net
|
||||||
|
'@welshman/router':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../router
|
||||||
|
'@welshman/signer':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../signer
|
||||||
|
'@welshman/store':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../store
|
||||||
|
'@welshman/util':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../util
|
||||||
|
rimraf:
|
||||||
|
specifier: ~6.0.0
|
||||||
|
version: 6.0.1
|
||||||
|
svelte:
|
||||||
|
specifier: ^5.39.12
|
||||||
|
version: 5.46.3
|
||||||
|
typescript:
|
||||||
|
specifier: ~5.8.0
|
||||||
|
version: 5.8.2
|
||||||
|
|
||||||
packages/content:
|
packages/content:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@braintree/sanitize-url':
|
'@braintree/sanitize-url':
|
||||||
|
|||||||
Reference in New Issue
Block a user