Split up commands and add them to domain modules

This commit is contained in:
Jon Staab
2026-06-16 16:34:43 -07:00
parent 163d2dc355
commit abb9f20747
21 changed files with 526 additions and 496 deletions
+37 -3
View File
@@ -1,7 +1,19 @@
import {BLOCKED_RELAYS, asDecryptedEvent, readList, getRelaysFromList} from "@welshman/util"
import {
BLOCKED_RELAYS,
asDecryptedEvent,
readList,
getRelaysFromList,
makeList,
makeEvent,
addToListPublicly,
removeFromList,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js"
import {RelayLists} from "./relayLists.js"
import {Network} from "./network.js"
import {Router} from "./router.js"
import {User} from "./user.js"
import {Thunks} from "./thunk.js"
import type {IClient} from "./client.js"
/**
@@ -19,8 +31,30 @@ export class BlockedRelayLists extends RepositoryCollection<ReturnType<typeof re
}
fetch(pubkey: string, relayHints: string[] = []) {
return this.ctx.use(RelayLists).loadUsingOutbox(pubkey, {kinds: [BLOCKED_RELAYS]}, relayHints)
return this.ctx.use(Network).loadUsingOutbox(pubkey, {kinds: [BLOCKED_RELAYS]}, relayHints)
}
getBlockedRelays = (pubkey: string) => getRelaysFromList(this.get(pubkey))
addRelay = async (url: string) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: BLOCKED_RELAYS})
const event = await addToListPublicly(list, ["relay", url]).reconcile(user.nip44EncryptToSelf)
return this.ctx.use(Thunks).publishToOutbox({event})
}
removeRelay = async (url: string) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: BLOCKED_RELAYS})
const event = await removeFromList(list, url).reconcile(user.nip44EncryptToSelf)
return this.ctx.use(Thunks).publishToOutbox({event})
}
setRelays = (urls: string[]) =>
this.ctx.use(Thunks).publish({
event: makeEvent(BLOCKED_RELAYS, {tags: urls.map(url => ["relay", url])}),
relays: this.ctx.use(Router).FromUser().getUrls(),
})
}
+2 -2
View File
@@ -1,7 +1,7 @@
import {BLOSSOM_SERVERS, asDecryptedEvent, readList} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js"
import {RelayLists} from "./relayLists.js"
import {Network} from "./network.js"
import type {IClient} from "./client.js"
/**
@@ -18,6 +18,6 @@ export class BlossomServerLists extends RepositoryCollection<ReturnType<typeof r
}
fetch(pubkey: string, relayHints: string[] = []) {
return this.ctx.use(RelayLists).loadUsingOutbox(pubkey, {kinds: [BLOSSOM_SERVERS]}, relayHints)
return this.ctx.use(Network).loadUsingOutbox(pubkey, {kinds: [BLOSSOM_SERVERS]}, relayHints)
}
}
-382
View File
@@ -1,382 +0,0 @@
import {uniq, reject, nth, now, nthNe, removeUndefined, nthEq} from "@welshman/lib"
import {
sendManagementRequest,
addToListPublicly,
addToListPrivately,
updateList,
removeFromList,
makeHttpAuth,
getListTags,
getRelayTags,
getRelayTagValues,
getRelaysFromList,
makeList,
makeRoomCreateEvent,
makeRoomDeleteEvent,
makeRoomEditEvent,
makeRoomJoinEvent,
makeRoomLeaveEvent,
makeRoomAddMemberEvent,
makeRoomRemoveMemberEvent,
isPublishedProfile,
createProfile,
editProfile,
RelayMode,
makeEvent,
MESSAGING_RELAYS,
BLOCKED_RELAYS,
SEARCH_RELAYS,
FOLLOWS,
RELAYS,
MUTES,
PINS,
prep,
} from "@welshman/util"
import type {ManagementRequest, EventTemplate, RoomMeta, Profile} from "@welshman/util"
import {addMaximalFallbacks, Router} from "./router.js"
import {MergedThunk, publishThunk} from "./thunk.js"
import type {ThunkOptions} from "./thunk.js"
import type {IClient} from "./client.js"
import type {User} from "./user.js"
import {RelayLists} from "./relayLists.js"
import {MessagingRelayLists} from "./messagingRelayLists.js"
import {BlockedRelayLists} from "./blockedRelayLists.js"
import {SearchRelayLists} from "./searchRelayLists.js"
import {FollowLists} from "./follows.js"
import {MuteLists} from "./mutes.js"
import {PinLists} from "./pins.js"
export type SendWrappedOptions = Omit<
ThunkOptions,
"event" | "relays" | "recipient" | "client" | "user"
> & {
event: EventTemplate
recipients: string[]
}
/**
* The high-level "do an action" API: each method builds an event for the
* client's user and publishes it via a thunk. Siblings (the user's lists, the
* router) are resolved lazily through `ctx.use`; the acting user is `ctx.user`.
*/
export class Commands {
constructor(readonly ctx: IClient) {}
private get user(): User {
if (!this.ctx.user) {
throw new Error("Commands require a signed-in user")
}
return this.ctx.user
}
private get router() {
return this.ctx.use(Router)
}
private get relayLists() {
return this.ctx.use(RelayLists)
}
private get messagingRelayLists() {
return this.ctx.use(MessagingRelayLists)
}
private get blockedRelayLists() {
return this.ctx.use(BlockedRelayLists)
}
private get searchRelayLists() {
return this.ctx.use(SearchRelayLists)
}
private get followLists() {
return this.ctx.use(FollowLists)
}
private get muteLists() {
return this.ctx.use(MuteLists)
}
private get pinLists() {
return this.ctx.use(PinLists)
}
private publish = (options: Omit<ThunkOptions, "client" | "user">) =>
publishThunk({...options, client: this.ctx, user: this.user})
private fromUser = () => this.router.FromUser().policy(addMaximalFallbacks).getUrls()
private encryptToSelf = (payload: string) => this.user.nip44EncryptToSelf(payload)
// NIP 65
removeRelay = async (url: string, mode: RelayMode) => {
const list = (await this.relayLists.forceLoad(this.user.pubkey, [])) || makeList({kind: RELAYS})
const dup = getRelayTags(getListTags(list)).find(nthEq(1, url))
const alt = mode === RelayMode.Read ? RelayMode.Write : RelayMode.Read
const tags = list.publicTags.filter(nthNe(1, url))
// If we had a duplicate that was used as the alt mode, keep the alt
if (dup && (!dup[2] || dup[2] === alt)) {
tags.push(["r", url, alt])
}
const event = {kind: list.kind, content: list.event?.content || "", tags}
const relays = this.fromUser()
// Make sure to notify the old relay too
relays.push(url)
return this.publish({event, relays})
}
addRelay = async (url: string, mode: RelayMode) => {
const list = (await this.relayLists.forceLoad(this.user.pubkey, [])) || makeList({kind: RELAYS})
const dup = getRelayTags(getListTags(list)).find(nthEq(1, url))
const tag = removeUndefined(["r", url, dup && dup[2] !== mode ? undefined : mode])
const tags = [...list.publicTags.filter(nthNe(1, url)), tag]
const event = {kind: list.kind, content: list.event?.content || "", tags}
return this.publish({event, relays: this.fromUser()})
}
setRelays = async (tags: string[][]) => {
const event = makeEvent(RELAYS, {tags})
const relays = this.router
.merge([this.router.Index(), this.router.FromRelays(getRelayTagValues(tags))])
.getUrls()
return this.publish({event, relays})
}
setReadRelays = async (urls: string[]) => {
const list = (await this.relayLists.forceLoad(this.user.pubkey, [])) || makeList({kind: RELAYS})
const writeRelays = reject(nthEq(2, RelayMode.Read), getRelayTags(getListTags(list))).map(
nth(1),
)
const writeTags = writeRelays.map(url => ["r", url, RelayMode.Write])
const readTags = urls.map(url => ["r", url, RelayMode.Read])
const tags = [...writeTags, ...readTags]
const event = {kind: list.kind, content: list.event?.content || "", tags}
return this.publish({event, relays: this.fromUser()})
}
setWriteRelays = async (urls: string[]) => {
const list = (await this.relayLists.forceLoad(this.user.pubkey, [])) || makeList({kind: RELAYS})
const readRelays = reject(nthEq(2, RelayMode.Write), getRelayTags(getListTags(list))).map(
nth(1),
)
const readTags = readRelays.map(url => ["r", url, RelayMode.Read])
const writeTags = urls.map(url => ["r", url, RelayMode.Write])
const tags = [...readTags, ...writeTags]
const event = {kind: list.kind, content: list.event?.content || "", tags}
return this.publish({event, relays: this.fromUser()})
}
// NIP 17
removeMessagingRelay = async (url: string) => {
const list =
(await this.messagingRelayLists.forceLoad(this.user.pubkey, [])) ||
makeList({kind: MESSAGING_RELAYS})
const event = await removeFromList(list, url).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
addMessagingRelay = async (url: string) => {
const list =
(await this.messagingRelayLists.forceLoad(this.user.pubkey, [])) ||
makeList({kind: MESSAGING_RELAYS})
const event = await addToListPublicly(list, ["relay", url]).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
setMessagingRelays = async (urls: string[]) => {
const event = makeEvent(MESSAGING_RELAYS, {tags: urls.map(url => ["relay", url])})
return this.publish({event, relays: this.router.FromUser().getUrls()})
}
// Blocked Relays
removeBlockedRelay = async (url: string) => {
const list =
(await this.blockedRelayLists.forceLoad(this.user.pubkey, [])) ||
makeList({kind: BLOCKED_RELAYS})
const event = await removeFromList(list, url).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
addBlockedRelay = async (url: string) => {
const list =
(await this.blockedRelayLists.forceLoad(this.user.pubkey, [])) ||
makeList({kind: BLOCKED_RELAYS})
const event = await addToListPublicly(list, ["relay", url]).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
setBlockedRelays = async (urls: string[]) => {
const event = makeEvent(BLOCKED_RELAYS, {tags: urls.map(url => ["relay", url])})
return this.publish({event, relays: this.router.FromUser().getUrls()})
}
// Search Relays
removeSearchRelay = async (url: string) => {
const list =
(await this.searchRelayLists.forceLoad(this.user.pubkey, [])) ||
makeList({kind: SEARCH_RELAYS})
const event = await removeFromList(list, url).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
addSearchRelay = async (url: string) => {
const list =
(await this.searchRelayLists.forceLoad(this.user.pubkey, [])) ||
makeList({kind: SEARCH_RELAYS})
const event = await addToListPublicly(list, ["relay", url]).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
setSearchRelays = async (urls: string[]) => {
const event = makeEvent(SEARCH_RELAYS, {tags: urls.map(url => ["relay", url])})
return this.publish({event, relays: this.router.FromUser().getUrls()})
}
// NIP 01
setProfile = (profile: Profile) => {
const relays = this.router.merge([this.router.Index(), this.router.FromUser()]).getUrls()
const event = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile)
return this.publish({event, relays})
}
// NIP 02
unfollow = async (value: string) => {
const list =
(await this.followLists.forceLoad(this.user.pubkey, [])) || makeList({kind: FOLLOWS})
const event = await removeFromList(list, value).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
follow = async (tag: string[]) => {
const list =
(await this.followLists.forceLoad(this.user.pubkey, [])) || makeList({kind: FOLLOWS})
const event = await addToListPublicly(list, tag).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
unmute = async (value: string) => {
const list = (await this.muteLists.forceLoad(this.user.pubkey, [])) || makeList({kind: MUTES})
const event = await removeFromList(list, value).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
mutePublicly = async (tag: string[]) => {
const list = (await this.muteLists.forceLoad(this.user.pubkey, [])) || makeList({kind: MUTES})
const event = await addToListPublicly(list, tag).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
mutePrivately = async (tag: string[]) => {
const list = (await this.muteLists.forceLoad(this.user.pubkey, [])) || makeList({kind: MUTES})
const event = await addToListPrivately(list, tag).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
setMutes = async ({
publicTags,
privateTags,
}: {
publicTags?: string[][]
privateTags?: string[][]
}) => {
const list = (await this.muteLists.forceLoad(this.user.pubkey, [])) || makeList({kind: MUTES})
const event = await updateList(list, {publicTags, privateTags}).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
unpin = async (value: string) => {
const list = (await this.pinLists.forceLoad(this.user.pubkey, [])) || makeList({kind: PINS})
const event = await removeFromList(list, value).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
pin = async (tag: string[]) => {
const list = (await this.pinLists.forceLoad(this.user.pubkey, [])) || makeList({kind: PINS})
const event = await addToListPublicly(list, tag).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
// NIP 59
sendWrapped = async ({event, recipients, ...options}: SendWrappedOptions) => {
// Stabilize the event id across the different wraps
const stableEvent = prep(event, this.user.pubkey, now())
return new MergedThunk(
await Promise.all(
uniq(recipients).map(async recipient => {
const relays = getRelaysFromList(await this.messagingRelayLists.load(recipient))
return this.publish({event: stableEvent, relays, recipient, ...options})
}),
),
)
}
// NIP 86
manageRelay = async (url: string, request: ManagementRequest) => {
url = url.replace(/^ws/, "http")
const authTemplate = await makeHttpAuth(url, "POST", JSON.stringify(request))
const authEvent = await this.user.sign(authTemplate)
return sendManagementRequest(url, request, authEvent)
}
// NIP 29
createRoom = (url: string, room: RoomMeta) =>
this.publish({event: makeRoomCreateEvent(room), relays: [url]})
deleteRoom = (url: string, room: RoomMeta) =>
this.publish({event: makeRoomDeleteEvent(room), relays: [url]})
editRoom = (url: string, room: RoomMeta) =>
this.publish({event: makeRoomEditEvent(room), relays: [url]})
joinRoom = (url: string, room: RoomMeta) =>
this.publish({event: makeRoomJoinEvent(room), relays: [url]})
leaveRoom = (url: string, room: RoomMeta) =>
this.publish({event: makeRoomLeaveEvent(room), relays: [url]})
addRoomMember = (url: string, room: RoomMeta, pubkey: string) =>
this.publish({event: makeRoomAddMemberEvent(room, pubkey), relays: [url]})
removeRoomMember = (url: string, room: RoomMeta, pubkey: string) =>
this.publish({event: makeRoomRemoveMemberEvent(room, pubkey), relays: [url]})
}
+1 -1
View File
@@ -5,7 +5,7 @@ import {defaultClientPolicies} from "./policies.js"
/**
* Creates a batteries-included client: a `Client` wired with the default client
* policies (event ingestion, relay-stats collection, gift-wrap unwrapping).
* Reach data modules via `client.use(Profiles)`, `client.use(Commands)`, etc.
* Reach data modules via `client.use(Profiles)`, `client.use(FollowLists)`, etc.
*
* For a bare client (no default side effects) construct `new Client(...)`
* directly, or pass your own `policies`.
+28 -3
View File
@@ -1,7 +1,16 @@
import {FOLLOWS, asDecryptedEvent, readList} from "@welshman/util"
import {
FOLLOWS,
asDecryptedEvent,
readList,
makeList,
addToListPublicly,
removeFromList,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js"
import {RelayLists} from "./relayLists.js"
import {Network} from "./network.js"
import {Thunks} from "./thunk.js"
import {User} from "./user.js"
import type {IClient} from "./client.js"
/**
@@ -18,6 +27,22 @@ export class FollowLists extends RepositoryCollection<ReturnType<typeof readList
}
fetch(pubkey: string, relayHints: string[] = []) {
return this.ctx.use(RelayLists).loadUsingOutbox(pubkey, {kinds: [FOLLOWS]}, relayHints)
return this.ctx.use(Network).loadUsingOutbox(pubkey, {kinds: [FOLLOWS]}, relayHints)
}
follow = async (tag: string[]) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: FOLLOWS})
const event = await addToListPublicly(list, tag).reconcile(user.nip44EncryptToSelf)
return this.ctx.use(Thunks).publishToOutbox({event})
}
unfollow = async (value: string) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: FOLLOWS})
const event = await removeFromList(list, value).reconcile(user.nip44EncryptToSelf)
return this.ctx.use(Thunks).publishToOutbox({event})
}
}
+34 -3
View File
@@ -1,10 +1,22 @@
import {get, writable} from "svelte/store"
import {TaskQueue} from "@welshman/lib"
import {getPubkeyTagValues} from "@welshman/util"
import type {TrustedEvent, SignedEvent} from "@welshman/util"
import {TaskQueue, uniq, now} from "@welshman/lib"
import {getPubkeyTagValues, getRelaysFromList, prep} from "@welshman/util"
import type {TrustedEvent, SignedEvent, EventTemplate} from "@welshman/util"
import {Nip59} from "@welshman/signer"
import {MergedThunk, Thunks} from "./thunk.js"
import type {ThunkOptions} from "./thunk.js"
import {User} from "./user.js"
import {MessagingRelayLists} from "./messagingRelayLists.js"
import type {IClient} from "./client.js"
export type SendWrappedOptions = Omit<
ThunkOptions,
"event" | "relays" | "recipient" | "client" | "user"
> & {
event: EventTemplate
recipients: string[]
}
/**
* Per-client gift-wrap (NIP-59) state: the unwrap queue plus failure/dedup
* tracking. Scoped to `ctx.user`, so a client only ever unwraps its own user's
@@ -46,4 +58,23 @@ export class GiftWraps {
this.queue.push(wrap)
}
// NIP-59: wrap an event for each recipient (using their messaging relays) and
// publish the wraps as the client's user.
publish = async ({event, recipients, ...options}: SendWrappedOptions) => {
const user = User.require(this.ctx)
// Stabilize the event id across the different wraps
const stableEvent = prep(event, user.pubkey, now())
return new MergedThunk(
await Promise.all(
uniq(recipients).map(async recipient => {
const relays = getRelaysFromList(await this.ctx.use(MessagingRelayLists).load(recipient))
return this.ctx.use(Thunks).publish({event: stableEvent, relays, recipient, ...options})
}),
),
)
}
}
+3 -2
View File
@@ -1,6 +1,6 @@
export * from "./client.js"
export * from "./policies.js"
export * from "./networking.js"
export * from "./network.js"
export * from "./stores.js"
export * from "./clientData.js"
export * from "./repositoryCollection.js"
@@ -29,6 +29,7 @@ export * from "./feeds.js"
export * from "./search.js"
export * from "./sync.js"
export * from "./giftWraps.js"
export * from "./commands.js"
export * from "./rooms.js"
export * from "./relayManagement.js"
export * from "./thunk.js"
export * from "./createApp.js"
+36 -3
View File
@@ -1,7 +1,18 @@
import {MESSAGING_RELAYS, asDecryptedEvent, readList} from "@welshman/util"
import {
MESSAGING_RELAYS,
asDecryptedEvent,
readList,
makeList,
makeEvent,
addToListPublicly,
removeFromList,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js"
import {RelayLists} from "./relayLists.js"
import {Network} from "./network.js"
import {Router} from "./router.js"
import {User} from "./user.js"
import {Thunks} from "./thunk.js"
import type {IClient} from "./client.js"
/**
@@ -19,6 +30,28 @@ export class MessagingRelayLists extends RepositoryCollection<ReturnType<typeof
}
fetch(pubkey: string, relayHints: string[] = []) {
return this.ctx.use(RelayLists).loadUsingOutbox(pubkey, {kinds: [MESSAGING_RELAYS]}, relayHints)
return this.ctx.use(Network).loadUsingOutbox(pubkey, {kinds: [MESSAGING_RELAYS]}, relayHints)
}
addRelay = async (url: string) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: MESSAGING_RELAYS})
const event = await addToListPublicly(list, ["relay", url]).reconcile(user.nip44EncryptToSelf)
return this.ctx.use(Thunks).publishToOutbox({event})
}
removeRelay = async (url: string) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: MESSAGING_RELAYS})
const event = await removeFromList(list, url).reconcile(user.nip44EncryptToSelf)
return this.ctx.use(Thunks).publishToOutbox({event})
}
setRelays = (urls: string[]) =>
this.ctx.use(Thunks).publish({
event: makeEvent(MESSAGING_RELAYS, {tags: urls.map(url => ["relay", url])}),
relays: this.ctx.use(Router).FromUser().getUrls(),
})
}
+46 -3
View File
@@ -1,9 +1,20 @@
import {MUTES, asDecryptedEvent, readList} from "@welshman/util"
import {
MUTES,
asDecryptedEvent,
readList,
makeList,
addToListPublicly,
addToListPrivately,
removeFromList,
updateList,
} from "@welshman/util"
import type {TrustedEvent, PublishedList} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js"
import type {IClient} from "./client.js"
import {RelayLists} from "./relayLists.js"
import {Network} from "./network.js"
import {Thunks} from "./thunk.js"
import {Plaintext} from "./plaintext.js"
import {User} from "./user.js"
/**
* Kind-10000 mute lists, keyed by pubkey. Mute lists carry private entries in
@@ -33,6 +44,38 @@ export class MuteLists extends RepositoryCollection<PublishedList> {
}
fetch(pubkey: string, relayHints: string[] = []) {
return this.ctx.use(RelayLists).loadUsingOutbox(pubkey, {kinds: [MUTES]}, relayHints)
return this.ctx.use(Network).loadUsingOutbox(pubkey, {kinds: [MUTES]}, relayHints)
}
mutePublicly = async (tag: string[]) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: MUTES})
const event = await addToListPublicly(list, tag).reconcile(user.nip44EncryptToSelf)
return this.ctx.use(Thunks).publishToOutbox({event})
}
mutePrivately = async (tag: string[]) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: MUTES})
const event = await addToListPrivately(list, tag).reconcile(user.nip44EncryptToSelf)
return this.ctx.use(Thunks).publishToOutbox({event})
}
unmute = async (value: string) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: MUTES})
const event = await removeFromList(list, value).reconcile(user.nip44EncryptToSelf)
return this.ctx.use(Thunks).publishToOutbox({event})
}
setMutes = async (updates: {publicTags?: string[][]; privateTags?: string[][]}) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: MUTES})
const event = await updateList(list, updates).reconcile(user.nip44EncryptToSelf)
return this.ctx.use(Thunks).publishToOutbox({event})
}
}
@@ -1,3 +1,6 @@
import {chunk, first} from "@welshman/lib"
import {RelayMode, getRelaysFromList, sortEventsDesc} from "@welshman/util"
import type {Filter} from "@welshman/util"
import {request, publish, diff, pull, push, makeLoader} from "@welshman/net"
import type {
Loader,
@@ -8,19 +11,24 @@ import type {
PullOptions,
PushOptions,
} from "@welshman/net"
import {Router, addMinimalFallbacks} from "./router.js"
import {RelayLists} from "./relayLists.js"
import type {IClient} from "./client.js"
/**
* Net utilities bound to the client's net context (its pool + repository). Reach
* it via `client.use(Networking)`; `load` is a shared, batched loader.
* it via `client.use(Network)`; `load` is a shared, batched loader.
*/
export class Networking {
export class Network {
load: Loader
constructor(readonly ctx: IClient) {
this.load = this.makeLoader({delay: 200, timeout: 3000, threshold: 0.5})
this.load = this.makeLoader({delay: 50, timeout: 3000, threshold: 0.5})
}
makeLoader = (options: Omit<LoaderOptions, "context">): Loader =>
makeLoader({...options, context: this.ctx.netContext})
request = (options: Omit<RequestOptions, "context">) =>
request({...options, context: this.ctx.netContext})
@@ -33,6 +41,22 @@ export class Networking {
push = (options: Omit<PushOptions, "context">) => push({...options, context: this.ctx.netContext})
makeLoader = (options: Omit<LoaderOptions, "context">): Loader =>
makeLoader({...options, context: this.ctx.netContext})
loadUsingOutbox = async (pubkey: string, filter: Filter = {}, relayHints: string[] = []) => {
const filters: Filter[] = [{...filter, authors: [pubkey]}]
const writeRelays = getRelaysFromList(await this.ctx.use(RelayLists).load(pubkey), RelayMode.Write)
const allRelays = this.ctx
.use(Router)
.FromRelays([...relayHints, ...writeRelays])
.policy(addMinimalFallbacks)
.limit(8)
.getUrls()
for (const relays of chunk(2, allRelays)) {
const events = await this.load({filters, relays})
if (events.length > 0) {
return first(sortEventsDesc(events))
}
}
}
}
+28 -3
View File
@@ -1,7 +1,16 @@
import {PINS, asDecryptedEvent, readList} from "@welshman/util"
import {
PINS,
asDecryptedEvent,
readList,
makeList,
addToListPublicly,
removeFromList,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js"
import {RelayLists} from "./relayLists.js"
import {Network} from "./network.js"
import {Thunks} from "./thunk.js"
import {User} from "./user.js"
import type {IClient} from "./client.js"
/**
@@ -18,6 +27,22 @@ export class PinLists extends RepositoryCollection<ReturnType<typeof readList>>
}
fetch(pubkey: string, relayHints: string[] = []) {
return this.ctx.use(RelayLists).loadUsingOutbox(pubkey, {kinds: [PINS]}, relayHints)
return this.ctx.use(Network).loadUsingOutbox(pubkey, {kinds: [PINS]}, relayHints)
}
pin = async (tag: string[]) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: PINS})
const event = await addToListPublicly(list, tag).reconcile(user.nip44EncryptToSelf)
return this.ctx.use(Thunks).publishToOutbox({event})
}
unpin = async (value: string) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: PINS})
const event = await removeFromList(list, value).reconcile(user.nip44EncryptToSelf)
return this.ctx.use(Thunks).publishToOutbox({event})
}
}
+22 -3
View File
@@ -1,7 +1,18 @@
import {derived, readable} from "svelte/store"
import {readProfile, displayProfile, displayPubkey, PROFILE} from "@welshman/util"
import {
readProfile,
displayProfile,
displayPubkey,
isPublishedProfile,
createProfile,
editProfile,
PROFILE,
} from "@welshman/util"
import type {Profile} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js"
import {RelayLists} from "./relayLists.js"
import {Network} from "./network.js"
import {Router} from "./router.js"
import {Thunks} from "./thunk.js"
import type {IClient} from "./client.js"
/**
@@ -18,7 +29,15 @@ export class Profiles extends RepositoryCollection<ReturnType<typeof readProfile
}
fetch(pubkey: string, relayHints: string[] = []) {
return this.ctx.use(RelayLists).loadUsingOutbox(pubkey, {kinds: [PROFILE]}, relayHints)
return this.ctx.use(Network).loadUsingOutbox(pubkey, {kinds: [PROFILE]}, relayHints)
}
publish = (profile: Profile) => {
const router = this.ctx.use(Router)
const relays = router.merge([router.Index(), router.FromUser()]).getUrls()
const event = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile)
return this.ctx.use(Thunks).publish({event, relays})
}
display = (pubkey: string | undefined) =>
+72 -26
View File
@@ -1,22 +1,27 @@
import {chunk, first} from "@welshman/lib"
import {reject, nth, nthNe, nthEq, removeUndefined} from "@welshman/lib"
import {
RELAYS,
RelayMode,
asDecryptedEvent,
readList,
getRelaysFromList,
sortEventsDesc,
getRelayTags,
getListTags,
getRelayTagValues,
makeList,
makeEvent,
} from "@welshman/util"
import type {Filter, TrustedEvent, PublishedList} from "@welshman/util"
import type {TrustedEvent, PublishedList} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js"
import {Router, addMinimalFallbacks} from "./router.js"
import {Networking} from "./networking.js"
import {Router} from "./router.js"
import {Network} from "./network.js"
import {User} from "./user.js"
import {Thunks} from "./thunk.js"
import type {IClient} from "./client.js"
/**
* NIP-65 relay lists, keyed by pubkey. This is the routing substrate every other
* outbox-model load depends on, so it also exposes `loadUsingOutbox` for other
* collections to use as their fetcher.
* outbox-model load depends on (see `Network.loadUsingOutbox`).
*/
export class RelayLists extends RepositoryCollection<PublishedList> {
constructor(ctx: IClient) {
@@ -29,7 +34,7 @@ export class RelayLists extends RepositoryCollection<PublishedList> {
fetch(pubkey: string, relayHints: string[] = []) {
const filters = [{kinds: [RELAYS], authors: [pubkey], limit: 1}]
const networking = this.ctx.use(Networking)
const networking = this.ctx.use(Network)
const router = this.ctx.use(Router)
return Promise.all([
@@ -42,27 +47,68 @@ export class RelayLists extends RepositoryCollection<PublishedList> {
getRelaysForPubkey = (pubkey: string, mode?: RelayMode) =>
getRelaysFromList(this.get(pubkey), mode)
// Load a pubkey's events using their advertised write relays (outbox model),
// plus any explicit relay hints. This is the fetcher outbox-loaded collections
// (profiles, follows, mutes, …) delegate to — it's a stable method, so calling
// it doesn't build a fresh loader per call.
// NIP-65 relay-list mutations for the client's user
loadUsingOutbox = async (pubkey: string, filter: Filter = {}, relayHints: string[] = []) => {
const filters: Filter[] = [{...filter, authors: [pubkey]}]
const writeRelays = getRelaysFromList(await this.load(pubkey), RelayMode.Write)
const allRelays = this.ctx
.use(Router)
.FromRelays([...relayHints, ...writeRelays])
.policy(addMinimalFallbacks)
.limit(8)
addRelay = async (url: string, mode: RelayMode) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: RELAYS})
const dup = getRelayTags(getListTags(list)).find(nthEq(1, url))
const tag = removeUndefined(["r", url, dup && dup[2] !== mode ? undefined : mode])
const tags = [...list.publicTags.filter(nthNe(1, url)), tag]
const event = {kind: list.kind, content: list.event?.content || "", tags}
return this.ctx.use(Thunks).publishToOutbox({event})
}
removeRelay = async (url: string, mode: RelayMode) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: RELAYS})
const dup = getRelayTags(getListTags(list)).find(nthEq(1, url))
const alt = mode === RelayMode.Read ? RelayMode.Write : RelayMode.Read
const tags = list.publicTags.filter(nthNe(1, url))
// If we had a duplicate that was used as the alt mode, keep the alt
if (dup && (!dup[2] || dup[2] === alt)) {
tags.push(["r", url, alt])
}
const event = {kind: list.kind, content: list.event?.content || "", tags}
// Pass the old relay as an extra so it's notified of the removal too
return this.ctx.use(Thunks).publishToOutbox({event, relays: [url]})
}
setRelays = (tags: string[][]) => {
const router = this.ctx.use(Router)
const event = makeEvent(RELAYS, {tags})
const relays = router
.merge([router.Index(), router.FromRelays(getRelayTagValues(tags))])
.getUrls()
for (const relays of chunk(2, allRelays)) {
const events = await this.ctx.use(Networking).load({filters, relays})
return this.ctx.use(Thunks).publish({event, relays})
}
if (events.length > 0) {
return first(sortEventsDesc(events))
}
}
setReadRelays = async (urls: string[]) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: RELAYS})
const writeRelays = reject(nthEq(2, RelayMode.Read), getRelayTags(getListTags(list))).map(nth(1))
const writeTags = writeRelays.map(url => ["r", url, RelayMode.Write])
const readTags = urls.map(url => ["r", url, RelayMode.Read])
const tags = [...writeTags, ...readTags]
const event = {kind: list.kind, content: list.event?.content || "", tags}
return this.ctx.use(Thunks).publishToOutbox({event})
}
setWriteRelays = async (urls: string[]) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: RELAYS})
const readRelays = reject(nthEq(2, RelayMode.Write), getRelayTags(getListTags(list))).map(nth(1))
const readTags = readRelays.map(url => ["r", url, RelayMode.Read])
const writeTags = urls.map(url => ["r", url, RelayMode.Write])
const tags = [...readTags, ...writeTags]
const event = {kind: list.kind, content: list.event?.content || "", tags}
return this.ctx.use(Thunks).publishToOutbox({event})
}
}
+21
View File
@@ -0,0 +1,21 @@
import {makeHttpAuth, sendManagementRequest} from "@welshman/util"
import type {ManagementRequest} from "@welshman/util"
import {User} from "./user.js"
import type {IClient} from "./client.js"
/**
* NIP-86 relay management. Signs an HTTP-auth event as the client's user and
* sends an admin request to a relay's management endpoint.
*/
export class RelayManagement {
constructor(readonly ctx: IClient) {}
post = async (url: string, request: ManagementRequest) => {
url = url.replace(/^ws/, "http")
const authTemplate = await makeHttpAuth(url, "POST", JSON.stringify(request))
const authEvent = await User.require(this.ctx).sign(authTemplate)
return sendManagementRequest(url, request, authEvent)
}
}
+40
View File
@@ -0,0 +1,40 @@
import {
makeRoomCreateEvent,
makeRoomDeleteEvent,
makeRoomEditEvent,
makeRoomJoinEvent,
makeRoomLeaveEvent,
makeRoomAddMemberEvent,
makeRoomRemoveMemberEvent,
} from "@welshman/util"
import type {RoomMeta} from "@welshman/util"
import {Thunks} from "./thunk.js"
import type {ThunkOptions} from "./thunk.js"
import type {IClient} from "./client.js"
/**
* NIP-29 relay-based group (room) management. Each method publishes the relevant
* room event to the given relay as the client's user.
*/
export class Rooms {
constructor(readonly ctx: IClient) {}
private publish = (url: string, event: ThunkOptions["event"]) =>
this.ctx.use(Thunks).publish({event, relays: [url]})
create = (url: string, room: RoomMeta) => this.publish(url, makeRoomCreateEvent(room))
delete = (url: string, room: RoomMeta) => this.publish(url, makeRoomDeleteEvent(room))
edit = (url: string, room: RoomMeta) => this.publish(url, makeRoomEditEvent(room))
join = (url: string, room: RoomMeta) => this.publish(url, makeRoomJoinEvent(room))
leave = (url: string, room: RoomMeta) => this.publish(url, makeRoomLeaveEvent(room))
addMember = (url: string, room: RoomMeta, pubkey: string) =>
this.publish(url, makeRoomAddMemberEvent(room, pubkey))
removeMember = (url: string, room: RoomMeta, pubkey: string) =>
this.publish(url, makeRoomRemoveMemberEvent(room, pubkey))
}
+2 -2
View File
@@ -8,7 +8,7 @@ import {PROFILE} from "@welshman/util"
import type {PublishedProfile, RelayProfile} from "@welshman/util"
import {throttled, deriveItems} from "@welshman/store"
import type {IClient} from "./client.js"
import {Networking} from "./networking.js"
import {Network} from "./network.js"
import {Router} from "./router.js"
import {Profiles} from "./profiles.js"
import {Topics} from "./topics.js"
@@ -119,7 +119,7 @@ export class Searches {
searchProfiles = debounce(500, (search: string) => {
if (search.length > 2) {
this.ctx.use(Networking).load({
this.ctx.use(Network).load({
filters: [{kinds: [PROFILE], search}],
relays: this.ctx.use(Router).Search().getUrls(),
})
+36 -3
View File
@@ -1,7 +1,18 @@
import {SEARCH_RELAYS, asDecryptedEvent, readList} from "@welshman/util"
import {
SEARCH_RELAYS,
asDecryptedEvent,
readList,
makeList,
makeEvent,
addToListPublicly,
removeFromList,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js"
import {RelayLists} from "./relayLists.js"
import {Network} from "./network.js"
import {Router} from "./router.js"
import {User} from "./user.js"
import {Thunks} from "./thunk.js"
import type {IClient} from "./client.js"
/**
@@ -19,6 +30,28 @@ export class SearchRelayLists extends RepositoryCollection<ReturnType<typeof rea
}
fetch(pubkey: string, relayHints: string[] = []) {
return this.ctx.use(RelayLists).loadUsingOutbox(pubkey, {kinds: [SEARCH_RELAYS]}, relayHints)
return this.ctx.use(Network).loadUsingOutbox(pubkey, {kinds: [SEARCH_RELAYS]}, relayHints)
}
addRelay = async (url: string) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: SEARCH_RELAYS})
const event = await addToListPublicly(list, ["relay", url]).reconcile(user.nip44EncryptToSelf)
return this.ctx.use(Thunks).publishToOutbox({event})
}
removeRelay = async (url: string) => {
const user = User.require(this.ctx)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: SEARCH_RELAYS})
const event = await removeFromList(list, url).reconcile(user.nip44EncryptToSelf)
return this.ctx.use(Thunks).publishToOutbox({event})
}
setRelays = (urls: string[]) =>
this.ctx.use(Thunks).publish({
event: makeEvent(SEARCH_RELAYS, {tags: urls.map(url => ["relay", url])}),
relays: this.ctx.use(Router).FromUser().getUrls(),
})
}
+3 -3
View File
@@ -1,7 +1,7 @@
import {isSignedEvent} from "@welshman/util"
import type {Filter, SignedEvent} from "@welshman/util"
import type {IClient} from "./client.js"
import {Networking} from "./networking.js"
import {Network} from "./network.js"
import {Relays} from "./relays.js"
export type AppSyncOpts = {
@@ -32,7 +32,7 @@ export class Sync {
}
pull = async ({relays, filters}: AppSyncOpts) => {
const net = this.ctx.use(Networking)
const net = this.ctx.use(Network)
const events = this.query(filters).filter(isSignedEvent)
await Promise.all(
@@ -45,7 +45,7 @@ export class Sync {
}
push = async ({relays, filters}: AppSyncOpts) => {
const net = this.ctx.use(Networking)
const net = this.ctx.use(Network)
const events = this.query(filters).filter(isSignedEvent)
await Promise.all(
+72 -49
View File
@@ -1,7 +1,7 @@
import type {Subscriber} from "svelte/store"
import {writable} from "svelte/store"
import type {Override} from "@welshman/lib"
import {append, TaskQueue, ensurePlural, remove, defer, sleep, nth, without} from "@welshman/lib"
import {append, TaskQueue, ensurePlural, remove, defer, sleep, nth, uniq, without} from "@welshman/lib"
import {
HashedEvent,
EventTemplate,
@@ -14,8 +14,9 @@ import {
import {PublishStatus, PublishResult, PublishOptions, PublishResultsByRelay} from "@welshman/net"
import {Nip01Signer, Nip59} from "@welshman/signer"
import type {IClient} from "./client.js"
import {Networking} from "./networking.js"
import type {User} from "./user.js"
import {Network} from "./network.js"
import {Router, addMaximalFallbacks} from "./router.js"
import {User} from "./user.js"
export type ThunkOptions = Override<
PublishOptions,
@@ -112,7 +113,7 @@ export class Thunk {
}
// Send it off
await this.options.client.use(Networking).publish({
await this.options.client.use(Network).publish({
...this.options,
event,
onSuccess: (result: PublishResult) => {
@@ -205,27 +206,6 @@ export class Thunk {
}
}
enqueue() {
thunkQueue.push(this)
for (const url of this.options.relays) {
this.options.client.tracker.track(this.event.id, url)
}
this.options.client.repository.publish(this.event)
thunks.update($thunks => append(this, $thunks))
this.controller.signal.addEventListener("abort", () => {
if (this.wrap) {
this.options.client.wrapManager.remove(this.wrap.id)
} else {
this.options.client.repository.removeEvent(this.event.id)
}
thunks.update($thunks => remove(this, $thunks))
})
}
subscribe(subscriber: Subscriber<Thunk>) {
this._subs.push(subscriber)
@@ -362,18 +342,6 @@ export const waitForThunkCompletion = (thunk: Thunk) =>
})
})
// Thunk state
export const thunks = writable<Thunk[]>([])
export const thunkQueue = new TaskQueue<Thunk>({
batchSize: 10,
batchDelay: 100,
processItem: (thunk: Thunk) => {
thunk.publish()
},
})
// Other thunk utilities
export const mergeThunks = (thunks: AbstractThunk[]) =>
@@ -389,21 +357,76 @@ export function* flattenThunks(thunks: AbstractThunk[]): Iterable<Thunk> {
}
}
export const publishThunk = (options: ThunkOptions) => {
const thunk = new Thunk(options)
thunk.enqueue()
return thunk
}
export const abortThunk = (thunk: AbstractThunk) => {
for (const child of flattenThunks([thunk])) {
child.controller.abort()
}
}
export const retryThunk = (thunk: AbstractThunk) =>
isMergedThunk(thunk)
? mergeThunks(thunk.thunks.map(t => publishThunk(t.options)))
: publishThunk(thunk.options)
/**
* Per-client thunk manager — the publish-side counterpart of `Network`. Owns
* the client's optimistic-publish `history` store and the `queue` that paces
* publishing. Reach it via `client.use(Thunks)`; `publish` fills in the client
* and user, enqueues the thunk (optimistically writing it to the repository),
* and returns it.
*/
export class Thunks {
history = writable<Thunk[]>([])
queue = new TaskQueue<Thunk>({
batchSize: 10,
batchDelay: 100,
processItem: (thunk: Thunk) => {
thunk.publish()
},
})
constructor(readonly ctx: IClient) {}
publish = (options: Omit<ThunkOptions, "client" | "user">) => {
const thunk = new Thunk({...options, client: this.ctx, user: User.require(this.ctx)})
this.enqueue(thunk)
return thunk
}
// Publish as the user to their outbox (write) relays, plus any extra `relays`.
publishToOutbox = ({
relays = [],
...options
}: Omit<ThunkOptions, "client" | "user" | "relays"> & {relays?: string[]}) =>
this.publish({
...options,
relays: uniq([
...relays,
...this.ctx.use(Router).FromUser().policy(addMaximalFallbacks).getUrls(),
]),
})
retry = (thunk: AbstractThunk) =>
isMergedThunk(thunk)
? mergeThunks(thunk.thunks.map(t => this.publish(t.options)))
: this.publish(thunk.options)
private enqueue(thunk: Thunk) {
this.queue.push(thunk)
for (const url of thunk.options.relays) {
this.ctx.tracker.track(thunk.event.id, url)
}
this.ctx.repository.publish(thunk.event)
this.history.update($history => append(thunk, $history))
thunk.controller.signal.addEventListener("abort", () => {
if (thunk.wrap) {
this.ctx.wrapManager.remove(thunk.wrap.id)
} else {
this.ctx.repository.removeEvent(thunk.event.id)
}
this.history.update($history => remove(thunk, $history))
})
}
}
+13
View File
@@ -3,6 +3,7 @@ import type {ISigner} from "@welshman/signer"
import {LoggingSigner} from "./logging.js"
import {getSignerFromSession} from "./session.js"
import type {Session} from "./session.js"
import type {IClient} from "./client.js"
/**
* A single identity: a pubkey plus the signer that proves it. A `Client` is
@@ -38,6 +39,18 @@ export class User {
return signer ? User.fromSigner(signer) : undefined
}
/**
* Return the client's signed-in user, throwing if there isn't one — the entry
* point for actions that can only run as a user (publishing, signing).
*/
static require(ctx: IClient): User {
if (!ctx.user) {
throw new Error("This action requires a signed-in user")
}
return ctx.user
}
sign = (event: StampedEvent) => this.signer.sign(event)
nip44EncryptToSelf = (payload: string) => this.signer.nip44.encrypt(this.pubkey, payload)