From abb9f207472e173c39ab4d4489c1c6c31ee47a6f Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Tue, 16 Jun 2026 16:34:43 -0700 Subject: [PATCH] Split up commands and add them to domain modules --- .fdignore | 1 + packages/client/src/blockedRelayLists.ts | 40 +- packages/client/src/blossom.ts | 4 +- packages/client/src/commands.ts | 382 ------------------ packages/client/src/createApp.ts | 2 +- packages/client/src/follows.ts | 31 +- packages/client/src/giftWraps.ts | 37 +- packages/client/src/index.ts | 5 +- packages/client/src/messagingRelayLists.ts | 39 +- packages/client/src/mutes.ts | 49 ++- .../client/src/{networking.ts => network.ts} | 34 +- packages/client/src/pins.ts | 31 +- packages/client/src/profiles.ts | 25 +- packages/client/src/relayLists.ts | 98 +++-- packages/client/src/relayManagement.ts | 21 + packages/client/src/rooms.ts | 40 ++ packages/client/src/search.ts | 4 +- packages/client/src/searchRelayLists.ts | 39 +- packages/client/src/sync.ts | 6 +- packages/client/src/thunk.ts | 121 +++--- packages/client/src/user.ts | 13 + 21 files changed, 526 insertions(+), 496 deletions(-) delete mode 100644 packages/client/src/commands.ts rename packages/client/src/{networking.ts => network.ts} (50%) create mode 100644 packages/client/src/relayManagement.ts create mode 100644 packages/client/src/rooms.ts diff --git a/.fdignore b/.fdignore index 691ae4c..51f8eb8 100644 --- a/.fdignore +++ b/.fdignore @@ -2,4 +2,5 @@ node_modules docs docs/reference docs/.vitepress/cache +dist build diff --git a/packages/client/src/blockedRelayLists.ts b/packages/client/src/blockedRelayLists.ts index ce940d2..1aafa64 100644 --- a/packages/client/src/blockedRelayLists.ts +++ b/packages/client/src/blockedRelayLists.ts @@ -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 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(), + }) } diff --git a/packages/client/src/blossom.ts b/packages/client/src/blossom.ts index c624db6..d5282cc 100644 --- a/packages/client/src/blossom.ts +++ b/packages/client/src/blossom.ts @@ -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 & { - 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) => - 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]}) -} diff --git a/packages/client/src/createApp.ts b/packages/client/src/createApp.ts index 458bb30..ea607ba 100644 --- a/packages/client/src/createApp.ts +++ b/packages/client/src/createApp.ts @@ -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`. diff --git a/packages/client/src/follows.ts b/packages/client/src/follows.ts index d665db9..56dd66e 100644 --- a/packages/client/src/follows.ts +++ b/packages/client/src/follows.ts @@ -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 { + 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}) } } diff --git a/packages/client/src/giftWraps.ts b/packages/client/src/giftWraps.ts index a4e71b2..3c914f7 100644 --- a/packages/client/src/giftWraps.ts +++ b/packages/client/src/giftWraps.ts @@ -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}) + }), + ), + ) + } } diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index e9292e4..3bda5a1 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -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" diff --git a/packages/client/src/messagingRelayLists.ts b/packages/client/src/messagingRelayLists.ts index 8997ce1..3aafd29 100644 --- a/packages/client/src/messagingRelayLists.ts +++ b/packages/client/src/messagingRelayLists.ts @@ -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 { + 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(), + }) } diff --git a/packages/client/src/mutes.ts b/packages/client/src/mutes.ts index 19f804e..db15877 100644 --- a/packages/client/src/mutes.ts +++ b/packages/client/src/mutes.ts @@ -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 { } 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}) } } diff --git a/packages/client/src/networking.ts b/packages/client/src/network.ts similarity index 50% rename from packages/client/src/networking.ts rename to packages/client/src/network.ts index 4eb5b64..1351200 100644 --- a/packages/client/src/networking.ts +++ b/packages/client/src/network.ts @@ -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): Loader => + makeLoader({...options, context: this.ctx.netContext}) + request = (options: Omit) => request({...options, context: this.ctx.netContext}) @@ -33,6 +41,22 @@ export class Networking { push = (options: Omit) => push({...options, context: this.ctx.netContext}) - makeLoader = (options: Omit): 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)) + } + } + } } diff --git a/packages/client/src/pins.ts b/packages/client/src/pins.ts index 0e454f6..75443a7 100644 --- a/packages/client/src/pins.ts +++ b/packages/client/src/pins.ts @@ -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> } 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}) } } diff --git a/packages/client/src/profiles.ts b/packages/client/src/profiles.ts index 166d83d..a55ff7c 100644 --- a/packages/client/src/profiles.ts +++ b/packages/client/src/profiles.ts @@ -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 { + 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) => diff --git a/packages/client/src/relayLists.ts b/packages/client/src/relayLists.ts index 904ea08..972cd9b 100644 --- a/packages/client/src/relayLists.ts +++ b/packages/client/src/relayLists.ts @@ -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 { constructor(ctx: IClient) { @@ -29,7 +34,7 @@ export class RelayLists extends RepositoryCollection { 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 { 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}) } } diff --git a/packages/client/src/relayManagement.ts b/packages/client/src/relayManagement.ts new file mode 100644 index 0000000..be298c5 --- /dev/null +++ b/packages/client/src/relayManagement.ts @@ -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) + } +} diff --git a/packages/client/src/rooms.ts b/packages/client/src/rooms.ts new file mode 100644 index 0000000..be6d5ed --- /dev/null +++ b/packages/client/src/rooms.ts @@ -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)) +} diff --git a/packages/client/src/search.ts b/packages/client/src/search.ts index 952173b..c35cb5b 100644 --- a/packages/client/src/search.ts +++ b/packages/client/src/search.ts @@ -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(), }) diff --git a/packages/client/src/searchRelayLists.ts b/packages/client/src/searchRelayLists.ts index 5fad540..67b5a6b 100644 --- a/packages/client/src/searchRelayLists.ts +++ b/packages/client/src/searchRelayLists.ts @@ -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 { + 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(), + }) } diff --git a/packages/client/src/sync.ts b/packages/client/src/sync.ts index 48eac4a..2154bf7 100644 --- a/packages/client/src/sync.ts +++ b/packages/client/src/sync.ts @@ -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( diff --git a/packages/client/src/thunk.ts b/packages/client/src/thunk.ts index 7aa8966..201d2bd 100644 --- a/packages/client/src/thunk.ts +++ b/packages/client/src/thunk.ts @@ -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) { this._subs.push(subscriber) @@ -362,18 +342,6 @@ export const waitForThunkCompletion = (thunk: Thunk) => }) }) -// Thunk state - -export const thunks = writable([]) - -export const thunkQueue = new TaskQueue({ - 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 { } } -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([]) + + queue = new TaskQueue({ + batchSize: 10, + batchDelay: 100, + processItem: (thunk: Thunk) => { + thunk.publish() + }, + }) + + constructor(readonly ctx: IClient) {} + + publish = (options: Omit) => { + 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 & {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)) + }) + } +} diff --git a/packages/client/src/user.ts b/packages/client/src/user.ts index 74295ef..63cfaf7 100644 --- a/packages/client/src/user.ts +++ b/packages/client/src/user.ts @@ -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)