AI refactor
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.8.13",
|
"version": "0.8.13",
|
||||||
"author": "hodlbod",
|
"author": "hodlbod",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"description": "A client god-object for building nostr applications",
|
"description": "An instance-based, composable client for building nostr applications",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import {BLOCKED_RELAYS, asDecryptedEvent, readList, getRelaysFromList} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {RepositoryCollection} from "./repositoryCollection.js"
|
||||||
|
import type {ClientContext} from "./client.js"
|
||||||
|
import type {RelayLists} from "./relayLists.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 RepositoryCollection<ReturnType<typeof readList>> {
|
||||||
|
constructor(
|
||||||
|
ctx: ClientContext,
|
||||||
|
readonly relayLists: RelayLists,
|
||||||
|
) {
|
||||||
|
super(ctx, {
|
||||||
|
filters: [{kinds: [BLOCKED_RELAYS]}],
|
||||||
|
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
||||||
|
getKey: list => list.event.pubkey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(pubkey: string, relayHints: string[] = []) {
|
||||||
|
return this.relayLists.makeOutboxLoader(BLOCKED_RELAYS)(pubkey, relayHints)
|
||||||
|
}
|
||||||
|
|
||||||
|
getBlockedRelays = (pubkey: string) => getRelaysFromList(this.get(pubkey))
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import {BLOSSOM_SERVERS, asDecryptedEvent, readList} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {RepositoryCollection} from "./repositoryCollection.js"
|
||||||
|
import type {ClientContext} from "./client.js"
|
||||||
|
import type {RelayLists} from "./relayLists.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blossom server lists (kind 10063), keyed by pubkey. Loaded via the outbox
|
||||||
|
* model (the author's write relays), so it depends on the relay-list collection.
|
||||||
|
*/
|
||||||
|
export class BlossomServerLists extends RepositoryCollection<ReturnType<typeof readList>> {
|
||||||
|
constructor(
|
||||||
|
ctx: ClientContext,
|
||||||
|
readonly relayLists: RelayLists,
|
||||||
|
) {
|
||||||
|
super(ctx, {
|
||||||
|
filters: [{kinds: [BLOSSOM_SERVERS]}],
|
||||||
|
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
||||||
|
getKey: list => list.event.pubkey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(pubkey: string, relayHints: string[] = []) {
|
||||||
|
return this.relayLists.makeOutboxLoader(BLOSSOM_SERVERS)(pubkey, relayHints)
|
||||||
|
}
|
||||||
|
}
|
||||||
+169
-37
@@ -1,7 +1,57 @@
|
|||||||
import {Maybe} from '@welshman/lib'
|
import type {Readable, Unsubscriber} from "svelte/store"
|
||||||
import {Repository, AdapterFactory, NetContext, WrapManager, DiffOptions, PullOptions, PushOptions, RequestOptions, PublishOptions, LoaderOptions, Tracker, Pool, push, pull, diff, publish, request, makeLoader} from '@welshman/net'
|
import {isDVMKind, isEphemeralKind} from "@welshman/util"
|
||||||
import {RelayStats} from './relayStats.js'
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {User} from './user.js'
|
import {
|
||||||
|
Pool,
|
||||||
|
Socket,
|
||||||
|
SocketEvent,
|
||||||
|
Tracker,
|
||||||
|
Repository,
|
||||||
|
WrapManager,
|
||||||
|
defaultSocketPolicies,
|
||||||
|
netContext,
|
||||||
|
isRelayEvent,
|
||||||
|
request,
|
||||||
|
publish,
|
||||||
|
diff,
|
||||||
|
pull,
|
||||||
|
push,
|
||||||
|
makeLoader,
|
||||||
|
} from "@welshman/net"
|
||||||
|
import type {
|
||||||
|
AdapterContext,
|
||||||
|
AdapterFactory,
|
||||||
|
SocketPolicy,
|
||||||
|
RelayMessage,
|
||||||
|
Loader,
|
||||||
|
LoaderOptions,
|
||||||
|
RequestOptions,
|
||||||
|
PublishOptions,
|
||||||
|
DiffOptions,
|
||||||
|
PullOptions,
|
||||||
|
PushOptions,
|
||||||
|
} from "@welshman/net"
|
||||||
|
import {
|
||||||
|
getEventsById,
|
||||||
|
deriveEventsById,
|
||||||
|
deriveEvents,
|
||||||
|
makeDeriveEvent,
|
||||||
|
getEventsByIdByUrl,
|
||||||
|
deriveEventsByIdByUrl,
|
||||||
|
getEventsByIdForUrl,
|
||||||
|
deriveEventsByIdForUrl,
|
||||||
|
deriveItemsByKey,
|
||||||
|
deriveIsDeleted,
|
||||||
|
} from "@welshman/store"
|
||||||
|
import type {
|
||||||
|
EventsByIdOptions,
|
||||||
|
EventOptions,
|
||||||
|
EventsByIdByUrlOptions,
|
||||||
|
EventsByIdForUrlOptions,
|
||||||
|
ItemsByKey,
|
||||||
|
ItemsByKeyOptions,
|
||||||
|
} from "@welshman/store"
|
||||||
|
import type {User} from "./user.js"
|
||||||
|
|
||||||
export type ClientOptions = {
|
export type ClientOptions = {
|
||||||
user?: User
|
user?: User
|
||||||
@@ -9,26 +59,82 @@ export type ClientOptions = {
|
|||||||
socketPolicies?: SocketPolicy[]
|
socketPolicies?: SocketPolicy[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Client {
|
/**
|
||||||
|
* The narrow seam that data modules (plugins) depend on instead of the concrete
|
||||||
|
* `Client`. Because plugins reference only `ClientContext`, the client never
|
||||||
|
* imports them and they never create a dependency cycle back to the client —
|
||||||
|
* the dependency is strictly one-way (plugin -> context).
|
||||||
|
*
|
||||||
|
* `Client implements ClientContext`, so the two stay in sync by construction.
|
||||||
|
*/
|
||||||
|
export interface ClientContext {
|
||||||
user?: User
|
user?: User
|
||||||
pool: Pool
|
pool: Pool
|
||||||
tracker: Tracker
|
tracker: Tracker
|
||||||
repository: Repository
|
repository: Repository
|
||||||
wrapManager: WrapManager
|
wrapManager: WrapManager
|
||||||
netContext: NetContext
|
|
||||||
relayStats: RelayStats
|
|
||||||
|
|
||||||
constructor(options: ClientOptions) {
|
// Net utilities, with this client's context baked in
|
||||||
|
request: (options: Omit<RequestOptions, "context">) => ReturnType<typeof request>
|
||||||
|
publish: (options: Omit<PublishOptions, "context">) => ReturnType<typeof publish>
|
||||||
|
diff: (options: Omit<DiffOptions, "context">) => ReturnType<typeof diff>
|
||||||
|
pull: (options: Omit<PullOptions, "context">) => ReturnType<typeof pull>
|
||||||
|
push: (options: Omit<PushOptions, "context">) => ReturnType<typeof push>
|
||||||
|
makeLoader: (options: Omit<LoaderOptions, "context">) => Loader
|
||||||
|
load: Loader
|
||||||
|
|
||||||
|
// Store utilities, with this client's repository/tracker baked in
|
||||||
|
getEventsById: (options: Omit<EventsByIdOptions, "repository">) => ReturnType<typeof getEventsById>
|
||||||
|
deriveEventsById: (
|
||||||
|
options: Omit<EventsByIdOptions, "repository">,
|
||||||
|
) => ReturnType<typeof deriveEventsById>
|
||||||
|
deriveEvents: (options: Omit<EventsByIdOptions, "repository">) => ReturnType<typeof deriveEvents>
|
||||||
|
makeDeriveEvent: (options: Omit<EventOptions, "repository">) => ReturnType<typeof makeDeriveEvent>
|
||||||
|
getEventsByIdByUrl: (
|
||||||
|
options: Omit<EventsByIdByUrlOptions, "tracker" | "repository">,
|
||||||
|
) => ReturnType<typeof getEventsByIdByUrl>
|
||||||
|
deriveEventsByIdByUrl: (
|
||||||
|
options: Omit<EventsByIdByUrlOptions, "tracker" | "repository">,
|
||||||
|
) => ReturnType<typeof deriveEventsByIdByUrl>
|
||||||
|
getEventsByIdForUrl: (
|
||||||
|
options: Omit<EventsByIdForUrlOptions, "tracker" | "repository">,
|
||||||
|
) => ReturnType<typeof getEventsByIdForUrl>
|
||||||
|
deriveEventsByIdForUrl: (
|
||||||
|
options: Omit<EventsByIdForUrlOptions, "tracker" | "repository">,
|
||||||
|
) => ReturnType<typeof deriveEventsByIdForUrl>
|
||||||
|
deriveItemsByKey: <T>(options: Omit<ItemsByKeyOptions<T>, "repository">) => Readable<ItemsByKey<T>>
|
||||||
|
deriveIsDeleted: (event: TrustedEvent) => ReturnType<typeof deriveIsDeleted>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 — plus net/store helpers bound to them.
|
||||||
|
*
|
||||||
|
* Data modules are NOT fields on the client; they are composed separately (see
|
||||||
|
* `createApp`) and reach the client through the `ClientContext` seam.
|
||||||
|
*/
|
||||||
|
export class Client implements ClientContext {
|
||||||
|
user?: User
|
||||||
|
pool: Pool
|
||||||
|
tracker: Tracker
|
||||||
|
repository: Repository
|
||||||
|
wrapManager: WrapManager
|
||||||
|
netContext: AdapterContext
|
||||||
|
load: Loader
|
||||||
|
ingestCleanup: Unsubscriber
|
||||||
|
|
||||||
|
constructor(options: ClientOptions = {}) {
|
||||||
this.user = options.user
|
this.user = options.user
|
||||||
this.pool = new Pool({
|
this.pool = new Pool({
|
||||||
makeSocket: (url: string) => {
|
makeSocket: (url: string) => {
|
||||||
const socketPolicies = options.socketPolicies ?? defaultSocketPolicies
|
let socketPolicies = options.socketPolicies ?? defaultSocketPolicies
|
||||||
|
|
||||||
if (this.user) {
|
if (this.user) {
|
||||||
socketPolicies = [...socketPolicies, this.user.makeSocketPolicyAuth()]
|
socketPolicies = [...socketPolicies, this.user.makeSocketPolicyAuth()]
|
||||||
}
|
}
|
||||||
|
|
||||||
return makeSocket(url, socketPolicies)
|
return new Socket(url, socketPolicies)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
this.tracker = new Tracker()
|
this.tracker = new Tracker()
|
||||||
@@ -47,58 +153,84 @@ export class Client {
|
|||||||
timeout: 3000,
|
timeout: 3000,
|
||||||
threshold: 0.5,
|
threshold: 0.5,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Ingest every event received on any socket into this 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.
|
||||||
|
this.ingestCleanup = this.pool.subscribe(socket => {
|
||||||
|
const onReceive = (message: RelayMessage) => this.ingest(message, socket.url)
|
||||||
|
|
||||||
|
socket.on(SocketEvent.Receive, onReceive)
|
||||||
|
|
||||||
|
return () => socket.off(SocketEvent.Receive, onReceive)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ingest = (message: RelayMessage, url: string) => {
|
||||||
|
if (!isRelayEvent(message)) return
|
||||||
|
|
||||||
|
const event = message[2]
|
||||||
|
|
||||||
|
if (isDVMKind(event.kind) || isEphemeralKind(event.kind)) return
|
||||||
|
if (!netContext.isEventValid(event, url)) return
|
||||||
|
|
||||||
|
this.tracker.track(event.id, url)
|
||||||
|
this.repository.publish(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
|
this.ingestCleanup()
|
||||||
this.pool.clear()
|
this.pool.clear()
|
||||||
this.tracker.clear()
|
this.tracker.clear()
|
||||||
this.repository.clear()
|
this.repository.clear()
|
||||||
this.wrapManager.clear()
|
this.wrapManager.clear()
|
||||||
this.relayStats.cleanup()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bind net utilities to client net context
|
// Net utilities, bound to this client's context
|
||||||
|
|
||||||
request = (options: RequestOptions) => request({...context: this.netContext, ...options})
|
request = (options: Omit<RequestOptions, "context">) =>
|
||||||
|
request({...options, context: this.netContext})
|
||||||
|
|
||||||
makeLoader = (options: LoaderOptions) => makeLoader({...context: this.netContext, ...options})
|
publish = (options: Omit<PublishOptions, "context">) =>
|
||||||
|
publish({...options, context: this.netContext})
|
||||||
|
|
||||||
publish = (options: PublishOptions) => publish({...context: this.netContext, ...options})
|
diff = (options: Omit<DiffOptions, "context">) => diff({...options, context: this.netContext})
|
||||||
|
|
||||||
diff = (options: DiffOptions) => diff({...context: this.netContext, ...options})
|
pull = (options: Omit<PullOptions, "context">) => pull({...options, context: this.netContext})
|
||||||
|
|
||||||
pull = (options: PullOptions) => pull({...context: this.netContext, ...options})
|
push = (options: Omit<PushOptions, "context">) => push({...options, context: this.netContext})
|
||||||
|
|
||||||
push = (options: PushOptions) => push({...context: this.netContext, ...options})
|
makeLoader = (options: Omit<LoaderOptions, "context">): Loader =>
|
||||||
|
makeLoader({...options, context: this.netContext})
|
||||||
|
|
||||||
// Bind store utilities to client stuff
|
// Store utilities, bound to this client's repository/tracker
|
||||||
|
|
||||||
getEventsById = (options: Omit<EventsByIdOptions 'repository'>) =>
|
getEventsById = (options: Omit<EventsByIdOptions, "repository">) =>
|
||||||
getEventsById({repository: this.repository, ...options})
|
getEventsById({...options, repository: this.repository})
|
||||||
|
|
||||||
deriveEventsById = (options: Omit<EventsByIdOptions 'repository'>) =>
|
deriveEventsById = (options: Omit<EventsByIdOptions, "repository">) =>
|
||||||
deriveEventsById({repository: this.repository, ...options})
|
deriveEventsById({...options, repository: this.repository})
|
||||||
|
|
||||||
deriveEvents = (options: Omit<EventsByIdOptions 'repository'>) =>
|
deriveEvents = (options: Omit<EventsByIdOptions, "repository">) =>
|
||||||
deriveEvents({repository: this.repository, ...options})
|
deriveEvents({...options, repository: this.repository})
|
||||||
|
|
||||||
makeDeriveEvent = (options: Omit<EventOptions, 'repository'>) =>
|
makeDeriveEvent = (options: Omit<EventOptions, "repository">) =>
|
||||||
makeDeriveEvent({repository: this.repository, ...options})
|
makeDeriveEvent({...options, repository: this.repository})
|
||||||
|
|
||||||
getEventsByIdByUrl = (options: Omit<EventsByIdByUrlOptions, 'tracker' | 'repository'>) =>
|
getEventsByIdByUrl = (options: Omit<EventsByIdByUrlOptions, "tracker" | "repository">) =>
|
||||||
getEventsByIdByUrl({tracker: this.tracker, repository: this.repository, ...options})
|
getEventsByIdByUrl({...options, tracker: this.tracker, repository: this.repository})
|
||||||
|
|
||||||
deriveEventsByIdByUrl = (options: Omit<EventsByIdByUrlOptions, 'tracker' | 'repository'>) =>
|
deriveEventsByIdByUrl = (options: Omit<EventsByIdByUrlOptions, "tracker" | "repository">) =>
|
||||||
deriveEventsByIdByUrl({tracker: this.tracker, repository: this.repository, ...options})
|
deriveEventsByIdByUrl({...options, tracker: this.tracker, repository: this.repository})
|
||||||
|
|
||||||
getEventsByIdForUrl = (options: Omit<EventsByIdForUrlOptions, 'tracker' | 'repository'>) =>
|
getEventsByIdForUrl = (options: Omit<EventsByIdForUrlOptions, "tracker" | "repository">) =>
|
||||||
getEventsByIdForUrl({tracker: this.tracker, repository: this.repository, ...options})
|
getEventsByIdForUrl({...options, tracker: this.tracker, repository: this.repository})
|
||||||
|
|
||||||
deriveEventsByIdForUrl = (options: Omit<EventsByIdForUrlOptions, 'tracker' | 'repository'>)
|
deriveEventsByIdForUrl = (options: Omit<EventsByIdForUrlOptions, "tracker" | "repository">) =>
|
||||||
deriveEventsByIdForUrl({tracker: this.tracker, repository: this.repository, ...options})
|
deriveEventsByIdForUrl({...options, tracker: this.tracker, repository: this.repository})
|
||||||
|
|
||||||
deriveItemsByKey = <T>(options: Omit<ItemsByKeyOptions<T>, 'repository'>) =>
|
deriveItemsByKey = <T>(options: Omit<ItemsByKeyOptions<T>, "repository">) =>
|
||||||
deriveEventsByIdForUrl({repository: this.repository, ...options})
|
deriveItemsByKey<T>({...options, repository: this.repository})
|
||||||
|
|
||||||
deriveIsDeleted = (event: TrustedEvent) => deriveIsDeleted(this.repository, event)
|
deriveIsDeleted = (event: TrustedEvent) => deriveIsDeleted(this.repository, event)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,93 +1,107 @@
|
|||||||
import {Subscriber} from 'svelte/store'
|
import {writable} from "svelte/store"
|
||||||
import {call} from '@welshman/lib'
|
import type {Readable, Unsubscriber} from "svelte/store"
|
||||||
|
import type {Maybe} from "@welshman/lib"
|
||||||
|
import {getter, makeDeriveItem, makeLoadItem, makeForceLoadItem} from "@welshman/store"
|
||||||
|
import type {MakeLoadItemOptions} from "@welshman/store"
|
||||||
|
import type {ClientContext} from "./client.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for a reactive, keyed collection of "local" (non-event) data —
|
||||||
|
* things like relay stats or NIP-11 profiles that aren't backed by the
|
||||||
|
* repository. The collection owns its own map and is its own Svelte store: its
|
||||||
|
* `subscribe` emits the underlying `Map`.
|
||||||
|
*
|
||||||
|
* Subclasses reach the client through the `ClientContext` seam, never the
|
||||||
|
* concrete `Client`, so they never create a dependency cycle.
|
||||||
|
*/
|
||||||
export class ClientData<T> {
|
export class ClientData<T> {
|
||||||
selfSubscribers: Subscriber<ClientData<T>>[] = []
|
protected index = writable(new Map<string, T>())
|
||||||
clearSubscribers: Subscriber<ClientData<T>>[] = []
|
protected getIndex = getter(this.index)
|
||||||
itemSubscribers: Subscriber<T>[] = []
|
protected itemSubscribers: ((key: string, value: Maybe<T>) => void)[] = []
|
||||||
data = new Map<string, T>()
|
public derive: (key?: string, ...args: any[]) => Readable<Maybe<T>>
|
||||||
|
|
||||||
// Svelte store-like methods
|
constructor(protected readonly ctx: ClientContext) {
|
||||||
|
this.derive = makeDeriveItem(this.index)
|
||||||
private notify = () => {
|
|
||||||
for (const subscriber of this.selfSubscribers) {
|
|
||||||
subscriber(this)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
subscribe = (subscriber: Subscriber<ClientData<T>>) => {
|
subscribe = this.index.subscribe
|
||||||
subscriber(this)
|
|
||||||
this.selfSubscribers.push(subscriber)
|
|
||||||
|
|
||||||
return () => {
|
get = (key: string): Maybe<T> => this.getIndex().get(key)
|
||||||
this.selfSubscribers.splice(this.selfSubscribers.indexOf(subscriber), 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
derived = (key: string) => {
|
getAll = (): T[] => Array.from(this.getIndex().values())
|
||||||
return readable(this.get(key), set => {
|
|
||||||
const subscribers = [
|
|
||||||
this.onClear(() => set(undefined)),
|
|
||||||
this.onItem(set)
|
|
||||||
]
|
|
||||||
|
|
||||||
return () => subscribers.forEach(call)
|
keys = () => this.getIndex().keys()
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// EventEmitter-like methods
|
values = () => this.getIndex().values()
|
||||||
|
|
||||||
private emitClear = () => {
|
|
||||||
for (const subscriber of this.clearSubscribers) {
|
|
||||||
subscriber(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.notify()
|
|
||||||
}
|
|
||||||
|
|
||||||
onClear = (subscriber: Subscriber<T>) => {
|
|
||||||
this.clearSubscribers.push(subscriber)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
this.clearSubscribers.splice(this.clearSubscribers.indexOf(subscriber), 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private emitItem = (item: T) => {
|
|
||||||
for (const subscriber of this.itemSubscribers) {
|
|
||||||
subscriber(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.notify()
|
|
||||||
}
|
|
||||||
|
|
||||||
onItem = (subscriber: Subscriber<T>) => {
|
|
||||||
this.itemSubscribers.push(subscriber)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
this.itemSubscribers.splice(this.itemSubscribers.indexOf(subscriber), 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map-like methods
|
|
||||||
|
|
||||||
clear = () => {
|
|
||||||
this.data.clear()
|
|
||||||
this.emitClear()
|
|
||||||
}
|
|
||||||
|
|
||||||
delete = (key: string) => {
|
|
||||||
this.data.delete(key)
|
|
||||||
this.emitItem(key, undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
set = (key: string, value: T) => {
|
set = (key: string, value: T) => {
|
||||||
this.data.set(key, value)
|
this.index.update($items => {
|
||||||
|
$items.set(key, value)
|
||||||
|
|
||||||
|
return $items
|
||||||
|
})
|
||||||
|
|
||||||
this.emitItem(key, value)
|
this.emitItem(key, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
get = (key: string) => this.data.get(key)
|
delete = (key: string) => {
|
||||||
values = () => this.data.values()
|
this.index.update($items => {
|
||||||
items = () => this.data.items()
|
$items.delete(key)
|
||||||
keys = () => this.data.keys()
|
|
||||||
|
return $items
|
||||||
|
})
|
||||||
|
|
||||||
|
this.emitItem(key, undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
clear = () => {
|
||||||
|
const keys = Array.from(this.getIndex().keys())
|
||||||
|
|
||||||
|
this.index.set(new Map())
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
this.emitItem(key, undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onItem = (subscriber: (key: string, value: Maybe<T>) => void): Unsubscriber => {
|
||||||
|
this.itemSubscribers.push(subscriber)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const i = this.itemSubscribers.indexOf(subscriber)
|
||||||
|
|
||||||
|
if (i !== -1) this.itemSubscribers.splice(i, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected emitItem = (key: string, value: Maybe<T>) => {
|
||||||
|
for (const subscriber of this.itemSubscribers) {
|
||||||
|
subscriber(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `ClientData` collection that knows how to lazily load items by key from the
|
||||||
|
* network. Subclasses implement `fetch`; `load`/`forceLoad`/`derive` 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: ClientContext, 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)
|
||||||
|
|
||||||
|
this.load = makeLoadItem(fetch, this.get, options)
|
||||||
|
this.forceLoad = makeForceLoadItem(fetch, this.get)
|
||||||
|
this.derive = makeDeriveItem(this.index, this.load)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
import {uniq, reject, nth, now, nthNe, removeUndefined, nthEq} from "@welshman/lib"
|
||||||
|
import {
|
||||||
|
sendManagementRequest,
|
||||||
|
addToListPublicly,
|
||||||
|
addToListPrivately,
|
||||||
|
updateList,
|
||||||
|
removeFromList,
|
||||||
|
makeHttpAuth,
|
||||||
|
getListTags,
|
||||||
|
getRelayTags,
|
||||||
|
getRelayTagValues,
|
||||||
|
getRelaysFromList,
|
||||||
|
makeList,
|
||||||
|
makeRoomCreateEvent,
|
||||||
|
makeRoomDeleteEvent,
|
||||||
|
makeRoomEditEvent,
|
||||||
|
makeRoomJoinEvent,
|
||||||
|
makeRoomLeaveEvent,
|
||||||
|
makeRoomAddMemberEvent,
|
||||||
|
makeRoomRemoveMemberEvent,
|
||||||
|
isPublishedProfile,
|
||||||
|
createProfile,
|
||||||
|
editProfile,
|
||||||
|
RelayMode,
|
||||||
|
makeEvent,
|
||||||
|
MESSAGING_RELAYS,
|
||||||
|
BLOCKED_RELAYS,
|
||||||
|
SEARCH_RELAYS,
|
||||||
|
FOLLOWS,
|
||||||
|
RELAYS,
|
||||||
|
MUTES,
|
||||||
|
PINS,
|
||||||
|
prep,
|
||||||
|
} from "@welshman/util"
|
||||||
|
import type {ManagementRequest, EventTemplate, RoomMeta, Profile} from "@welshman/util"
|
||||||
|
import {addMaximalFallbacks} from "./router.js"
|
||||||
|
import type {Router} from "./router.js"
|
||||||
|
import {MergedThunk, publishThunk} from "./thunk.js"
|
||||||
|
import type {ThunkOptions} from "./thunk.js"
|
||||||
|
import type {ClientContext} from "./client.js"
|
||||||
|
import type {User} from "./user.js"
|
||||||
|
import type {RelayLists} from "./relayLists.js"
|
||||||
|
import type {MessagingRelayLists} from "./messagingRelayLists.js"
|
||||||
|
import type {BlockedRelayLists} from "./blockedRelayLists.js"
|
||||||
|
import type {SearchRelayLists} from "./searchRelayLists.js"
|
||||||
|
import type {FollowLists} from "./follows.js"
|
||||||
|
import type {MuteLists} from "./mutes.js"
|
||||||
|
import type {PinLists} from "./pins.js"
|
||||||
|
|
||||||
|
export type CommandsDeps = {
|
||||||
|
client: ClientContext
|
||||||
|
user: User
|
||||||
|
router: Router
|
||||||
|
relayLists: RelayLists
|
||||||
|
messagingRelayLists: MessagingRelayLists
|
||||||
|
blockedRelayLists: BlockedRelayLists
|
||||||
|
searchRelayLists: SearchRelayLists
|
||||||
|
followLists: FollowLists
|
||||||
|
muteLists: MuteLists
|
||||||
|
pinLists: PinLists
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SendWrappedOptions = Omit<
|
||||||
|
ThunkOptions,
|
||||||
|
"event" | "relays" | "recipient" | "client" | "user"
|
||||||
|
> & {
|
||||||
|
event: EventTemplate
|
||||||
|
recipients: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The high-level "do an action" API: each method builds an event for the
|
||||||
|
* client's user and publishes it via a thunk. Replaces the old module of global
|
||||||
|
* functions; everything that used a global (current pubkey, signer, router, the
|
||||||
|
* user's lists) is now injected.
|
||||||
|
*/
|
||||||
|
export class Commands {
|
||||||
|
readonly client: ClientContext
|
||||||
|
readonly user: User
|
||||||
|
readonly router: Router
|
||||||
|
readonly relayLists: RelayLists
|
||||||
|
readonly messagingRelayLists: MessagingRelayLists
|
||||||
|
readonly blockedRelayLists: BlockedRelayLists
|
||||||
|
readonly searchRelayLists: SearchRelayLists
|
||||||
|
readonly followLists: FollowLists
|
||||||
|
readonly muteLists: MuteLists
|
||||||
|
readonly pinLists: PinLists
|
||||||
|
|
||||||
|
constructor(deps: CommandsDeps) {
|
||||||
|
this.client = deps.client
|
||||||
|
this.user = deps.user
|
||||||
|
this.router = deps.router
|
||||||
|
this.relayLists = deps.relayLists
|
||||||
|
this.messagingRelayLists = deps.messagingRelayLists
|
||||||
|
this.blockedRelayLists = deps.blockedRelayLists
|
||||||
|
this.searchRelayLists = deps.searchRelayLists
|
||||||
|
this.followLists = deps.followLists
|
||||||
|
this.muteLists = deps.muteLists
|
||||||
|
this.pinLists = deps.pinLists
|
||||||
|
}
|
||||||
|
|
||||||
|
private publish = (options: Omit<ThunkOptions, "client" | "user">) =>
|
||||||
|
publishThunk({...options, client: this.client, user: this.user})
|
||||||
|
|
||||||
|
private fromUser = () => this.router.FromUser().policy(addMaximalFallbacks).getUrls()
|
||||||
|
|
||||||
|
private encryptToSelf = (payload: string) => this.user.nip44EncryptToSelf(payload)
|
||||||
|
|
||||||
|
// NIP 65
|
||||||
|
|
||||||
|
removeRelay = async (url: string, mode: RelayMode) => {
|
||||||
|
const list = (await this.relayLists.forceLoad(this.user.pubkey, [])) || makeList({kind: RELAYS})
|
||||||
|
const dup = getRelayTags(getListTags(list)).find(nthEq(1, url))
|
||||||
|
const alt = mode === RelayMode.Read ? RelayMode.Write : RelayMode.Read
|
||||||
|
const tags = list.publicTags.filter(nthNe(1, url))
|
||||||
|
|
||||||
|
// If we had a duplicate that was used as the alt mode, keep the alt
|
||||||
|
if (dup && (!dup[2] || dup[2] === alt)) {
|
||||||
|
tags.push(["r", url, alt])
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = {kind: list.kind, content: list.event?.content || "", tags}
|
||||||
|
const relays = this.fromUser()
|
||||||
|
|
||||||
|
// Make sure to notify the old relay too
|
||||||
|
relays.push(url)
|
||||||
|
|
||||||
|
return this.publish({event, relays})
|
||||||
|
}
|
||||||
|
|
||||||
|
addRelay = async (url: string, mode: RelayMode) => {
|
||||||
|
const list = (await this.relayLists.forceLoad(this.user.pubkey, [])) || makeList({kind: RELAYS})
|
||||||
|
const dup = getRelayTags(getListTags(list)).find(nthEq(1, url))
|
||||||
|
const tag = removeUndefined(["r", url, dup && dup[2] !== mode ? undefined : mode])
|
||||||
|
const tags = [...list.publicTags.filter(nthNe(1, url)), tag]
|
||||||
|
const event = {kind: list.kind, content: list.event?.content || "", tags}
|
||||||
|
|
||||||
|
return this.publish({event, relays: this.fromUser()})
|
||||||
|
}
|
||||||
|
|
||||||
|
setRelays = async (tags: string[][]) => {
|
||||||
|
const event = makeEvent(RELAYS, {tags})
|
||||||
|
const relays = this.router
|
||||||
|
.merge([this.router.Index(), this.router.FromRelays(getRelayTagValues(tags))])
|
||||||
|
.getUrls()
|
||||||
|
|
||||||
|
return this.publish({event, relays})
|
||||||
|
}
|
||||||
|
|
||||||
|
setReadRelays = async (urls: string[]) => {
|
||||||
|
const list = (await this.relayLists.forceLoad(this.user.pubkey, [])) || makeList({kind: RELAYS})
|
||||||
|
const writeRelays = reject(nthEq(2, RelayMode.Read), getRelayTags(getListTags(list))).map(nth(1))
|
||||||
|
const writeTags = writeRelays.map(url => ["r", url, RelayMode.Write])
|
||||||
|
const readTags = urls.map(url => ["r", url, RelayMode.Read])
|
||||||
|
const tags = [...writeTags, ...readTags]
|
||||||
|
const event = {kind: list.kind, content: list.event?.content || "", tags}
|
||||||
|
|
||||||
|
return this.publish({event, relays: this.fromUser()})
|
||||||
|
}
|
||||||
|
|
||||||
|
setWriteRelays = async (urls: string[]) => {
|
||||||
|
const list = (await this.relayLists.forceLoad(this.user.pubkey, [])) || makeList({kind: RELAYS})
|
||||||
|
const readRelays = reject(nthEq(2, RelayMode.Write), getRelayTags(getListTags(list))).map(nth(1))
|
||||||
|
const readTags = readRelays.map(url => ["r", url, RelayMode.Read])
|
||||||
|
const writeTags = urls.map(url => ["r", url, RelayMode.Write])
|
||||||
|
const tags = [...readTags, ...writeTags]
|
||||||
|
const event = {kind: list.kind, content: list.event?.content || "", tags}
|
||||||
|
|
||||||
|
return this.publish({event, relays: this.fromUser()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NIP 17
|
||||||
|
|
||||||
|
removeMessagingRelay = async (url: string) => {
|
||||||
|
const list =
|
||||||
|
(await this.messagingRelayLists.forceLoad(this.user.pubkey, [])) ||
|
||||||
|
makeList({kind: MESSAGING_RELAYS})
|
||||||
|
const event = await removeFromList(list, url).reconcile(this.encryptToSelf)
|
||||||
|
|
||||||
|
return this.publish({event, relays: this.fromUser()})
|
||||||
|
}
|
||||||
|
|
||||||
|
addMessagingRelay = async (url: string) => {
|
||||||
|
const list =
|
||||||
|
(await this.messagingRelayLists.forceLoad(this.user.pubkey, [])) ||
|
||||||
|
makeList({kind: MESSAGING_RELAYS})
|
||||||
|
const event = await addToListPublicly(list, ["relay", url]).reconcile(this.encryptToSelf)
|
||||||
|
|
||||||
|
return this.publish({event, relays: this.fromUser()})
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessagingRelays = async (urls: string[]) => {
|
||||||
|
const event = makeEvent(MESSAGING_RELAYS, {tags: urls.map(url => ["relay", url])})
|
||||||
|
|
||||||
|
return this.publish({event, relays: this.router.FromUser().getUrls()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blocked Relays
|
||||||
|
|
||||||
|
removeBlockedRelay = async (url: string) => {
|
||||||
|
const list =
|
||||||
|
(await this.blockedRelayLists.forceLoad(this.user.pubkey, [])) ||
|
||||||
|
makeList({kind: BLOCKED_RELAYS})
|
||||||
|
const event = await removeFromList(list, url).reconcile(this.encryptToSelf)
|
||||||
|
|
||||||
|
return this.publish({event, relays: this.fromUser()})
|
||||||
|
}
|
||||||
|
|
||||||
|
addBlockedRelay = async (url: string) => {
|
||||||
|
const list =
|
||||||
|
(await this.blockedRelayLists.forceLoad(this.user.pubkey, [])) ||
|
||||||
|
makeList({kind: BLOCKED_RELAYS})
|
||||||
|
const event = await addToListPublicly(list, ["relay", url]).reconcile(this.encryptToSelf)
|
||||||
|
|
||||||
|
return this.publish({event, relays: this.fromUser()})
|
||||||
|
}
|
||||||
|
|
||||||
|
setBlockedRelays = async (urls: string[]) => {
|
||||||
|
const event = makeEvent(BLOCKED_RELAYS, {tags: urls.map(url => ["relay", url])})
|
||||||
|
|
||||||
|
return this.publish({event, relays: this.router.FromUser().getUrls()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search Relays
|
||||||
|
|
||||||
|
removeSearchRelay = async (url: string) => {
|
||||||
|
const list =
|
||||||
|
(await this.searchRelayLists.forceLoad(this.user.pubkey, [])) ||
|
||||||
|
makeList({kind: SEARCH_RELAYS})
|
||||||
|
const event = await removeFromList(list, url).reconcile(this.encryptToSelf)
|
||||||
|
|
||||||
|
return this.publish({event, relays: this.fromUser()})
|
||||||
|
}
|
||||||
|
|
||||||
|
addSearchRelay = async (url: string) => {
|
||||||
|
const list =
|
||||||
|
(await this.searchRelayLists.forceLoad(this.user.pubkey, [])) ||
|
||||||
|
makeList({kind: SEARCH_RELAYS})
|
||||||
|
const event = await addToListPublicly(list, ["relay", url]).reconcile(this.encryptToSelf)
|
||||||
|
|
||||||
|
return this.publish({event, relays: this.fromUser()})
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearchRelays = async (urls: string[]) => {
|
||||||
|
const event = makeEvent(SEARCH_RELAYS, {tags: urls.map(url => ["relay", url])})
|
||||||
|
|
||||||
|
return this.publish({event, relays: this.router.FromUser().getUrls()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NIP 01
|
||||||
|
|
||||||
|
setProfile = (profile: Profile) => {
|
||||||
|
const relays = this.router.merge([this.router.Index(), this.router.FromUser()]).getUrls()
|
||||||
|
const event = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile)
|
||||||
|
|
||||||
|
return this.publish({event, relays})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NIP 02
|
||||||
|
|
||||||
|
unfollow = async (value: string) => {
|
||||||
|
const list = (await this.followLists.forceLoad(this.user.pubkey, [])) || makeList({kind: FOLLOWS})
|
||||||
|
const event = await removeFromList(list, value).reconcile(this.encryptToSelf)
|
||||||
|
|
||||||
|
return this.publish({event, relays: this.fromUser()})
|
||||||
|
}
|
||||||
|
|
||||||
|
follow = async (tag: string[]) => {
|
||||||
|
const list = (await this.followLists.forceLoad(this.user.pubkey, [])) || makeList({kind: FOLLOWS})
|
||||||
|
const event = await addToListPublicly(list, tag).reconcile(this.encryptToSelf)
|
||||||
|
|
||||||
|
return this.publish({event, relays: this.fromUser()})
|
||||||
|
}
|
||||||
|
|
||||||
|
unmute = async (value: string) => {
|
||||||
|
const list = (await this.muteLists.forceLoad(this.user.pubkey, [])) || makeList({kind: MUTES})
|
||||||
|
const event = await removeFromList(list, value).reconcile(this.encryptToSelf)
|
||||||
|
|
||||||
|
return this.publish({event, relays: this.fromUser()})
|
||||||
|
}
|
||||||
|
|
||||||
|
mutePublicly = async (tag: string[]) => {
|
||||||
|
const list = (await this.muteLists.forceLoad(this.user.pubkey, [])) || makeList({kind: MUTES})
|
||||||
|
const event = await addToListPublicly(list, tag).reconcile(this.encryptToSelf)
|
||||||
|
|
||||||
|
return this.publish({event, relays: this.fromUser()})
|
||||||
|
}
|
||||||
|
|
||||||
|
mutePrivately = async (tag: string[]) => {
|
||||||
|
const list = (await this.muteLists.forceLoad(this.user.pubkey, [])) || makeList({kind: MUTES})
|
||||||
|
const event = await addToListPrivately(list, tag).reconcile(this.encryptToSelf)
|
||||||
|
|
||||||
|
return this.publish({event, relays: this.fromUser()})
|
||||||
|
}
|
||||||
|
|
||||||
|
setMutes = async ({
|
||||||
|
publicTags,
|
||||||
|
privateTags,
|
||||||
|
}: {
|
||||||
|
publicTags?: string[][]
|
||||||
|
privateTags?: string[][]
|
||||||
|
}) => {
|
||||||
|
const list = (await this.muteLists.forceLoad(this.user.pubkey, [])) || makeList({kind: MUTES})
|
||||||
|
const event = await updateList(list, {publicTags, privateTags}).reconcile(this.encryptToSelf)
|
||||||
|
|
||||||
|
return this.publish({event, relays: this.fromUser()})
|
||||||
|
}
|
||||||
|
|
||||||
|
unpin = async (value: string) => {
|
||||||
|
const list = (await this.pinLists.forceLoad(this.user.pubkey, [])) || makeList({kind: PINS})
|
||||||
|
const event = await removeFromList(list, value).reconcile(this.encryptToSelf)
|
||||||
|
|
||||||
|
return this.publish({event, relays: this.fromUser()})
|
||||||
|
}
|
||||||
|
|
||||||
|
pin = async (tag: string[]) => {
|
||||||
|
const list = (await this.pinLists.forceLoad(this.user.pubkey, [])) || makeList({kind: PINS})
|
||||||
|
const event = await addToListPublicly(list, tag).reconcile(this.encryptToSelf)
|
||||||
|
|
||||||
|
return this.publish({event, relays: this.fromUser()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NIP 59
|
||||||
|
|
||||||
|
sendWrapped = async ({event, recipients, ...options}: SendWrappedOptions) => {
|
||||||
|
// Stabilize the event id across the different wraps
|
||||||
|
const stableEvent = prep(event, this.user.pubkey, now())
|
||||||
|
|
||||||
|
return new MergedThunk(
|
||||||
|
await Promise.all(
|
||||||
|
uniq(recipients).map(async recipient => {
|
||||||
|
const relays = getRelaysFromList(await this.messagingRelayLists.load(recipient))
|
||||||
|
|
||||||
|
return this.publish({event: stableEvent, relays, recipient, ...options})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NIP 86
|
||||||
|
|
||||||
|
manageRelay = async (url: string, request: ManagementRequest) => {
|
||||||
|
url = url.replace(/^ws/, "http")
|
||||||
|
|
||||||
|
const authTemplate = await makeHttpAuth(url, "POST", JSON.stringify(request))
|
||||||
|
const authEvent = await this.user.sign(authTemplate)
|
||||||
|
|
||||||
|
return sendManagementRequest(url, request, authEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NIP 29
|
||||||
|
|
||||||
|
createRoom = (url: string, room: RoomMeta) =>
|
||||||
|
this.publish({event: makeRoomCreateEvent(room), relays: [url]})
|
||||||
|
|
||||||
|
deleteRoom = (url: string, room: RoomMeta) =>
|
||||||
|
this.publish({event: makeRoomDeleteEvent(room), relays: [url]})
|
||||||
|
|
||||||
|
editRoom = (url: string, room: RoomMeta) =>
|
||||||
|
this.publish({event: makeRoomEditEvent(room), relays: [url]})
|
||||||
|
|
||||||
|
joinRoom = (url: string, room: RoomMeta) =>
|
||||||
|
this.publish({event: makeRoomJoinEvent(room), relays: [url]})
|
||||||
|
|
||||||
|
leaveRoom = (url: string, room: RoomMeta) =>
|
||||||
|
this.publish({event: makeRoomLeaveEvent(room), relays: [url]})
|
||||||
|
|
||||||
|
addRoomMember = (url: string, room: RoomMeta, pubkey: string) =>
|
||||||
|
this.publish({event: makeRoomAddMemberEvent(room, pubkey), relays: [url]})
|
||||||
|
|
||||||
|
removeRoomMember = (url: string, room: RoomMeta, pubkey: string) =>
|
||||||
|
this.publish({event: makeRoomRemoveMemberEvent(room, pubkey), relays: [url]})
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import {Client} from "./client.js"
|
||||||
|
import type {ClientOptions} from "./client.js"
|
||||||
|
import {Router} from "./router.js"
|
||||||
|
import type {RouterOptions} from "./router.js"
|
||||||
|
import {RelayStats} from "./relayStats.js"
|
||||||
|
import {RelayLists} from "./relayLists.js"
|
||||||
|
import {BlockedRelayLists} from "./blockedRelayLists.js"
|
||||||
|
import {Relays} from "./relays.js"
|
||||||
|
import {Plaintext} from "./plaintext.js"
|
||||||
|
import {Profiles} from "./profiles.js"
|
||||||
|
import {FollowLists} from "./follows.js"
|
||||||
|
import {MuteLists} from "./mutes.js"
|
||||||
|
import {PinLists} from "./pins.js"
|
||||||
|
import {BlossomServerLists} from "./blossom.js"
|
||||||
|
import {MessagingRelayLists} from "./messagingRelayLists.js"
|
||||||
|
import {SearchRelayLists} from "./searchRelayLists.js"
|
||||||
|
import {Handles} from "./handles.js"
|
||||||
|
import {Zappers} from "./zappers.js"
|
||||||
|
import {Topics} from "./topics.js"
|
||||||
|
import {Tags} from "./tags.js"
|
||||||
|
import {Wot} from "./wot.js"
|
||||||
|
import {Feeds} from "./feeds.js"
|
||||||
|
import {Searches} from "./search.js"
|
||||||
|
import {Sync} from "./sync.js"
|
||||||
|
import {GiftWraps} from "./giftWraps.js"
|
||||||
|
import {Commands} from "./commands.js"
|
||||||
|
|
||||||
|
export type AppOptions = ClientOptions & {
|
||||||
|
dufflepudUrl?: string
|
||||||
|
// Whether to unwrap incoming NIP-59 gift wraps (DMs) for this client's user.
|
||||||
|
shouldUnwrap?: boolean
|
||||||
|
// The router's data dependencies are wired up below, so callers only supply
|
||||||
|
// the configuration knobs.
|
||||||
|
router?: Omit<RouterOptions, "getRelaysForPubkey" | "getRelayQuality">
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composes a default application instance: a `Client` plus the core data
|
||||||
|
* modules, wired together. This is where genuine domain cycles (Router <->
|
||||||
|
* RelayLists, RelayStats <-> BlockedRelayLists) are broken — modules are given
|
||||||
|
* lazily-resolved closures that reach their siblings at call time, never at
|
||||||
|
* construction time.
|
||||||
|
*
|
||||||
|
* Callers who want a different module set can ignore this helper and compose
|
||||||
|
* their own bag directly, or spread additional modules onto the result.
|
||||||
|
*/
|
||||||
|
export const createApp = (options: AppOptions = {}) => {
|
||||||
|
const client = new Client(options)
|
||||||
|
const relays = new Relays(client)
|
||||||
|
const plaintext = new Plaintext(client)
|
||||||
|
|
||||||
|
// Declared up-front so the lazily-invoked closures below can reach them. None
|
||||||
|
// are called during construction, only at routing/scoring time.
|
||||||
|
let relayLists: RelayLists
|
||||||
|
let blockedRelayLists: BlockedRelayLists
|
||||||
|
|
||||||
|
const relayStats = new RelayStats(client, {
|
||||||
|
isRelayBlocked: url => {
|
||||||
|
const pubkey = client.user?.pubkey
|
||||||
|
|
||||||
|
return pubkey ? blockedRelayLists.getBlockedRelays(pubkey).includes(url) : false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = new Router(client, {
|
||||||
|
...options.router,
|
||||||
|
getRelaysForPubkey: (pubkey, mode) => relayLists.getRelaysForPubkey(pubkey, mode),
|
||||||
|
getRelayQuality: url => relayStats.getQuality(url),
|
||||||
|
})
|
||||||
|
|
||||||
|
relayLists = new RelayLists(client, router)
|
||||||
|
blockedRelayLists = new BlockedRelayLists(client, relayLists)
|
||||||
|
|
||||||
|
const profiles = new Profiles(client, relayLists)
|
||||||
|
const followLists = new FollowLists(client, relayLists)
|
||||||
|
const muteLists = new MuteLists(client, relayLists, plaintext)
|
||||||
|
const pinLists = new PinLists(client, relayLists)
|
||||||
|
const blossomServerLists = new BlossomServerLists(client, relayLists)
|
||||||
|
const messagingRelayLists = new MessagingRelayLists(client, relayLists)
|
||||||
|
const searchRelayLists = new SearchRelayLists(client, relayLists)
|
||||||
|
const handles = new Handles(client, profiles, {dufflepudUrl: options.dufflepudUrl})
|
||||||
|
const zappers = new Zappers(client, profiles, {dufflepudUrl: options.dufflepudUrl})
|
||||||
|
const topics = new Topics(client)
|
||||||
|
const tags = new Tags(client, router, profiles)
|
||||||
|
const wot = new Wot(client, followLists, muteLists)
|
||||||
|
const feeds = new Feeds(client, wot)
|
||||||
|
const searches = new Searches(client, router, profiles, topics, relays, handles, wot)
|
||||||
|
const sync = new Sync(client, relays)
|
||||||
|
const giftWraps = new GiftWraps(client, {shouldUnwrap: options.shouldUnwrap})
|
||||||
|
|
||||||
|
// Commands act on behalf of a signed-in user, so they're only available when
|
||||||
|
// the client has one.
|
||||||
|
const commands = client.user
|
||||||
|
? new Commands({
|
||||||
|
client,
|
||||||
|
user: client.user,
|
||||||
|
router,
|
||||||
|
relayLists,
|
||||||
|
messagingRelayLists,
|
||||||
|
blockedRelayLists,
|
||||||
|
searchRelayLists,
|
||||||
|
followLists,
|
||||||
|
muteLists,
|
||||||
|
pinLists,
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
router,
|
||||||
|
relays,
|
||||||
|
plaintext,
|
||||||
|
relayStats,
|
||||||
|
relayLists,
|
||||||
|
blockedRelayLists,
|
||||||
|
profiles,
|
||||||
|
followLists,
|
||||||
|
muteLists,
|
||||||
|
pinLists,
|
||||||
|
blossomServerLists,
|
||||||
|
messagingRelayLists,
|
||||||
|
searchRelayLists,
|
||||||
|
handles,
|
||||||
|
zappers,
|
||||||
|
topics,
|
||||||
|
tags,
|
||||||
|
wot,
|
||||||
|
feeds,
|
||||||
|
searches,
|
||||||
|
sync,
|
||||||
|
giftWraps,
|
||||||
|
commands,
|
||||||
|
cleanup: () => {
|
||||||
|
relayStats.cleanup()
|
||||||
|
giftWraps.cleanup()
|
||||||
|
client.cleanup()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type App = ReturnType<typeof createApp>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import {Scope, FeedController} from "@welshman/feeds"
|
||||||
|
import type {FeedControllerOptions, Feed} from "@welshman/feeds"
|
||||||
|
import type {AdapterContext} from "@welshman/net"
|
||||||
|
import type {ClientContext} from "./client.js"
|
||||||
|
import type {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 the injected `Wot`, and feeds fetch through THIS client's net
|
||||||
|
* context (pool + repository) rather than the global one.
|
||||||
|
*/
|
||||||
|
export class Feeds {
|
||||||
|
constructor(
|
||||||
|
readonly ctx: ClientContext,
|
||||||
|
readonly wot: Wot,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
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.wot.getFollows($pubkey)
|
||||||
|
case Scope.Network:
|
||||||
|
return this.wot.getNetwork($pubkey)
|
||||||
|
case Scope.Followers:
|
||||||
|
return this.wot.getFollowers($pubkey)
|
||||||
|
default:
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getPubkeysForWOTRange = (min: number, max: number): string[] => {
|
||||||
|
const pubkeys = []
|
||||||
|
const $maxWot = this.wot.getMaxWot() ?? 0
|
||||||
|
const thresholdMin = $maxWot * min
|
||||||
|
const thresholdMax = $maxWot * max
|
||||||
|
|
||||||
|
for (const [tpk, score] of this.wot.getWotGraph().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({
|
||||||
|
getPubkeysForScope: this.getPubkeysForScope,
|
||||||
|
getPubkeysForWOTRange: this.getPubkeysForWOTRange,
|
||||||
|
signer: this.ctx.user?.signer,
|
||||||
|
context: this.netContext,
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import {FOLLOWS, asDecryptedEvent, readList} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {RepositoryCollection} from "./repositoryCollection.js"
|
||||||
|
import type {ClientContext} from "./client.js"
|
||||||
|
import type {RelayLists} from "./relayLists.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kind-3 follow lists, keyed by pubkey. Loaded via the outbox model (the
|
||||||
|
* author's write relays), so it depends on the relay-list collection.
|
||||||
|
*/
|
||||||
|
export class FollowLists extends RepositoryCollection<ReturnType<typeof readList>> {
|
||||||
|
constructor(
|
||||||
|
ctx: ClientContext,
|
||||||
|
readonly relayLists: RelayLists,
|
||||||
|
) {
|
||||||
|
super(ctx, {
|
||||||
|
filters: [{kinds: [FOLLOWS]}],
|
||||||
|
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
||||||
|
getKey: followList => followList.event.pubkey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(pubkey: string, relayHints: string[] = []) {
|
||||||
|
return this.relayLists.makeOutboxLoader(FOLLOWS)(pubkey, relayHints)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import {get, writable} from "svelte/store"
|
||||||
|
import type {Unsubscriber} from "svelte/store"
|
||||||
|
import {on, TaskQueue} from "@welshman/lib"
|
||||||
|
import {WRAP, getPubkeyTagValues} from "@welshman/util"
|
||||||
|
import type {TrustedEvent, SignedEvent} from "@welshman/util"
|
||||||
|
import {Nip59} from "@welshman/signer"
|
||||||
|
import type {ClientContext} from "./client.js"
|
||||||
|
|
||||||
|
export type GiftWrapsOptions = {
|
||||||
|
shouldUnwrap?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-client gift-wrap (NIP-59) ingestion. Watches the client's repository for
|
||||||
|
* kind-1059 wraps and unwraps the ones addressed to THIS client's user, storing
|
||||||
|
* the resulting rumors via the wrap manager.
|
||||||
|
*
|
||||||
|
* In the old global model a single queue tried every logged-in account's signer
|
||||||
|
* against every wrap, depositing all rumors into one shared repository — which
|
||||||
|
* is exactly how DM history got merged across accounts. Here a client only ever
|
||||||
|
* unwraps its own user's messages into its own repository.
|
||||||
|
*/
|
||||||
|
export class GiftWraps {
|
||||||
|
shouldUnwrap = writable(false)
|
||||||
|
failedUnwraps = new Set<string>()
|
||||||
|
queue: TaskQueue<TrustedEvent>
|
||||||
|
cleanup: Unsubscriber
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly ctx: ClientContext,
|
||||||
|
options: GiftWrapsOptions = {},
|
||||||
|
) {
|
||||||
|
this.shouldUnwrap.set(options.shouldUnwrap ?? false)
|
||||||
|
|
||||||
|
this.queue = new TaskQueue<TrustedEvent>({
|
||||||
|
batchSize: 5,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Process wraps already in the repository, then any that arrive later
|
||||||
|
for (const wrap of this.ctx.repository.query([{kinds: [WRAP]}])) {
|
||||||
|
this.enqueue(wrap)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cleanup = on(this.ctx.repository, "update", ({added}: {added: TrustedEvent[]}) => {
|
||||||
|
for (const event of added) {
|
||||||
|
if (event.kind === WRAP) {
|
||||||
|
this.enqueue(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueue = (wrap: TrustedEvent) => {
|
||||||
|
if (!get(this.shouldUnwrap)) return
|
||||||
|
if (this.failedUnwraps.has(wrap.id)) return
|
||||||
|
if (this.ctx.wrapManager.getRumor(wrap.id)) return
|
||||||
|
|
||||||
|
this.queue.push(wrap)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import {tryCatch, fetchJson, batcher, postJson, last} from "@welshman/lib"
|
||||||
|
import type {Maybe} from "@welshman/lib"
|
||||||
|
import {deriveDeduplicated} from "@welshman/store"
|
||||||
|
import {LoadableData} from "./clientData.js"
|
||||||
|
import type {ClientContext} from "./client.js"
|
||||||
|
import type {Profiles} from "./profiles.js"
|
||||||
|
|
||||||
|
export type Handle = {
|
||||||
|
nip05: string
|
||||||
|
pubkey?: string
|
||||||
|
nip46?: string[]
|
||||||
|
relays?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryProfile(nip05: string): Promise<Maybe<Handle>> {
|
||||||
|
const parts = nip05.split("@")
|
||||||
|
const name = parts.length > 1 ? parts[0] : "_"
|
||||||
|
const domain = last(parts)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
names,
|
||||||
|
relays = {},
|
||||||
|
nip46 = {},
|
||||||
|
} = await fetchJson(`https://${domain}/.well-known/nostr.json?name=${name}`)
|
||||||
|
|
||||||
|
const pubkey = names[name]
|
||||||
|
|
||||||
|
if (!pubkey) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nip05,
|
||||||
|
pubkey,
|
||||||
|
nip46: nip46[pubkey],
|
||||||
|
relays: relays[pubkey],
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const displayNip05 = (nip05: string) =>
|
||||||
|
nip05?.startsWith("_@") ? last(nip05.split("@")) : nip05
|
||||||
|
|
||||||
|
export const displayHandle = (handle: Handle) => displayNip05(handle.nip05)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-05 handles, keyed by nip05 identifier. A "local" loadable collection:
|
||||||
|
* items aren't nostr events, they're fetched over HTTP (either directly from
|
||||||
|
* 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: ClientContext,
|
||||||
|
readonly profiles: Profiles,
|
||||||
|
readonly options: {dufflepudUrl?: string} = {},
|
||||||
|
) {
|
||||||
|
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.options.dufflepudUrl) {
|
||||||
|
const res: any = await tryCatch(
|
||||||
|
async () => await postJson(`${this.options.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.profiles.load(pubkey, relays)
|
||||||
|
|
||||||
|
return $profile?.nip05 ? this.load($profile.nip05) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
deriveForPubkey = (pubkey: string, relays: string[] = []) => {
|
||||||
|
this.loadForPubkey(pubkey, relays)
|
||||||
|
|
||||||
|
return deriveDeduplicated(
|
||||||
|
[this.index, this.profiles.derive(pubkey, relays)],
|
||||||
|
([$handlesByNip05, $profile]) => {
|
||||||
|
if (!$profile?.nip05) return undefined
|
||||||
|
|
||||||
|
const handle = $handlesByNip05.get($profile.nip05)
|
||||||
|
|
||||||
|
if (handle?.pubkey !== pubkey) return undefined
|
||||||
|
|
||||||
|
return handle
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
display = (nip05: string) => displayNip05(nip05)
|
||||||
|
}
|
||||||
@@ -1,3 +1,30 @@
|
|||||||
export * from "./thunk.ts"
|
export * from "./client.js"
|
||||||
export * from "./client.ts"
|
export * from "./clientData.js"
|
||||||
export * from "./relays.ts"
|
export * from "./repositoryCollection.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 "./wot.js"
|
||||||
|
export * from "./feeds.js"
|
||||||
|
export * from "./search.js"
|
||||||
|
export * from "./sync.js"
|
||||||
|
export * from "./giftWraps.js"
|
||||||
|
export * from "./commands.js"
|
||||||
|
export * from "./thunk.js"
|
||||||
|
export * from "./createApp.js"
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import {MESSAGING_RELAYS, asDecryptedEvent, readList} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {RepositoryCollection} from "./repositoryCollection.js"
|
||||||
|
import type {ClientContext} from "./client.js"
|
||||||
|
import type {RelayLists} from "./relayLists.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 RepositoryCollection<ReturnType<typeof readList>> {
|
||||||
|
constructor(
|
||||||
|
ctx: ClientContext,
|
||||||
|
readonly relayLists: RelayLists,
|
||||||
|
) {
|
||||||
|
super(ctx, {
|
||||||
|
filters: [{kinds: [MESSAGING_RELAYS]}],
|
||||||
|
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
||||||
|
getKey: list => list.event.pubkey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(pubkey: string, relayHints: string[] = []) {
|
||||||
|
return this.relayLists.makeOutboxLoader(MESSAGING_RELAYS)(pubkey, relayHints)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import {MUTES, asDecryptedEvent, readList} from "@welshman/util"
|
||||||
|
import type {TrustedEvent, PublishedList} from "@welshman/util"
|
||||||
|
import {RepositoryCollection} from "./repositoryCollection.js"
|
||||||
|
import type {ClientContext} from "./client.js"
|
||||||
|
import type {RelayLists} from "./relayLists.js"
|
||||||
|
import type {Plaintext} from "./plaintext.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kind-10000 mute lists, keyed by pubkey. Mute lists carry private entries in
|
||||||
|
* encrypted content, so decoding goes through the plaintext cache.
|
||||||
|
*/
|
||||||
|
export class MuteLists extends RepositoryCollection<PublishedList> {
|
||||||
|
constructor(
|
||||||
|
ctx: ClientContext,
|
||||||
|
readonly relayLists: RelayLists,
|
||||||
|
readonly plaintext: Plaintext,
|
||||||
|
) {
|
||||||
|
super(ctx, {
|
||||||
|
filters: [{kinds: [MUTES]}],
|
||||||
|
eventToItem: async (event: TrustedEvent) => {
|
||||||
|
const content = await plaintext.ensure(event)
|
||||||
|
|
||||||
|
// If this is our own mute list but it couldn't be decrypted yet because
|
||||||
|
// no signer is available, don't cache a result with empty private tags —
|
||||||
|
// that would get stuck permanently since the repository view won't
|
||||||
|
// re-process an already-seen event id. Returning undefined leaves it
|
||||||
|
// uncached so it's retried once a signer is available. For other
|
||||||
|
// pubkeys' lists we fall through and read just the public tags.
|
||||||
|
if (event.content && content === undefined && event.pubkey === ctx.user?.pubkey) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return readList(asDecryptedEvent(event, {content}))
|
||||||
|
},
|
||||||
|
getKey: mute => mute.event.pubkey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(pubkey: string, relayHints: string[] = []) {
|
||||||
|
return this.relayLists.makeOutboxLoader(MUTES)(pubkey, relayHints)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import {PINS, asDecryptedEvent, readList} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {RepositoryCollection} from "./repositoryCollection.js"
|
||||||
|
import type {ClientContext} from "./client.js"
|
||||||
|
import type {RelayLists} from "./relayLists.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-51 pin lists (kind 10001), keyed by pubkey. Loaded via the outbox model
|
||||||
|
* (the author's write relays), so it depends on the relay-list collection.
|
||||||
|
*/
|
||||||
|
export class PinLists extends RepositoryCollection<ReturnType<typeof readList>> {
|
||||||
|
constructor(
|
||||||
|
ctx: ClientContext,
|
||||||
|
readonly relayLists: RelayLists,
|
||||||
|
) {
|
||||||
|
super(ctx, {
|
||||||
|
filters: [{kinds: [PINS]}],
|
||||||
|
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
||||||
|
getKey: pins => pins.event.pubkey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(pubkey: string, relayHints: string[] = []) {
|
||||||
|
return this.relayLists.makeOutboxLoader(PINS)(pubkey, relayHints)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
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.
|
||||||
|
*
|
||||||
|
* In the old global model decryption used `getSigner(getSession(event.pubkey))`
|
||||||
|
* — whichever logged-in account authored the event. In the per-client model
|
||||||
|
* there is exactly one identity, so this reduces to "is this our user?". That
|
||||||
|
* scoping is also what keeps decrypted content (including DM rumors) from
|
||||||
|
* bleeding across identities — each client decrypts only its own.
|
||||||
|
*/
|
||||||
|
export class Plaintext extends ClientData<string> {
|
||||||
|
ensure = async (event: TrustedEvent): Promise<Maybe<string>> => {
|
||||||
|
// Check for key presence rather than truthiness so a legitimately empty
|
||||||
|
// decrypted result ("") is treated as cached and we don't re-hit the signer
|
||||||
|
// on every call.
|
||||||
|
if (event.content && this.get(event.id) === undefined) {
|
||||||
|
const signer = event.pubkey === this.ctx.user?.pubkey ? this.ctx.user?.signer : undefined
|
||||||
|
|
||||||
|
if (!signer) return
|
||||||
|
|
||||||
|
let result
|
||||||
|
|
||||||
|
try {
|
||||||
|
result = await decrypt(signer, event.pubkey, event.content)
|
||||||
|
} catch (e: any) {
|
||||||
|
if (!String(e).match(/invalid base64/)) {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result !== undefined) {
|
||||||
|
this.set(event.id, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.get(event.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import {derived, readable} from "svelte/store"
|
||||||
|
import {readProfile, displayProfile, displayPubkey, PROFILE} from "@welshman/util"
|
||||||
|
import {RepositoryCollection} from "./repositoryCollection.js"
|
||||||
|
import type {ClientContext} from "./client.js"
|
||||||
|
import type {RelayLists} from "./relayLists.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kind-0 profiles, keyed by pubkey. Loaded via the outbox model (the author's
|
||||||
|
* write relays), so it depends on the relay-list collection.
|
||||||
|
*/
|
||||||
|
export class Profiles extends RepositoryCollection<ReturnType<typeof readProfile>> {
|
||||||
|
constructor(
|
||||||
|
ctx: ClientContext,
|
||||||
|
readonly relayLists: RelayLists,
|
||||||
|
) {
|
||||||
|
super(ctx, {
|
||||||
|
filters: [{kinds: [PROFILE]}],
|
||||||
|
eventToItem: readProfile,
|
||||||
|
getKey: profile => profile.event.pubkey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(pubkey: string, relayHints: string[] = []) {
|
||||||
|
return this.relayLists.makeOutboxLoader(PROFILE)(pubkey, relayHints)
|
||||||
|
}
|
||||||
|
|
||||||
|
display = (pubkey: string | undefined) =>
|
||||||
|
pubkey ? displayProfile(this.get(pubkey), displayPubkey(pubkey)) : ""
|
||||||
|
|
||||||
|
deriveDisplay = (pubkey: string | undefined, ...args: any[]) =>
|
||||||
|
pubkey
|
||||||
|
? derived(this.derive(pubkey, ...args), $profile =>
|
||||||
|
displayProfile($profile, displayPubkey(pubkey)),
|
||||||
|
)
|
||||||
|
: readable("")
|
||||||
|
}
|
||||||
@@ -1,67 +1,59 @@
|
|||||||
import {chunk, first} from "@welshman/lib"
|
import {chunk, first} from "@welshman/lib"
|
||||||
import {
|
import {
|
||||||
RELAYS,
|
RELAYS,
|
||||||
|
RelayMode,
|
||||||
asDecryptedEvent,
|
asDecryptedEvent,
|
||||||
readList,
|
readList,
|
||||||
TrustedEvent,
|
|
||||||
sortEventsDesc,
|
|
||||||
getRelaysFromList,
|
getRelaysFromList,
|
||||||
RelayMode,
|
|
||||||
Filter,
|
|
||||||
isPlainReplaceableKind,
|
isPlainReplaceableKind,
|
||||||
|
sortEventsDesc,
|
||||||
} from "@welshman/util"
|
} from "@welshman/util"
|
||||||
import {
|
import type {Filter, TrustedEvent, PublishedList} from "@welshman/util"
|
||||||
deriveItemsByKey,
|
import {RepositoryCollection} from "./repositoryCollection.js"
|
||||||
deriveItems,
|
import {addMinimalFallbacks} from "./router.js"
|
||||||
makeForceLoadItem,
|
import type {Router} from "./router.js"
|
||||||
makeLoadItem,
|
import type {ClientContext} from "./client.js"
|
||||||
makeDeriveItem,
|
|
||||||
getter,
|
|
||||||
} from "@welshman/store"
|
|
||||||
import {load} from "@welshman/net"
|
|
||||||
import {Router, addMinimalFallbacks} from "@welshman/router"
|
|
||||||
import type {Client} from './client.ts'
|
|
||||||
|
|
||||||
export class RelayLists extends ClientData<RelayStatsItem> {
|
/**
|
||||||
constructor(readonly client: Client) {}
|
* NIP-65 relay lists, keyed by pubkey. This is the routing substrate every
|
||||||
|
* other outbox-model load depends on, so it also exposes `loadUsingOutbox` /
|
||||||
|
* `makeOutboxLoader` for other collections to build their fetchers on.
|
||||||
|
*
|
||||||
|
* It depends on a `Router`, and the `Router` depends (via injected functions) on
|
||||||
|
* this collection — a genuine domain cycle that `createApp` breaks by wiring the
|
||||||
|
* router's `getRelaysForPubkey` to a lazily-resolved closure.
|
||||||
|
*/
|
||||||
|
export class RelayLists extends RepositoryCollection<PublishedList> {
|
||||||
|
constructor(
|
||||||
|
ctx: ClientContext,
|
||||||
|
readonly router: Router,
|
||||||
|
) {
|
||||||
|
super(ctx, {
|
||||||
|
filters: [{kinds: [RELAYS]}],
|
||||||
|
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
||||||
|
getKey: (list: PublishedList) => list.event.pubkey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fetch = async (pubkey: string, relayHints: string[] = []) => {
|
fetch(pubkey: string, relayHints: string[] = []) {
|
||||||
const filters = [{kinds: [RELAYS], authors: [pubkey], limit: 1}]
|
const filters = [{kinds: [RELAYS], authors: [pubkey], limit: 1}]
|
||||||
|
|
||||||
await Promise.all([
|
return Promise.all([
|
||||||
this.client.load({filters, relays: Router.get().FromRelays(relayHints).getUrls()}),
|
this.ctx.load({filters, relays: this.router.FromRelays(relayHints).getUrls()}),
|
||||||
this.client.load({filters, relays: Router.get().FromPubkey(pubkey).getUrls()}),
|
this.ctx.load({filters, relays: this.router.FromPubkey(pubkey).getUrls()}),
|
||||||
this.client.load({filters, relays: Router.get().Index().getUrls()}),
|
this.ctx.load({filters, relays: this.router.Index().getUrls()}),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
export const relayListsByPubkey = deriveItemsByKey({
|
getRelaysForPubkey = (pubkey: string, mode?: RelayMode) =>
|
||||||
repository,
|
getRelaysFromList(this.get(pubkey), mode)
|
||||||
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
|
||||||
filters: [{kinds: [RELAYS]}],
|
|
||||||
getKey: relayList => relayList.event.pubkey,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const relayLists = deriveItems(relayListsByPubkey)
|
// Load a pubkey's events using their advertised write relays (outbox model)
|
||||||
|
|
||||||
export const getRelayListsByPubkey = getter(relayListsByPubkey)
|
loadUsingOutbox = async (kind: number, pubkey: string, filter: Filter = {}) => {
|
||||||
|
const filters: Filter[] = [{...filter, kinds: [kind], authors: [pubkey]}]
|
||||||
export const getRelayLists = getter(relayLists)
|
const writeRelays = getRelaysFromList(await this.load(pubkey), RelayMode.Write)
|
||||||
|
const allRelays = this.router
|
||||||
export const getRelayList = (pubkey: string) => getRelayListsByPubkey().get(pubkey)
|
|
||||||
|
|
||||||
export const forceLoadRelayList = makeForceLoadItem(fetchRelayList, getRelayList)
|
|
||||||
|
|
||||||
export const loadRelayList = makeLoadItem(fetchRelayList, getRelayList)
|
|
||||||
|
|
||||||
export const deriveRelayList = makeDeriveItem(relayListsByPubkey, loadRelayList)
|
|
||||||
|
|
||||||
// Outbox loader
|
|
||||||
|
|
||||||
export const loadUsingOutbox = async (kind: number, pubkey: string, filter: Filter = {}) => {
|
|
||||||
const filters = [{...filter, kinds: [kind], authors: [pubkey]}]
|
|
||||||
const writeRelays = getRelaysFromList(await loadRelayList(pubkey), RelayMode.Write)
|
|
||||||
const allRelays = Router.get()
|
|
||||||
.FromRelays(writeRelays)
|
.FromRelays(writeRelays)
|
||||||
.policy(addMinimalFallbacks)
|
.policy(addMinimalFallbacks)
|
||||||
.limit(8)
|
.limit(8)
|
||||||
@@ -72,7 +64,7 @@ export class RelayLists extends ClientData<RelayStatsItem> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const relays of chunk(2, allRelays)) {
|
for (const relays of chunk(2, allRelays)) {
|
||||||
const events = await load({filters, relays})
|
const events = await this.ctx.load({filters, relays})
|
||||||
|
|
||||||
if (events.length > 0) {
|
if (events.length > 0) {
|
||||||
return first(sortEventsDesc(events))
|
return first(sortEventsDesc(events))
|
||||||
@@ -80,12 +72,15 @@ export class RelayLists extends ClientData<RelayStatsItem> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const makeOutboxLoader =
|
makeOutboxLoader =
|
||||||
(kind: number, filter: Filter = {}, limit = 1) =>
|
(kind: number, filter: Filter = {}) =>
|
||||||
async (pubkey: string, relayHints: string[] = []) => {
|
async (pubkey: string, relayHints: string[] = []) => {
|
||||||
const filters = [{...filter, kinds: [kind], authors: [pubkey]}]
|
const filters: Filter[] = [{...filter, kinds: [kind], authors: [pubkey]}]
|
||||||
const relays = Router.get().FromRelays(relayHints).getUrls()
|
const relays = this.router.FromRelays(relayHints).getUrls()
|
||||||
|
|
||||||
await Promise.all([load({filters, relays}), loadUsingOutbox(kind, pubkey, filter)])
|
await Promise.all([
|
||||||
|
this.ctx.load({filters, relays}),
|
||||||
|
this.loadUsingOutbox(kind, pubkey, filter),
|
||||||
|
])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import {writable, Subscriber} from "svelte/store"
|
import type {Unsubscriber} from "svelte/store"
|
||||||
import {getter, makeDeriveItem} from "@welshman/store"
|
|
||||||
import {groupBy, batch, now, uniq, ago, DAY, HOUR, MINUTE} from "@welshman/lib"
|
import {groupBy, batch, now, uniq, ago, DAY, HOUR, MINUTE} from "@welshman/lib"
|
||||||
import {isOnionUrl, isLocalUrl, isIPAddress, isRelayUrl, getRelaysFromList} from "@welshman/util"
|
import {isOnionUrl, isLocalUrl, isIPAddress, isRelayUrl} from "@welshman/util"
|
||||||
import {Socket, SocketStatus, SocketEvent, ClientMessage, RelayMessage} from "@welshman/net"
|
import {SocketStatus, SocketEvent} from "@welshman/net"
|
||||||
import {getBlockedRelayList} from "./blockedRelayLists.js"
|
import type {Socket, ClientMessage, RelayMessage} from "@welshman/net"
|
||||||
import type {Client} from "./client.js"
|
import {ClientData} from "./clientData.js"
|
||||||
|
import type {ClientContext} from "./client.js"
|
||||||
|
|
||||||
export type RelayStatsUpdate = [string, (stats: RelayStatsItem) => void]
|
export type RelayStatsUpdate = [string, (stats: RelayStatsItem) => void]
|
||||||
|
|
||||||
@@ -53,11 +52,27 @@ export const makeRelayStatsItem = (url: string): RelayStatsItem => ({
|
|||||||
notice_count: 0,
|
notice_count: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export type RelayStatsOptions = {
|
||||||
|
// Allows a host app to zero out blocked relays once a blocked-relay-list
|
||||||
|
// module is composed in, without RelayStats depending on it.
|
||||||
|
isRelayBlocked?: (url: string) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks per-relay connection statistics by listening to socket activity on the
|
||||||
|
* client's pool, and exposes a `getQuality` heuristic the router uses to rank
|
||||||
|
* relays. A "local" collection — its data isn't backed by the repository.
|
||||||
|
*/
|
||||||
export class RelayStats extends ClientData<RelayStatsItem> {
|
export class RelayStats extends ClientData<RelayStatsItem> {
|
||||||
cleanup: Unsubscriber
|
cleanup: Unsubscriber
|
||||||
|
|
||||||
constructor(readonly client: Client) {
|
constructor(
|
||||||
this.cleanup = client.pool.subscribe(socket => {
|
ctx: ClientContext,
|
||||||
|
readonly statsOptions: RelayStatsOptions = {},
|
||||||
|
) {
|
||||||
|
super(ctx)
|
||||||
|
|
||||||
|
this.cleanup = ctx.pool.subscribe(socket => {
|
||||||
socket.on(SocketEvent.Send, this.onSocketSend)
|
socket.on(SocketEvent.Send, this.onSocketSend)
|
||||||
socket.on(SocketEvent.Receive, this.onSocketReceive)
|
socket.on(SocketEvent.Receive, this.onSocketReceive)
|
||||||
socket.on(SocketEvent.Status, this.onSocketStatus)
|
socket.on(SocketEvent.Status, this.onSocketStatus)
|
||||||
@@ -70,14 +85,50 @@ export class RelayStats extends ClientData<RelayStatsItem> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getQuality = (url: string) => {
|
||||||
|
// Skip non-relays entirely
|
||||||
|
if (!isRelayUrl(url)) return 0
|
||||||
|
|
||||||
|
// Skip blocked relays (when a host app provides the check)
|
||||||
|
if (this.statsOptions.isRelayBlocked?.(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
|
||||||
|
}
|
||||||
|
|
||||||
// Utilities for syncing stats from connections to relays
|
// Utilities for syncing stats from connections to relays
|
||||||
|
|
||||||
private updateRelayStats = batch(150, (batched: RelayStatsUpdate[]) => {
|
private updateRelayStats = batch(150, (batched: RelayStatsUpdate[]) => {
|
||||||
for (const [url, updates] of groupBy(([url]) => url, batched)) {
|
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 prev = this.get(url)
|
||||||
const next = prev ? {...prev} : makeRelayStatsItem(url)
|
const next = prev ? {...prev} : makeRelayStatsItem(url)
|
||||||
|
|
||||||
for (const [_, update] of updates) {
|
for (const [, update] of updates) {
|
||||||
update(next)
|
update(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +158,7 @@ export class RelayStats extends ClientData<RelayStatsItem> {
|
|||||||
|
|
||||||
private onSocketReceive = ([verb, ...extra]: RelayMessage, url: string) => {
|
private onSocketReceive = ([verb, ...extra]: RelayMessage, url: string) => {
|
||||||
if (verb === "OK") {
|
if (verb === "OK") {
|
||||||
const [_, ok] = extra
|
const [, ok] = extra
|
||||||
|
|
||||||
this.updateRelayStats([
|
this.updateRelayStats([
|
||||||
url,
|
url,
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
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} 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) => displayRelayProfile(this.get(url), displayRelayUrl(url))
|
||||||
|
|
||||||
|
deriveDisplay = (url: string) =>
|
||||||
|
derived(this.derive(url), $relay => displayRelayProfile($relay, displayRelayUrl(url)))
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import type {Readable} from "svelte/store"
|
||||||
|
import type {Maybe} from "@welshman/lib"
|
||||||
|
import type {Filter} from "@welshman/util"
|
||||||
|
import {deriveItems, getter, makeLoadItem, makeForceLoadItem, makeDeriveItem} from "@welshman/store"
|
||||||
|
import type {EventToItem, ItemsByKey, MakeLoadItemOptions} from "@welshman/store"
|
||||||
|
import type {ClientContext} from "./client.js"
|
||||||
|
|
||||||
|
export type RepositoryCollectionOptions<T> = {
|
||||||
|
filters: Filter[]
|
||||||
|
eventToItem: EventToItem<T>
|
||||||
|
getKey: (item: T) => string
|
||||||
|
loadOptions?: MakeLoadItemOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for a reactive, keyed collection of data derived from nostr events.
|
||||||
|
* The repository is the single source of truth — the collection is a live view
|
||||||
|
* over `ctx.deriveItemsByKey`, never a duplicated map. Subclasses implement
|
||||||
|
* `fetch` (how to load an item by key from the network) and pass the
|
||||||
|
* filters/decoder via `super`.
|
||||||
|
*
|
||||||
|
* Like `ClientData`, subclasses depend only on the `ClientContext` seam.
|
||||||
|
*/
|
||||||
|
export abstract class RepositoryCollection<T> {
|
||||||
|
byKey: Readable<ItemsByKey<T>>
|
||||||
|
all: Readable<T[]>
|
||||||
|
subscribe: Readable<ItemsByKey<T>>["subscribe"]
|
||||||
|
get: (key: string) => Maybe<T>
|
||||||
|
getAll: () => T[]
|
||||||
|
keys: () => IterableIterator<string>
|
||||||
|
values: () => IterableIterator<T>
|
||||||
|
load: (key: string, ...args: any[]) => Promise<Maybe<T>>
|
||||||
|
forceLoad: (key: string, ...args: any[]) => Promise<Maybe<T>>
|
||||||
|
// Reactive view of a single key that also triggers a load
|
||||||
|
derive: (key?: string, ...args: any[]) => Readable<Maybe<T>>
|
||||||
|
// Reactive view of a single key that does not trigger a load
|
||||||
|
derived: (key?: string, ...args: any[]) => Readable<Maybe<T>>
|
||||||
|
private getByKey: () => ItemsByKey<T>
|
||||||
|
|
||||||
|
abstract fetch(key: string, ...args: any[]): Promise<unknown>
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
protected readonly ctx: ClientContext,
|
||||||
|
options: RepositoryCollectionOptions<T>,
|
||||||
|
) {
|
||||||
|
const fetch = (key: string, ...args: any[]) => this.fetch(key, ...args)
|
||||||
|
|
||||||
|
this.byKey = ctx.deriveItemsByKey<T>({
|
||||||
|
filters: options.filters,
|
||||||
|
eventToItem: options.eventToItem,
|
||||||
|
getKey: options.getKey,
|
||||||
|
})
|
||||||
|
this.all = deriveItems(this.byKey)
|
||||||
|
this.subscribe = this.byKey.subscribe
|
||||||
|
this.getByKey = getter(this.byKey)
|
||||||
|
this.getAll = getter(this.all)
|
||||||
|
this.get = (key: string) => this.getByKey().get(key)
|
||||||
|
this.keys = () => this.getByKey().keys()
|
||||||
|
this.values = () => this.getByKey().values()
|
||||||
|
this.load = makeLoadItem(fetch, this.get, options.loadOptions)
|
||||||
|
this.forceLoad = makeForceLoadItem(fetch, this.get)
|
||||||
|
this.derive = makeDeriveItem(this.byKey, this.load)
|
||||||
|
this.derived = makeDeriveItem(this.byKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience views of the current user's own item (replaces the old
|
||||||
|
// user.ts userProfile/userFollowList/etc. derived stores)
|
||||||
|
|
||||||
|
getForUser = () => {
|
||||||
|
const pubkey = this.ctx.user?.pubkey
|
||||||
|
|
||||||
|
return pubkey ? this.get(pubkey) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
deriveForUser = (...args: any[]) => this.derive(this.ctx.user?.pubkey, ...args)
|
||||||
|
|
||||||
|
loadForUser = (...args: any[]) => {
|
||||||
|
const pubkey = this.ctx.user?.pubkey
|
||||||
|
|
||||||
|
return pubkey ? this.load(pubkey, ...args) : Promise.resolve(undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
forceLoadForUser = (...args: any[]) => {
|
||||||
|
const pubkey = this.ctx.user?.pubkey
|
||||||
|
|
||||||
|
return pubkey ? this.forceLoad(pubkey, ...args) : Promise.resolve(undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,41 +1,17 @@
|
|||||||
|
import {nth, uniq, first, sortBy, shuffle, inc, add, take} from "@welshman/lib"
|
||||||
import {
|
import {
|
||||||
nth,
|
|
||||||
uniq,
|
|
||||||
intersection,
|
|
||||||
mergeLeft,
|
|
||||||
first,
|
|
||||||
clamp,
|
|
||||||
sortBy,
|
|
||||||
shuffle,
|
|
||||||
pushToMapKey,
|
|
||||||
inc,
|
|
||||||
add,
|
|
||||||
take,
|
|
||||||
chunks,
|
|
||||||
} from "@welshman/lib"
|
|
||||||
import {
|
|
||||||
getFilterId,
|
|
||||||
isRelayUrl,
|
isRelayUrl,
|
||||||
isOnionUrl,
|
isOnionUrl,
|
||||||
isLocalUrl,
|
isLocalUrl,
|
||||||
isShareableRelayUrl,
|
isShareableRelayUrl,
|
||||||
PROFILE,
|
|
||||||
RELAYS,
|
|
||||||
MESSAGING_RELAYS,
|
|
||||||
FOLLOWS,
|
|
||||||
WRAP,
|
|
||||||
getPubkeyTagValues,
|
getPubkeyTagValues,
|
||||||
normalizeRelayUrl,
|
normalizeRelayUrl,
|
||||||
TrustedEvent,
|
|
||||||
Filter,
|
|
||||||
readList,
|
|
||||||
getAncestorTags,
|
getAncestorTags,
|
||||||
asDecryptedEvent,
|
|
||||||
getRelaysFromList,
|
|
||||||
getPubkeyTags,
|
getPubkeyTags,
|
||||||
RelayMode,
|
RelayMode,
|
||||||
} from "@welshman/util"
|
} from "@welshman/util"
|
||||||
import {Repository} from "@welshman/net"
|
import type {TrustedEvent, Filter} from "@welshman/util"
|
||||||
|
import type {ClientContext} from "./client.js"
|
||||||
|
|
||||||
export type RelaysAndFilters = {
|
export type RelaysAndFilters = {
|
||||||
relays: string[]
|
relays: string[]
|
||||||
@@ -67,6 +43,21 @@ export type RouterOptions = {
|
|||||||
* @returns The limit setting as a number.
|
* @returns The limit setting as a number.
|
||||||
*/
|
*/
|
||||||
getLimit?: () => number
|
getLimit?: () => number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a pubkey's relays for a given mode. Injected (rather than read off
|
||||||
|
* a relay-list module directly) so the router has no dependency on its sibling
|
||||||
|
* data modules. See `createApp`.
|
||||||
|
* @returns An array of relay URLs as strings.
|
||||||
|
*/
|
||||||
|
getRelaysForPubkey?: (pubkey: string, mode?: RelayMode) => string[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scores a relay url for ranking (higher is better). Injected so the router
|
||||||
|
* doesn't depend on the relay-stats module.
|
||||||
|
* @returns A quality score, typically between 0 and 1.
|
||||||
|
*/
|
||||||
|
getRelayQuality?: (url: string) => number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Selection = {
|
export type Selection = {
|
||||||
@@ -92,18 +83,24 @@ export const addMaximalFallbacks = (count: number, limit: number) => limit - cou
|
|||||||
// Router class
|
// Router class
|
||||||
|
|
||||||
export class Router {
|
export class Router {
|
||||||
constructor(readonly client: Client, readonly options: RouterOptions) {}
|
constructor(
|
||||||
|
readonly ctx: ClientContext,
|
||||||
|
readonly options: RouterOptions,
|
||||||
|
) {}
|
||||||
|
|
||||||
// Utilities derived from options
|
// Utilities derived from options
|
||||||
|
|
||||||
getRelaysForPubkey = (pubkey: string, mode?: RelayMode) =>
|
getRelaysForPubkey = (pubkey: string, mode?: RelayMode) =>
|
||||||
getRelaysFromList(this.client.relayList.get(pubkey), mode)
|
this.options.getRelaysForPubkey?.(pubkey, mode) || []
|
||||||
|
|
||||||
getRelaysForPubkeys = (pubkeys: string[], mode?: RelayMode) =>
|
getRelaysForPubkeys = (pubkeys: string[], mode?: RelayMode) =>
|
||||||
pubkeys.map(pubkey => this.getRelaysForPubkey(pubkey, mode))
|
pubkeys.map(pubkey => this.getRelaysForPubkey(pubkey, mode))
|
||||||
|
|
||||||
getRelaysForUser = (mode?: RelayMode) =>
|
getRelaysForUser = (mode?: RelayMode) => {
|
||||||
this.getRelaysForPubkey(this.client.pubkey, mode)
|
const pubkey = this.ctx.user?.pubkey
|
||||||
|
|
||||||
|
return pubkey ? this.getRelaysForPubkey(pubkey, mode) : []
|
||||||
|
}
|
||||||
|
|
||||||
// Utilities for creating scenarios
|
// Utilities for creating scenarios
|
||||||
|
|
||||||
@@ -277,7 +274,7 @@ export class RouterScenario {
|
|||||||
|
|
||||||
const scoreRelay = (relay: string) => {
|
const scoreRelay = (relay: string) => {
|
||||||
const weight = relayWeights.get(relay)!
|
const weight = relayWeights.get(relay)!
|
||||||
const quality = this.router.client.getRelayQuality(relay)
|
const quality = this.router.options.getRelayQuality?.(relay) ?? 1
|
||||||
|
|
||||||
// Log the weight, since it's a straight count which ends up over-weighting hubs.
|
// Log the weight, since it's a straight count which ends up over-weighting hubs.
|
||||||
// Also add some random noise so that we'll occasionally pick lower quality/less
|
// Also add some random noise so that we'll occasionally pick lower quality/less
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
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, deriveItems} from "@welshman/store"
|
||||||
|
import type {ClientContext} from "./client.js"
|
||||||
|
import type {Router} from "./router.js"
|
||||||
|
import type {Profiles} from "./profiles.js"
|
||||||
|
import type {Topics, Topic} from "./topics.js"
|
||||||
|
import type {Relays} from "./relays.js"
|
||||||
|
import type {Handles} from "./handles.js"
|
||||||
|
import type {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: ClientContext,
|
||||||
|
readonly router: Router,
|
||||||
|
readonly profiles: Profiles,
|
||||||
|
readonly topics: Topics,
|
||||||
|
readonly relays: Relays,
|
||||||
|
readonly handles: Handles,
|
||||||
|
readonly wot: Wot,
|
||||||
|
) {
|
||||||
|
this.profileSearch = derived(
|
||||||
|
[throttled(800, this.profiles.all), throttled(800, this.handles)],
|
||||||
|
([$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.wot.getWotGraph().get(item.event.pubkey) || 0
|
||||||
|
|
||||||
|
return dec(score) * inc(wotScore / (this.wot.getMaxWot() || 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.topics.all, $topics =>
|
||||||
|
createSearch($topics, {
|
||||||
|
getValue: (topic: Topic) => topic.name,
|
||||||
|
fuseOptions: {keys: ["name"]},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
this.relaySearch = derived(deriveItems(this.relays), $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.load({
|
||||||
|
filters: [{kinds: [PROFILE], search}],
|
||||||
|
relays: this.router.Search().getUrls(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import {SEARCH_RELAYS, asDecryptedEvent, readList} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {RepositoryCollection} from "./repositoryCollection.js"
|
||||||
|
import type {ClientContext} from "./client.js"
|
||||||
|
import type {RelayLists} from "./relayLists.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 RepositoryCollection<ReturnType<typeof readList>> {
|
||||||
|
constructor(
|
||||||
|
ctx: ClientContext,
|
||||||
|
readonly relayLists: RelayLists,
|
||||||
|
) {
|
||||||
|
super(ctx, {
|
||||||
|
filters: [{kinds: [SEARCH_RELAYS]}],
|
||||||
|
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
||||||
|
getKey: searchRelayList => searchRelayList.event.pubkey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(pubkey: string, relayHints: string[] = []) {
|
||||||
|
return this.relayLists.makeOutboxLoader(SEARCH_RELAYS)(pubkey, relayHints)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import {Client as PomadeClient, PomadeSigner} from "@pomade/core"
|
||||||
|
import type {ClientOptions as PomadeClientOptions} from "@pomade/core"
|
||||||
|
import {writable} from "svelte/store"
|
||||||
|
import {randomId, append} from "@welshman/lib"
|
||||||
|
import {getPubkey} from "@welshman/util"
|
||||||
|
import {
|
||||||
|
WrappedSigner,
|
||||||
|
Nip46Broker,
|
||||||
|
Nip46Signer,
|
||||||
|
Nip07Signer,
|
||||||
|
Nip01Signer,
|
||||||
|
Nip55Signer,
|
||||||
|
} from "@welshman/signer"
|
||||||
|
import type {ISigner} from "@welshman/signer"
|
||||||
|
import {User} from "./user.js"
|
||||||
|
import type {UserOptions} from "./user.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session descriptors and the signer construction that turns them into a
|
||||||
|
* `User`. In the old global package these fed a multi-account registry (a single
|
||||||
|
* `sessions` map + `pubkey` pointer over one shared repository — the root of the
|
||||||
|
* merged-DM bug). In the per-client model each session becomes its own `User`
|
||||||
|
* (and thus its own `Client` with its own repository), so "multi-account" is
|
||||||
|
* just "multiple clients" and lives above this module.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export enum SessionMethod {
|
||||||
|
Nip01 = "nip01",
|
||||||
|
Nip07 = "nip07",
|
||||||
|
Nip46 = "nip46",
|
||||||
|
Nip55 = "nip55",
|
||||||
|
Pomade = "pomade",
|
||||||
|
Pubkey = "pubkey",
|
||||||
|
Anonymous = "anonymous",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionNip01 = {
|
||||||
|
method: SessionMethod.Nip01
|
||||||
|
pubkey: string
|
||||||
|
secret: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionNip07 = {
|
||||||
|
method: SessionMethod.Nip07
|
||||||
|
pubkey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionNip46 = {
|
||||||
|
method: SessionMethod.Nip46
|
||||||
|
pubkey: string
|
||||||
|
secret: string
|
||||||
|
handler: {
|
||||||
|
pubkey: string
|
||||||
|
relays: string[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionNip55 = {
|
||||||
|
method: SessionMethod.Nip55
|
||||||
|
pubkey: string
|
||||||
|
signer: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionPomade = {
|
||||||
|
method: SessionMethod.Pomade
|
||||||
|
pubkey: string
|
||||||
|
clientOptions: PomadeClientOptions
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionPubkey = {
|
||||||
|
method: SessionMethod.Pubkey
|
||||||
|
pubkey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionAnonymous = {
|
||||||
|
method: SessionMethod.Anonymous
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionAnyMethod =
|
||||||
|
| SessionNip01
|
||||||
|
| SessionNip07
|
||||||
|
| SessionNip46
|
||||||
|
| SessionNip55
|
||||||
|
| SessionPomade
|
||||||
|
| SessionPubkey
|
||||||
|
| SessionAnonymous
|
||||||
|
|
||||||
|
export type Session = SessionAnyMethod & Record<string, any>
|
||||||
|
|
||||||
|
// Session factories
|
||||||
|
|
||||||
|
export const makeNip01Session = (secret: string): SessionNip01 => ({
|
||||||
|
method: SessionMethod.Nip01,
|
||||||
|
secret,
|
||||||
|
pubkey: getPubkey(secret),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const makeNip07Session = (pubkey: string): SessionNip07 => ({
|
||||||
|
method: SessionMethod.Nip07,
|
||||||
|
pubkey,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const makeNip46Session = (
|
||||||
|
pubkey: string,
|
||||||
|
clientSecret: string,
|
||||||
|
signerPubkey: string,
|
||||||
|
relays: string[],
|
||||||
|
): SessionNip46 => ({
|
||||||
|
method: SessionMethod.Nip46,
|
||||||
|
pubkey,
|
||||||
|
secret: clientSecret,
|
||||||
|
handler: {pubkey: signerPubkey, relays},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const makeNip55Session = (pubkey: string, signer: string): SessionNip55 => ({
|
||||||
|
method: SessionMethod.Nip55,
|
||||||
|
pubkey,
|
||||||
|
signer,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const makePomadeSession = (
|
||||||
|
pubkey: string,
|
||||||
|
email: string,
|
||||||
|
clientOptions: PomadeClientOptions,
|
||||||
|
): SessionPomade => ({
|
||||||
|
method: SessionMethod.Pomade,
|
||||||
|
pubkey,
|
||||||
|
clientOptions,
|
||||||
|
email,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const makePubkeySession = (pubkey: string): SessionPubkey => ({
|
||||||
|
method: SessionMethod.Pubkey,
|
||||||
|
pubkey,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Type guards
|
||||||
|
|
||||||
|
export const isNip01Session = (session?: Session): session is SessionNip01 =>
|
||||||
|
session?.method === SessionMethod.Nip01
|
||||||
|
|
||||||
|
export const isNip07Session = (session?: Session): session is SessionNip07 =>
|
||||||
|
session?.method === SessionMethod.Nip07
|
||||||
|
|
||||||
|
export const isNip46Session = (session?: Session): session is SessionNip46 =>
|
||||||
|
session?.method === SessionMethod.Nip46
|
||||||
|
|
||||||
|
export const isNip55Session = (session?: Session): session is SessionNip55 =>
|
||||||
|
session?.method === SessionMethod.Nip55
|
||||||
|
|
||||||
|
export const isPomadeSession = (session?: Session): session is SessionPomade =>
|
||||||
|
session?.method === SessionMethod.Pomade
|
||||||
|
|
||||||
|
export const isPubkeySession = (session?: Session): session is SessionPubkey =>
|
||||||
|
session?.method === SessionMethod.Pubkey
|
||||||
|
|
||||||
|
// Signer construction
|
||||||
|
|
||||||
|
export const nip46Perms = "sign_event:22242,nip04_encrypt,nip04_decrypt,nip44_encrypt,nip44_decrypt"
|
||||||
|
|
||||||
|
export type SignerLogEntry = {
|
||||||
|
id: string
|
||||||
|
method: string
|
||||||
|
started_at: number
|
||||||
|
finished_at?: number
|
||||||
|
ok?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const signerLog = writable<SignerLogEntry[]>([])
|
||||||
|
|
||||||
|
export const wrapSigner = (signer: ISigner) =>
|
||||||
|
new WrappedSigner(signer, async <T>(method: string, thunk: () => Promise<T>) => {
|
||||||
|
const id = randomId()
|
||||||
|
|
||||||
|
signerLog.update(log => append({id, method, started_at: Date.now()}, log))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await thunk()
|
||||||
|
|
||||||
|
signerLog.update(log =>
|
||||||
|
log.map(x => (x.id === id ? {...x, finished_at: Date.now(), ok: true} : x)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (error: any) {
|
||||||
|
signerLog.update(log =>
|
||||||
|
log.map(x => (x.id === id ? {...x, finished_at: Date.now(), ok: false} : x)),
|
||||||
|
)
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getSigner = (session?: Session): ISigner | undefined => {
|
||||||
|
if (isNip07Session(session)) return wrapSigner(new Nip07Signer())
|
||||||
|
if (isNip01Session(session)) return wrapSigner(new Nip01Signer(session.secret))
|
||||||
|
if (isNip55Session(session)) return wrapSigner(new Nip55Signer(session.signer, session.pubkey))
|
||||||
|
if (isPomadeSession(session)) return wrapSigner(new PomadeSigner(new PomadeClient(session.clientOptions)))
|
||||||
|
if (isNip46Session(session)) {
|
||||||
|
const {
|
||||||
|
secret: clientSecret,
|
||||||
|
handler: {relays, pubkey: signerPubkey},
|
||||||
|
} = session
|
||||||
|
const broker = new Nip46Broker({clientSecret, signerPubkey, relays})
|
||||||
|
|
||||||
|
return wrapSigner(new Nip46Signer(broker))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a `User` (pubkey + signer) from a session descriptor. Returns undefined
|
||||||
|
* for sessions that can't sign (e.g. read-only Pubkey or Anonymous). Pass the
|
||||||
|
* result to `new Client({user})` / `createApp({user})`.
|
||||||
|
*/
|
||||||
|
export const userFromSession = (session: Session, options: UserOptions = {}): User | undefined => {
|
||||||
|
const signer = getSigner(session)
|
||||||
|
|
||||||
|
return signer && typeof session.pubkey === "string"
|
||||||
|
? new User(session.pubkey, signer, options)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login helpers — each returns a User to build a client/app with
|
||||||
|
|
||||||
|
export const loginWithNip01 = (secret: string, options?: UserOptions) =>
|
||||||
|
userFromSession(makeNip01Session(secret), options)
|
||||||
|
|
||||||
|
export const loginWithNip07 = (pubkey: string, options?: UserOptions) =>
|
||||||
|
userFromSession(makeNip07Session(pubkey), options)
|
||||||
|
|
||||||
|
export const loginWithNip46 = (
|
||||||
|
pubkey: string,
|
||||||
|
clientSecret: string,
|
||||||
|
signerPubkey: string,
|
||||||
|
relays: string[],
|
||||||
|
options?: UserOptions,
|
||||||
|
) => userFromSession(makeNip46Session(pubkey, clientSecret, signerPubkey, relays), options)
|
||||||
|
|
||||||
|
export const loginWithNip55 = (pubkey: string, signer: string, options?: UserOptions) =>
|
||||||
|
userFromSession(makeNip55Session(pubkey, signer), options)
|
||||||
|
|
||||||
|
export const loginWithPomade = (
|
||||||
|
pubkey: string,
|
||||||
|
email: string,
|
||||||
|
clientOptions: PomadeClientOptions,
|
||||||
|
options?: UserOptions,
|
||||||
|
) => userFromSession(makePomadeSession(pubkey, email, clientOptions), options)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import {isSignedEvent} from "@welshman/util"
|
||||||
|
import type {Filter, SignedEvent} from "@welshman/util"
|
||||||
|
import type {ClientContext} from "./client.js"
|
||||||
|
import type {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 injected `Relays` collection to detect negentropy support.
|
||||||
|
*/
|
||||||
|
export class Sync {
|
||||||
|
constructor(
|
||||||
|
readonly ctx: ClientContext,
|
||||||
|
readonly relays: Relays,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
query = (filters: Filter[]) =>
|
||||||
|
this.ctx.repository.query(filters, {shouldSort: filters.every(f => f.limit === undefined)})
|
||||||
|
|
||||||
|
hasNegentropy = (url: string) => {
|
||||||
|
const relay = this.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 events = this.query(filters).filter(isSignedEvent)
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
relays.map(async relay => {
|
||||||
|
await (this.hasNegentropy(relay)
|
||||||
|
? this.ctx.pull({filters, events, relays: [relay]})
|
||||||
|
: this.ctx.request({filters, relays: [relay], autoClose: true}))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
push = async ({relays, filters}: AppSyncOpts) => {
|
||||||
|
const events = this.query(filters).filter(isSignedEvent)
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
relays.map(async relay => {
|
||||||
|
await (this.hasNegentropy(relay)
|
||||||
|
? this.ctx.push({filters, events, relays: [relay]})
|
||||||
|
: Promise.all(
|
||||||
|
events.map((event: SignedEvent) => this.ctx.publish({event, relays: [relay]})),
|
||||||
|
))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import {uniq, remove} from "@welshman/lib"
|
||||||
|
import {
|
||||||
|
getAddress,
|
||||||
|
isReplaceable,
|
||||||
|
getReplyTags,
|
||||||
|
getPubkeyTagValues,
|
||||||
|
isReplaceableKind,
|
||||||
|
isShareableRelayUrl,
|
||||||
|
} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import type {Router} from "./router.js"
|
||||||
|
import type {Profiles} from "./profiles.js"
|
||||||
|
import type {ClientContext} 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: ClientContext,
|
||||||
|
readonly router: Router,
|
||||||
|
readonly profiles: Profiles,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
tagZapSplit = (pubkey: string, split = 1) => [
|
||||||
|
"zap",
|
||||||
|
pubkey,
|
||||||
|
this.router.FromPubkey(pubkey).getUrl() || "",
|
||||||
|
String(split),
|
||||||
|
]
|
||||||
|
|
||||||
|
tagPubkey = (pubkey: string) => [
|
||||||
|
"p",
|
||||||
|
pubkey,
|
||||||
|
this.router.FromPubkey(pubkey).getUrl() || "",
|
||||||
|
this.profiles.display(pubkey),
|
||||||
|
]
|
||||||
|
|
||||||
|
tagEvent = (event: TrustedEvent, url = "", mark = "") => {
|
||||||
|
if (!url) {
|
||||||
|
url = this.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.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.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.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.router.FromPubkey(event.pubkey).getUrl() || ""
|
||||||
|
const eventHint = relay || this.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.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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,14 +13,14 @@ import {
|
|||||||
} from "@welshman/util"
|
} from "@welshman/util"
|
||||||
import {PublishStatus, PublishResult, PublishOptions, PublishResultsByRelay} from "@welshman/net"
|
import {PublishStatus, PublishResult, PublishOptions, PublishResultsByRelay} from "@welshman/net"
|
||||||
import {Nip01Signer, Nip59} from "@welshman/signer"
|
import {Nip01Signer, Nip59} from "@welshman/signer"
|
||||||
import type {Client} from './client.js'
|
import type {ClientContext} from './client.js'
|
||||||
import type {User} from './user.js'
|
import type {User} from './user.js'
|
||||||
|
|
||||||
export type ThunkOptions = Override<
|
export type ThunkOptions = Override<
|
||||||
PublishOptions,
|
PublishOptions,
|
||||||
{
|
{
|
||||||
user: User
|
user: User
|
||||||
client: Client
|
client: ClientContext
|
||||||
event: EventTemplate
|
event: EventTemplate
|
||||||
recipient?: string
|
recipient?: string
|
||||||
delay?: number
|
delay?: number
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import {readable} from "svelte/store"
|
||||||
|
import type {Readable} from "svelte/store"
|
||||||
|
import {on} from "@welshman/lib"
|
||||||
|
import {getTopicTagValues} from "@welshman/util"
|
||||||
|
import {deriveItems} from "@welshman/store"
|
||||||
|
import type {ClientContext} 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: ClientContext) {
|
||||||
|
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}: {added: {tags: string[][]}[]}) => {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,25 @@
|
|||||||
import {Maybe} from '@welshman/lib'
|
import {makeSocketPolicyAuth} from "@welshman/net"
|
||||||
import {Repository, AdapterFactory, NetContext, WrapManager, DiffOptions, PullOptions, PushOptions, RequestOptions, PublishOptions, LoaderOptions, Tracker, Pool, push, pull, diff, publish, request, makeLoader} from '@welshman/net'
|
import type {Socket} from "@welshman/net"
|
||||||
import {ISigner} from '@welshman/signer'
|
import type {StampedEvent} from "@welshman/util"
|
||||||
|
import type {ISigner} from "@welshman/signer"
|
||||||
|
|
||||||
export type UserOptions = {
|
export type UserOptions = {
|
||||||
shouldAuth?: (socket: Socket) => boolean
|
shouldAuth?: (socket: Socket) => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
export class User {
|
||||||
constructor(
|
constructor(
|
||||||
readonly pubkey: string,
|
readonly pubkey: string,
|
||||||
readonly signer: ISigner,
|
readonly signer: ISigner,
|
||||||
readonly options: UserOptions
|
readonly options: UserOptions = {},
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
static async fromSigner(signer: ISigner, options: UserOptions) {
|
static async fromSigner(signer: ISigner, options: UserOptions = {}) {
|
||||||
const pubkey = await signer.getPubkey()
|
const pubkey = await signer.getPubkey()
|
||||||
|
|
||||||
return new User(pubkey, signer, options)
|
return new User(pubkey, signer, options)
|
||||||
@@ -24,4 +30,8 @@ export class User {
|
|||||||
sign: this.signer.sign,
|
sign: this.signer.sign,
|
||||||
shouldAuth: this.options.shouldAuth,
|
shouldAuth: this.options.shouldAuth,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
sign = (event: StampedEvent) => this.signer.sign(event)
|
||||||
|
|
||||||
|
nip44EncryptToSelf = (payload: string) => this.signer.nip44.encrypt(this.pubkey, payload)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import {derived, writable} from "svelte/store"
|
||||||
|
import type {Readable, Writable} from "svelte/store"
|
||||||
|
import {max, throttle, addToMapKey, inc, dec} from "@welshman/lib"
|
||||||
|
import {getListTags, getPubkeyTagValues} from "@welshman/util"
|
||||||
|
import {throttled, getter} from "@welshman/store"
|
||||||
|
import type {ClientContext} from "./client.js"
|
||||||
|
import type {FollowLists} from "./follows.js"
|
||||||
|
import type {MuteLists} from "./mutes.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export class Wot {
|
||||||
|
followersByPubkey: Readable<Map<string, Set<string>>>
|
||||||
|
mutersByPubkey: Readable<Map<string, Set<string>>>
|
||||||
|
wotGraph: Writable<Map<string, number>>
|
||||||
|
maxWot: Readable<number | undefined>
|
||||||
|
|
||||||
|
private getFollowersByPubkeyStore: () => Map<string, Set<string>>
|
||||||
|
private getMutersByPubkeyStore: () => Map<string, Set<string>>
|
||||||
|
private getWotGraphStore: () => Map<string, number>
|
||||||
|
private getMaxWotStore: () => number | undefined
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly ctx: ClientContext,
|
||||||
|
readonly followLists: FollowLists,
|
||||||
|
readonly muteLists: MuteLists,
|
||||||
|
) {
|
||||||
|
this.followersByPubkey = derived(throttled(1000, this.followLists.all), lists => {
|
||||||
|
const $followersByPubkey = new Map<string, Set<string>>()
|
||||||
|
|
||||||
|
for (const list of lists) {
|
||||||
|
for (const pubkey of getPubkeyTagValues(getListTags(list))) {
|
||||||
|
addToMapKey($followersByPubkey, pubkey, list.event.pubkey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $followersByPubkey
|
||||||
|
})
|
||||||
|
|
||||||
|
this.mutersByPubkey = derived(throttled(1000, this.muteLists.all), lists => {
|
||||||
|
const $mutersByPubkey = new Map<string, Set<string>>()
|
||||||
|
|
||||||
|
for (const list of lists) {
|
||||||
|
for (const pubkey of getPubkeyTagValues(getListTags(list))) {
|
||||||
|
addToMapKey($mutersByPubkey, pubkey, list.event.pubkey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $mutersByPubkey
|
||||||
|
})
|
||||||
|
|
||||||
|
this.wotGraph = writable(new Map<string, number>())
|
||||||
|
|
||||||
|
this.maxWot = derived(this.wotGraph, $g => max(Array.from($g.values())))
|
||||||
|
|
||||||
|
this.getFollowersByPubkeyStore = getter(this.followersByPubkey)
|
||||||
|
this.getMutersByPubkeyStore = getter(this.mutersByPubkey)
|
||||||
|
this.getWotGraphStore = getter(this.wotGraph)
|
||||||
|
this.getMaxWotStore = getter(this.maxWot)
|
||||||
|
|
||||||
|
this.followLists.subscribe(this.buildGraph)
|
||||||
|
this.muteLists.subscribe(this.buildGraph)
|
||||||
|
}
|
||||||
|
|
||||||
|
getFollows = (pubkey: string) => getPubkeyTagValues(getListTags(this.followLists.get(pubkey)))
|
||||||
|
|
||||||
|
getMutes = (pubkey: string) => getPubkeyTagValues(getListTags(this.muteLists.get(pubkey)))
|
||||||
|
|
||||||
|
getNetwork = (pubkey: string) => {
|
||||||
|
const pubkeys = new Set(this.getFollows(pubkey))
|
||||||
|
const network = new Set<string>()
|
||||||
|
|
||||||
|
for (const follow of pubkeys) {
|
||||||
|
for (const tpk of this.getFollows(follow)) {
|
||||||
|
if (!pubkeys.has(tpk)) {
|
||||||
|
network.add(tpk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(network)
|
||||||
|
}
|
||||||
|
|
||||||
|
getFollowersByPubkey = () => this.getFollowersByPubkeyStore()
|
||||||
|
|
||||||
|
getMutersByPubkey = () => this.getMutersByPubkeyStore()
|
||||||
|
|
||||||
|
getFollowers = (pubkey: string) => Array.from(this.getFollowersByPubkey().get(pubkey) || [])
|
||||||
|
|
||||||
|
getMuters = (pubkey: string) => Array.from(this.getMutersByPubkey().get(pubkey) || [])
|
||||||
|
|
||||||
|
getFollowsWhoFollow = (pubkey: string, target: string) =>
|
||||||
|
this.getFollows(pubkey).filter(other => this.getFollows(other).includes(target))
|
||||||
|
|
||||||
|
getFollowsWhoMute = (pubkey: string, target: string) =>
|
||||||
|
this.getFollows(pubkey).filter(other => this.getMutes(other).includes(target))
|
||||||
|
|
||||||
|
getWotGraph = () => this.getWotGraphStore()
|
||||||
|
|
||||||
|
getMaxWot = () => this.getMaxWotStore()
|
||||||
|
|
||||||
|
buildGraph = throttle(1000, () => {
|
||||||
|
const $pubkey = this.ctx.user?.pubkey
|
||||||
|
const $graph = new Map<string, number>()
|
||||||
|
const $follows = $pubkey ? this.getFollows($pubkey) : Array.from(this.followLists.keys())
|
||||||
|
|
||||||
|
for (const follow of $follows) {
|
||||||
|
for (const pubkey of this.getFollows(follow)) {
|
||||||
|
$graph.set(pubkey, inc($graph.get(pubkey)))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pubkey of this.getMutes(follow)) {
|
||||||
|
$graph.set(pubkey, dec($graph.get(pubkey)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.wotGraph.set($graph)
|
||||||
|
})
|
||||||
|
|
||||||
|
getWotScore = (pubkey: string, target: string) => {
|
||||||
|
const follows = pubkey ? this.getFollowsWhoFollow(pubkey, target) : this.getFollowers(target)
|
||||||
|
const mutes = pubkey ? this.getFollowsWhoMute(pubkey, target) : this.getMuters(target)
|
||||||
|
|
||||||
|
return follows.length - mutes.length
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import {writable} from "svelte/store"
|
||||||
|
import {
|
||||||
|
removeUndefined,
|
||||||
|
fetchJson,
|
||||||
|
bech32ToHex,
|
||||||
|
hexToBech32,
|
||||||
|
tryCatch,
|
||||||
|
batcher,
|
||||||
|
postJson,
|
||||||
|
} from "@welshman/lib"
|
||||||
|
import {getTagValues, zapFromEvent} from "@welshman/util"
|
||||||
|
import type {Zapper, Zap, TrustedEvent} from "@welshman/util"
|
||||||
|
import {deriveDeduplicated} from "@welshman/store"
|
||||||
|
import {LoadableData} from "./clientData.js"
|
||||||
|
import type {ClientContext} from "./client.js"
|
||||||
|
import type {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> {
|
||||||
|
constructor(
|
||||||
|
ctx: ClientContext,
|
||||||
|
readonly profiles: Profiles,
|
||||||
|
readonly options: {dufflepudUrl?: string} = {},
|
||||||
|
) {
|
||||||
|
super(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use dufflepud if it's set up to protect user privacy, otherwise fetch directly
|
||||||
|
if (this.options.dufflepudUrl) {
|
||||||
|
const hexUrls = valid.map(bech32ToHex)
|
||||||
|
const res: any = await tryCatch(
|
||||||
|
async () =>
|
||||||
|
await postJson(`${this.options.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.profiles.load(pubkey, relays)
|
||||||
|
|
||||||
|
return $profile?.lnurl ? this.load($profile.lnurl) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
deriveForPubkey = (pubkey: string, relays: string[] = []) => {
|
||||||
|
this.loadForPubkey(pubkey, relays)
|
||||||
|
|
||||||
|
return deriveDeduplicated(
|
||||||
|
[this.index, this.profiles.derive(pubkey, relays)],
|
||||||
|
([$zappersByLnurl, $profile]) =>
|
||||||
|
$profile?.lnurl ? $zappersByLnurl.get($profile.lnurl) : undefined,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
getLnUrlsForEvent = async (event: TrustedEvent) => {
|
||||||
|
const pubkeys = getTagValues("zap", event.tags)
|
||||||
|
|
||||||
|
if (pubkeys.length > 0) {
|
||||||
|
const profiles = await Promise.all(pubkeys.map(pubkey => this.profiles.load(pubkey)))
|
||||||
|
const lnurls = removeUndefined(profiles.map(profile => profile?.lnurl))
|
||||||
|
|
||||||
|
if (lnurls.length > 0) {
|
||||||
|
return lnurls
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await this.profiles.load(event.pubkey)
|
||||||
|
|
||||||
|
return removeUndefined([profile?.lnurl])
|
||||||
|
}
|
||||||
|
|
||||||
|
getZapperForZap = async (zap: TrustedEvent, parent: TrustedEvent) => {
|
||||||
|
const lnurls = await this.getLnUrlsForEvent(parent)
|
||||||
|
|
||||||
|
return lnurls.length > 0 ? this.load(lnurls[0]) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
getValidZap = async (zap: TrustedEvent, parent: TrustedEvent) => {
|
||||||
|
const zapper = await this.getZapperForZap(zap, parent)
|
||||||
|
|
||||||
|
return zapper ? zapFromEvent(zap, zapper) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
getValidZaps = async (zaps: TrustedEvent[], parent: TrustedEvent) =>
|
||||||
|
removeUndefined(await Promise.all(zaps.map(zap => this.getValidZap(zap, parent))))
|
||||||
|
|
||||||
|
deriveValidZaps = (zaps: TrustedEvent[], parent: TrustedEvent) => {
|
||||||
|
const store = writable<Zap[]>([])
|
||||||
|
|
||||||
|
this.getValidZaps(zaps, parent).then(validZaps => {
|
||||||
|
store.set(validZaps)
|
||||||
|
})
|
||||||
|
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
}
|
||||||
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