Refine domain, integrate into app
tests / tests (push) Failing after 5m14s

This commit is contained in:
Jon Staab
2026-06-19 12:50:34 -07:00
parent 1bd62d3024
commit e2a6ef21cd
115 changed files with 1354 additions and 3176 deletions
+2
View File
@@ -25,6 +25,7 @@
},
"peerDependencies": {
"@pomade/core": "^0.2.1",
"@welshman/domain": "workspace:*",
"@welshman/feeds": "workspace:*",
"@welshman/lib": "workspace:*",
"@welshman/net": "workspace:*",
@@ -39,6 +40,7 @@
"typescript": "~5.8.0",
"@pomade/core": "^0.2.1",
"@types/throttle-debounce": "^5.0.2",
"@welshman/domain": "workspace:*",
"@welshman/feeds": "workspace:*",
"@welshman/lib": "workspace:*",
"@welshman/net": "workspace:*",
+15 -30
View File
@@ -1,18 +1,8 @@
import {
BLOCKED_RELAYS,
asDecryptedEvent,
readList,
getRelaysFromList,
makeList,
makeEvent,
addToListPublicly,
removeFromList,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {BLOCKED_RELAYS} from "@welshman/util"
import {BlockedRelayList, BlockedRelayListBuilder} from "@welshman/domain"
import {DerivedPlugin} from "./base.js"
import type {Projection} from "./base.js"
import {Network} from "./network.js"
import {Router} from "./router.js"
import {User} from "../user.js"
import {Thunks} from "./thunk.js"
import type {IApp} from "../app.js"
@@ -22,12 +12,12 @@ import type {IApp} from "../app.js"
* so it depends on the relay-list collection. Feeds `RelayStats.getQuality` so
* blocked relays are never selected.
*/
export class BlockedRelayLists extends DerivedPlugin<ReturnType<typeof readList>> {
export class BlockedRelayLists extends DerivedPlugin<BlockedRelayList> {
constructor(app: IApp) {
super(app, {
filters: [{kinds: [BLOCKED_RELAYS]}],
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
getKey: list => list.event.pubkey,
eventToItem: BlockedRelayList.factory(app.user?.signer),
getKey: list => list.author(),
})
}
@@ -36,27 +26,22 @@ export class BlockedRelayLists extends DerivedPlugin<ReturnType<typeof readList>
}
urls = (pubkey: string): Projection<string[]> =>
this.project(pubkey, list => getRelaysFromList(list))
this.project(pubkey, list => list?.urls() ?? [])
addRelay = async (url: string) => {
update = async (fn: (builder: BlockedRelayListBuilder) => void) => {
const user = User.require(this.app)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: BLOCKED_RELAYS})
const event = await addToListPublicly(list, ["relay", url]).reconcile(user.nip44EncryptToSelf)
const builder = new BlockedRelayListBuilder(await this.forceLoad(user.pubkey))
fn(builder)
const event = await builder.toTemplate(user.signer)
return this.app.use(Thunks).publishToOutbox({event})
}
removeRelay = async (url: string) => {
const user = User.require(this.app)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: BLOCKED_RELAYS})
const event = await removeFromList(list, url).reconcile(user.nip44EncryptToSelf)
addUrl = (url: string) => this.update(builder => builder.addUrl(url))
return this.app.use(Thunks).publishToOutbox({event})
}
removeUrl = (url: string) => this.update(builder => builder.removeUrl(url))
setRelays = (urls: string[]) =>
this.app.use(Thunks).publish({
event: makeEvent(BLOCKED_RELAYS, {tags: urls.map(url => ["relay", url])}),
relays: this.app.use(Router).FromUser().getUrls(),
})
setUrls = (urls: string[]) => this.update(builder => builder.setUrls(urls))
}
+5 -5
View File
@@ -1,5 +1,5 @@
import {BLOSSOM_SERVERS, asDecryptedEvent, readList} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {BLOSSOM_SERVERS} from "@welshman/util"
import {BlossomServerList} from "@welshman/domain"
import {DerivedPlugin} from "./base.js"
import {Network} from "./network.js"
import type {IApp} from "../app.js"
@@ -8,12 +8,12 @@ import type {IApp} from "../app.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 DerivedPlugin<ReturnType<typeof readList>> {
export class BlossomServerLists extends DerivedPlugin<BlossomServerList> {
constructor(app: IApp) {
super(app, {
filters: [{kinds: [BLOSSOM_SERVERS]}],
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
getKey: list => list.event.pubkey,
eventToItem: BlossomServerList.factory(app.user?.signer),
getKey: list => list.author(),
})
}
+13 -21
View File
@@ -1,12 +1,5 @@
import {
FOLLOWS,
asDecryptedEvent,
readList,
makeList,
addToListPublicly,
removeFromList,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {FOLLOWS} from "@welshman/util"
import {FollowList, FollowListBuilder} from "@welshman/domain"
import {DerivedPlugin} from "./base.js"
import {Network} from "./network.js"
import {Thunks} from "./thunk.js"
@@ -17,12 +10,12 @@ import type {IApp} from "../app.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 DerivedPlugin<ReturnType<typeof readList>> {
export class FollowLists extends DerivedPlugin<FollowList> {
constructor(app: IApp) {
super(app, {
filters: [{kinds: [FOLLOWS]}],
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
getKey: followList => followList.event.pubkey,
eventToItem: FollowList.factory(app.user?.signer),
getKey: followList => followList.author(),
})
}
@@ -30,19 +23,18 @@ export class FollowLists extends DerivedPlugin<ReturnType<typeof readList>> {
return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [FOLLOWS]}, relayHints)
}
follow = async (tag: string[]) => {
update = async (fn: (builder: FollowListBuilder) => void) => {
const user = User.require(this.app)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: FOLLOWS})
const event = await addToListPublicly(list, tag).reconcile(user.nip44EncryptToSelf)
const builder = new FollowListBuilder(await this.forceLoad(user.pubkey))
fn(builder)
const event = await builder.toTemplate(user.signer)
return this.app.use(Thunks).publishToOutbox({event})
}
unfollow = async (value: string) => {
const user = User.require(this.app)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: FOLLOWS})
const event = await removeFromList(list, value).reconcile(user.nip44EncryptToSelf)
follow = (tag: string[]) => this.update(builder => builder.addPublic(tag))
return this.app.use(Thunks).publishToOutbox({event})
}
unfollow = (value: string) => this.update(builder => builder.removeFollow(value))
}
+9 -4
View File
@@ -2,6 +2,7 @@ import {tryCatch, batcher, postJson} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import {queryProfile, displayNip05} from "@welshman/util"
import type {Handle} from "@welshman/util"
import type {Profile} from "@welshman/domain"
import {deriveDeduplicated} from "@welshman/store"
import {LoadableMapPlugin, projection} from "./base.js"
import type {Projection} from "./base.js"
@@ -60,16 +61,20 @@ export class Handles extends LoadableMapPlugin<Handle> {
loadForPubkey = async (pubkey: string, relays: string[] = []) => {
const $profile = await this.app.use(Profiles).load(pubkey, relays)
return $profile?.nip05 ? this.load($profile.nip05) : undefined
const nip05 = $profile?.nip05()
return nip05 ? this.load(nip05) : undefined
}
forPubkey = (pubkey: string, relays: string[] = []): Projection<Maybe<Handle>> => {
this.loadForPubkey(pubkey, relays)
const read = ([$handlesByNip05, $profile]: [ReadonlyMap<string, Handle>, Maybe<{nip05?: string}>]) => {
if (!$profile?.nip05) return undefined
const read = ([$handlesByNip05, $profile]: [ReadonlyMap<string, Handle>, Maybe<Profile>]) => {
const nip05 = $profile?.nip05()
const handle = $handlesByNip05.get($profile.nip05)
if (!nip05) return undefined
const handle = $handlesByNip05.get(nip05)
if (handle?.pubkey !== pubkey) return undefined
+15 -30
View File
@@ -1,18 +1,8 @@
import {
MESSAGING_RELAYS,
asDecryptedEvent,
readList,
getRelaysFromList,
makeList,
makeEvent,
addToListPublicly,
removeFromList,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {MESSAGING_RELAYS} from "@welshman/util"
import {MessagingRelayList, MessagingRelayListBuilder} from "@welshman/domain"
import {DerivedPlugin} from "./base.js"
import type {Projection} from "./base.js"
import {Network} from "./network.js"
import {Router} from "./router.js"
import {User} from "../user.js"
import {Thunks} from "./thunk.js"
import type {IApp} from "../app.js"
@@ -22,12 +12,12 @@ import type {IApp} from "../app.js"
* outbox model (the author's write relays), so it depends on the relay-list
* collection.
*/
export class MessagingRelayLists extends DerivedPlugin<ReturnType<typeof readList>> {
export class MessagingRelayLists extends DerivedPlugin<MessagingRelayList> {
constructor(app: IApp) {
super(app, {
filters: [{kinds: [MESSAGING_RELAYS]}],
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
getKey: list => list.event.pubkey,
eventToItem: MessagingRelayList.factory(app.user?.signer),
getKey: list => list.author(),
})
}
@@ -36,27 +26,22 @@ export class MessagingRelayLists extends DerivedPlugin<ReturnType<typeof readLis
}
urls = (pubkey: string): Projection<string[]> =>
this.project(pubkey, list => getRelaysFromList(list))
this.project(pubkey, list => list?.urls() ?? [])
addRelay = async (url: string) => {
update = async (fn: (builder: MessagingRelayListBuilder) => void) => {
const user = User.require(this.app)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: MESSAGING_RELAYS})
const event = await addToListPublicly(list, ["relay", url]).reconcile(user.nip44EncryptToSelf)
const builder = new MessagingRelayListBuilder(await this.forceLoad(user.pubkey))
fn(builder)
const event = await builder.toTemplate(user.signer)
return this.app.use(Thunks).publishToOutbox({event})
}
removeRelay = async (url: string) => {
const user = User.require(this.app)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: MESSAGING_RELAYS})
const event = await removeFromList(list, url).reconcile(user.nip44EncryptToSelf)
addUrl = (url: string) => this.update(builder => builder.addUrl(url))
return this.app.use(Thunks).publishToOutbox({event})
}
removeUrl = (url: string) => this.update(builder => builder.removeUrl(url))
setRelays = (urls: string[]) =>
this.app.use(Thunks).publish({
event: makeEvent(MESSAGING_RELAYS, {tags: urls.map(url => ["relay", url])}),
relays: this.app.use(Router).FromUser().getUrls(),
})
setUrls = (urls: string[]) => this.update(builder => builder.setUrls(urls))
}
+56 -43
View File
@@ -1,14 +1,8 @@
import {
MUTES,
asDecryptedEvent,
readList,
makeList,
addToListPublicly,
addToListPrivately,
removeFromList,
updateList,
} from "@welshman/util"
import type {TrustedEvent, PublishedList} from "@welshman/util"
import {nthEq} from "@welshman/lib"
import {MUTES} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import type {ISigner} from "@welshman/signer"
import {MuteList, MuteListBuilder} from "@welshman/domain"
import {DerivedPlugin} from "./base.js"
import type {IApp} from "../app.js"
import {Network} from "./network.js"
@@ -17,19 +11,47 @@ import {Plaintext} from "./plaintext.js"
import {User} from "../user.js"
/**
* Kind-10000 mute lists, keyed by pubkey. Mute lists carry private entries in
* encrypted content, so decoding goes through the plaintext cache.
* A signer that decrypts via the app's plaintext cache (keyed by event), falling
* back to the real signer. Lets `MuteList.fromEvent(event, signer)` reuse cached
* decryptions instead of re-decrypting. Returns undefined when there's no user,
* so the reader falls back to public-only.
*/
export class MuteLists extends DerivedPlugin<PublishedList> {
const makeCachedSigner = (app: IApp, event: TrustedEvent): ISigner | undefined => {
const user = app.user
if (!user) return undefined
const {signer} = user
const decryptVia =
(fallback: (pubkey: string, message: string) => Promise<string>) =>
async (pubkey: string, message: string) =>
(await app.use(Plaintext).ensure(event)) ?? fallback(pubkey, message)
return {
sign: (event, options) => signer.sign(event, options),
getPubkey: () => signer.getPubkey(),
nip04: {
encrypt: (pubkey, message) => signer.nip04.encrypt(pubkey, message),
decrypt: decryptVia((pubkey, message) => signer.nip04.decrypt(pubkey, message)),
},
nip44: {
encrypt: (pubkey, message) => signer.nip44.encrypt(pubkey, message),
decrypt: decryptVia((pubkey, message) => signer.nip44.decrypt(pubkey, message)),
},
}
}
/**
* Kind-10000 mute lists, keyed by pubkey. Mute lists carry private entries in
* encrypted content, decoded through the plaintext cache (via a cache-backed
* signer passed to the reader).
*/
export class MuteLists extends DerivedPlugin<MuteList> {
constructor(app: IApp) {
super(app, {
filters: [{kinds: [MUTES]}],
eventToItem: async (event: TrustedEvent) => {
const content = await app.use(Plaintext).ensure(event)
return readList(asDecryptedEvent(event, {content}))
},
getKey: mute => mute.event.pubkey,
eventToItem: event => MuteList.fromEvent(event, makeCachedSigner(app, event)),
getKey: mute => mute.author(),
})
}
@@ -37,35 +59,26 @@ export class MuteLists extends DerivedPlugin<PublishedList> {
return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [MUTES]}, relayHints)
}
mutePublicly = async (tag: string[]) => {
update = async (fn: (builder: MuteListBuilder) => void) => {
const user = User.require(this.app)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: MUTES})
const event = await addToListPublicly(list, tag).reconcile(user.nip44EncryptToSelf)
const builder = new MuteListBuilder(await this.forceLoad(user.pubkey))
fn(builder)
const event = await builder.toTemplate(user.signer)
return this.app.use(Thunks).publishToOutbox({event})
}
mutePrivately = async (tag: string[]) => {
const user = User.require(this.app)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: MUTES})
const event = await addToListPrivately(list, tag).reconcile(user.nip44EncryptToSelf)
mutePublicly = (tag: string[]) => this.update(builder => builder.addPublic(tag))
return this.app.use(Thunks).publishToOutbox({event})
}
mutePrivately = (tag: string[]) => this.update(builder => builder.addPrivate(tag))
unmute = async (value: string) => {
const user = User.require(this.app)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: MUTES})
const event = await removeFromList(list, value).reconcile(user.nip44EncryptToSelf)
unmute = (value: string) => this.update(builder => builder.drop(nthEq(1, value)))
return this.app.use(Thunks).publishToOutbox({event})
}
setMutes = async (updates: {publicTags?: string[][]; privateTags?: string[][]}) => {
const user = User.require(this.app)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: MUTES})
const event = await updateList(list, updates).reconcile(user.nip44EncryptToSelf)
return this.app.use(Thunks).publishToOutbox({event})
}
setMutes = (updates: {publicTags?: string[][]; privateTags?: string[][]}) =>
this.update(builder => {
if (updates.publicTags) builder.clearPublic().addPublic(...updates.publicTags)
if (updates.privateTags) builder.clearPrivate().addPrivate(...updates.privateTags)
})
}
+2 -2
View File
@@ -1,5 +1,5 @@
import {chunk, first} from "@welshman/lib"
import {RelayMode, getRelaysFromList, sortEventsDesc} from "@welshman/util"
import {sortEventsDesc} from "@welshman/util"
import type {Filter} from "@welshman/util"
import {request, publish, diff, pull, push, makeLoader} from "@welshman/net"
import type {
@@ -44,7 +44,7 @@ export class Network {
loadUsingOutbox = async (pubkey: string, filter: Filter = {}, relayHints: string[] = []) => {
const filters: Filter[] = [{...filter, authors: [pubkey]}]
const writeRelays = getRelaysFromList(await this.app.use(RelayLists).load(pubkey), RelayMode.Write)
const writeRelays = (await this.app.use(RelayLists).load(pubkey))?.writeUrls() ?? []
const allRelays = this.app
.use(Router)
.FromRelays([...relayHints, ...writeRelays])
+13 -21
View File
@@ -1,12 +1,5 @@
import {
PINS,
asDecryptedEvent,
readList,
makeList,
addToListPublicly,
removeFromList,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {PINS} from "@welshman/util"
import {PinList, PinListBuilder} from "@welshman/domain"
import {DerivedPlugin} from "./base.js"
import {Network} from "./network.js"
import {Thunks} from "./thunk.js"
@@ -17,12 +10,12 @@ import type {IApp} from "../app.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 DerivedPlugin<ReturnType<typeof readList>> {
export class PinLists extends DerivedPlugin<PinList> {
constructor(app: IApp) {
super(app, {
filters: [{kinds: [PINS]}],
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
getKey: pins => pins.event.pubkey,
eventToItem: PinList.factory(app.user?.signer),
getKey: pins => pins.author(),
})
}
@@ -30,19 +23,18 @@ export class PinLists extends DerivedPlugin<ReturnType<typeof readList>> {
return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [PINS]}, relayHints)
}
pin = async (tag: string[]) => {
update = async (fn: (builder: PinListBuilder) => void) => {
const user = User.require(this.app)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: PINS})
const event = await addToListPublicly(list, tag).reconcile(user.nip44EncryptToSelf)
const builder = new PinListBuilder(await this.forceLoad(user.pubkey))
fn(builder)
const event = await builder.toTemplate(user.signer)
return this.app.use(Thunks).publishToOutbox({event})
}
unpin = async (value: string) => {
const user = User.require(this.app)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: PINS})
const event = await removeFromList(list, value).reconcile(user.nip44EncryptToSelf)
pin = (tag: string[]) => this.update(builder => builder.pinPublicly(tag))
return this.app.use(Thunks).publishToOutbox({event})
}
unpin = (value: string) => this.update(builder => builder.unpin(value))
}
+14 -17
View File
@@ -1,32 +1,25 @@
import {derived, readable} from "svelte/store"
import {
readProfile,
displayProfile,
displayPubkey,
isPublishedProfile,
createProfile,
editProfile,
PROFILE,
} from "@welshman/util"
import type {Profile} from "@welshman/util"
import {PROFILE} from "@welshman/util"
import type {Maybe} from "@welshman/lib"
import {Profile, ProfileBuilder, displayPubkey} from "@welshman/domain"
import {DerivedPlugin, projection} from "./base.js"
import type {Projection} from "./base.js"
import {Network} from "./network.js"
import {Router} from "./router.js"
import {Thunks} from "./thunk.js"
import {User} from "../user.js"
import type {IApp} from "../app.js"
/**
* Kind-0 profiles, keyed by pubkey. Loaded via the outbox model (the author's
* write relays), resolved through the relay-list collection at fetch time.
*/
export class Profiles extends DerivedPlugin<ReturnType<typeof readProfile>> {
export class Profiles extends DerivedPlugin<Profile> {
constructor(app: IApp) {
super(app, {
filters: [{kinds: [PROFILE]}],
eventToItem: readProfile,
getKey: profile => profile.event.pubkey,
eventToItem: Profile.factory(app.user?.signer),
getKey: profile => profile.author(),
})
}
@@ -34,17 +27,21 @@ export class Profiles extends DerivedPlugin<ReturnType<typeof readProfile>> {
return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [PROFILE]}, relayHints)
}
publish = (profile: Profile) => {
// Publish the app user's kind-0, merging `values` over their current profile
// (preserving any unknown metadata fields and source tags).
publish = async (values: Record<string, any>) => {
const user = User.require(this.app)
const router = this.app.use(Router)
const relays = router.merge([router.Index(), router.FromUser()]).getUrls()
const event = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile)
const builder = new ProfileBuilder(this.get(user.pubkey)).update(values)
const event = await builder.toTemplate()
return this.app.use(Thunks).publish({event, relays})
}
display = (pubkey: string | undefined, ...args: any[]): Projection<string> => {
const read = ($profile: Maybe<ReturnType<typeof readProfile>>) =>
pubkey ? displayProfile($profile, displayPubkey(pubkey)) : ""
const read = ($profile: Maybe<Profile>) =>
pubkey ? ($profile?.display() ?? displayPubkey(pubkey)) : ""
return projection(
pubkey ? derived(this.one(pubkey, ...args), read) : readable(""),
+26 -63
View File
@@ -1,17 +1,5 @@
import {reject, nth, nthNe, nthEq, removeUndefined} from "@welshman/lib"
import {
RELAYS,
RelayMode,
asDecryptedEvent,
readList,
getRelaysFromList,
getRelayTags,
getListTags,
getRelayTagValues,
makeList,
makeEvent,
} from "@welshman/util"
import type {TrustedEvent, PublishedList} from "@welshman/util"
import {RELAYS, RelayMode, getRelayTagValues} from "@welshman/util"
import {RelayList, RelayListBuilder} from "@welshman/domain"
import {DerivedPlugin} from "./base.js"
import type {Projection} from "./base.js"
import {addMinimalFallbacks} from "@welshman/router"
@@ -25,12 +13,12 @@ import type {IApp} from "../app.js"
* NIP-65 relay lists, keyed by pubkey. This is the routing substrate every other
* outbox-model load depends on (see `Network.loadUsingOutbox`).
*/
export class RelayLists extends DerivedPlugin<PublishedList> {
export class RelayLists extends DerivedPlugin<RelayList> {
constructor(app: IApp) {
super(app, {
filters: [{kinds: [RELAYS]}],
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
getKey: (list: PublishedList) => list.event.pubkey,
eventToItem: RelayList.factory(app.user?.signer),
getKey: (list: RelayList) => list.author(),
})
}
@@ -47,40 +35,37 @@ export class RelayLists extends DerivedPlugin<PublishedList> {
}
urls = (pubkey: string): Projection<string[]> =>
this.project(pubkey, list => getRelaysFromList(list))
this.project(pubkey, list => list?.urls() ?? [])
readUrls = (pubkey: string): Projection<string[]> =>
this.project(pubkey, list => getRelaysFromList(list, RelayMode.Read))
this.project(pubkey, list => list?.readUrls() ?? [])
writeUrls = (pubkey: string): Projection<string[]> =>
this.project(pubkey, list => getRelaysFromList(list, RelayMode.Write))
this.project(pubkey, list => list?.writeUrls() ?? [])
// NIP-65 relay-list mutations for the app's user
addRelay = async (url: string, mode: RelayMode) => {
update = async (fn: (builder: RelayListBuilder) => void) => {
const user = User.require(this.app)
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}
const builder = new RelayListBuilder(await this.forceLoad(user.pubkey))
fn(builder)
const event = await builder.toTemplate(user.signer)
return this.app.use(Thunks).publishToOutbox({event})
}
addRelay = (url: string, mode: RelayMode) => this.update(builder => builder.addUrl(url, mode))
setReadRelays = (urls: string[]) => this.update(builder => builder.setReadUrls(urls))
setWriteRelays = (urls: string[]) => this.update(builder => builder.setWriteUrls(urls))
removeRelay = async (url: string, mode: RelayMode) => {
const user = User.require(this.app)
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}
const builder = new RelayListBuilder(await this.forceLoad(user.pubkey))
const event = await builder.removeUrl(url, mode).toTemplate(user.signer)
// publishToOutbox is outbox-only, so build relays here to also notify the
// removed relay of its removal
@@ -89,37 +74,15 @@ export class RelayLists extends DerivedPlugin<PublishedList> {
return this.app.use(Thunks).publish({event, relays})
}
setRelays = (tags: string[][]) => {
setRelays = async (tags: string[][]) => {
const user = User.require(this.app)
const router = this.app.use(Router)
const event = makeEvent(RELAYS, {tags})
const builder = new RelayListBuilder(await this.forceLoad(user.pubkey))
const event = await builder.setTags(tags).toTemplate(user.signer)
const relays = router
.merge([router.Index(), router.FromRelays(getRelayTagValues(tags))])
.getUrls()
return this.app.use(Thunks).publish({event, relays})
}
setReadRelays = async (urls: string[]) => {
const user = User.require(this.app)
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.app.use(Thunks).publishToOutbox({event})
}
setWriteRelays = async (urls: string[]) => {
const user = User.require(this.app)
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.app.use(Thunks).publishToOutbox({event})
}
}
+53 -18
View File
@@ -1,17 +1,30 @@
import {
makeRoomCreateEvent,
makeRoomDeleteEvent,
makeRoomEditEvent,
makeRoomJoinEvent,
makeRoomLeaveEvent,
makeRoomAddMemberEvent,
makeRoomRemoveMemberEvent,
} from "@welshman/util"
import type {RoomMeta} from "@welshman/util"
RoomCreateBuilder,
RoomDeleteBuilder,
RoomEditBuilder,
RoomJoinBuilder,
RoomLeaveBuilder,
RoomAddMemberBuilder,
RoomRemoveMemberBuilder,
} from "@welshman/domain"
import {Thunks} from "./thunk.js"
import type {ThunkOptions} from "./thunk.js"
import type {IApp} from "../app.js"
// Room metadata used when publishing NIP-29 room events. `h` is the group id.
export type RoomMeta = {
h: string
name?: string
about?: string
picture?: string
pictureMeta?: string[]
isClosed?: boolean
isHidden?: boolean
isPrivate?: boolean
isRestricted?: boolean
livekit?: boolean
}
/**
* NIP-29 relay-based group (room) management. Each method publishes the relevant
* room event to the given relay as the app's user.
@@ -22,19 +35,41 @@ export class Rooms {
private publish = (url: string, event: ThunkOptions["event"]) =>
this.app.use(Thunks).publish({event, relays: [url]})
create = (url: string, room: RoomMeta) => this.publish(url, makeRoomCreateEvent(room))
create = async (url: string, room: RoomMeta) =>
this.publish(url, await new RoomCreateBuilder().setGroup(room.h).toTemplate())
delete = (url: string, room: RoomMeta) => this.publish(url, makeRoomDeleteEvent(room))
delete = async (url: string, room: RoomMeta) =>
this.publish(url, await new RoomDeleteBuilder().setGroup(room.h).toTemplate())
edit = (url: string, room: RoomMeta) => this.publish(url, makeRoomEditEvent(room))
edit = async (url: string, room: RoomMeta) => {
const builder = new RoomEditBuilder().setGroup(room.h)
join = (url: string, room: RoomMeta) => this.publish(url, makeRoomJoinEvent(room))
if (room.name) builder.setName(room.name)
if (room.about) builder.setAbout(room.about)
if (room.picture) builder.setPicture(room.picture, room.pictureMeta)
leave = (url: string, room: RoomMeta) => this.publish(url, makeRoomLeaveEvent(room))
builder
.setClosed(Boolean(room.isClosed))
.setHidden(Boolean(room.isHidden))
.setPrivate(Boolean(room.isPrivate))
.setRestricted(Boolean(room.isRestricted))
.setLivekit(Boolean(room.livekit))
addMember = (url: string, room: RoomMeta, pubkey: string) =>
this.publish(url, makeRoomAddMemberEvent(room, pubkey))
return this.publish(url, await builder.toTemplate())
}
removeMember = (url: string, room: RoomMeta, pubkey: string) =>
this.publish(url, makeRoomRemoveMemberEvent(room, pubkey))
join = async (url: string, room: RoomMeta) =>
this.publish(url, await new RoomJoinBuilder().setGroup(room.h).toTemplate())
leave = async (url: string, room: RoomMeta) =>
this.publish(url, await new RoomLeaveBuilder().setGroup(room.h).toTemplate())
addMember = async (url: string, room: RoomMeta, pubkey: string) =>
this.publish(url, await new RoomAddMemberBuilder().setGroup(room.h).addPubkey(pubkey).toTemplate())
removeMember = async (url: string, room: RoomMeta, pubkey: string) =>
this.publish(
url,
await new RoomRemoveMemberBuilder().setGroup(room.h).addPubkey(pubkey).toTemplate(),
)
}
+23 -15
View File
@@ -5,7 +5,8 @@ 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 type {RelayProfile} from "@welshman/util"
import type {Profile} from "@welshman/domain"
import {throttled} from "@welshman/store"
import type {IApp} from "../app.js"
import {Network} from "./network.js"
@@ -63,26 +64,19 @@ export const createSearch = <V, T>(options: T[], opts: SearchOptions<V, T>): Sea
* fires a debounced NIP-50 network search through the app's loader.
*/
export class Searches {
profileSearch: Readable<Search<string, PublishedProfile>>
profileSearch: Readable<Search<string, Profile>>
topicSearch: Readable<Search<string, Topic>>
relaySearch: Readable<Search<string, RelayProfile>>
constructor(readonly app: IApp) {
this.profileSearch = derived(
[throttled(800, this.app.use(Profiles).all.$), throttled(800, this.app.use(Handles).index.$)],
([$profiles, $handlesByNip05]) => {
// Remove invalid nip05's from profiles
const options = $profiles.map(p => {
const isNip05Valid = !p.nip05 || $handlesByNip05.get(p.nip05)?.pubkey === p.event.pubkey
return isNip05Valid ? p : {...p, nip05: ""}
})
return createSearch(options, {
([$profiles, $handlesByNip05]) =>
createSearch($profiles, {
onSearch: this.searchProfiles,
getValue: (profile: PublishedProfile) => profile.event.pubkey,
getValue: (profile: Profile) => profile.author(),
sortFn: ({score = 1, item}) => {
const wotScore = this.app.use(Wot).graph.get().get(item.event.pubkey) || 0
const wotScore = this.app.use(Wot).graph.get().get(item.author()) || 0
return dec(score) * inc(wotScore / (this.app.use(Wot).max.get() || 1))
},
@@ -95,9 +89,23 @@ export class Searches {
],
threshold: 0.3,
shouldSort: false,
// Read fields off the domain reader's parsed `values`; only expose a
// nip05 that's verified against the loaded handle (anti-spoofing).
getFn: (profile: Profile, path) => {
const key = Array.isArray(path) ? path[0] : path
if (key === "nip05") {
const nip05 = profile.nip05()
return nip05 && $handlesByNip05.get(nip05)?.pubkey === profile.author()
? nip05
: ""
}
return profile.values[key] ?? ""
},
},
})
},
}),
)
this.topicSearch = derived(this.app.use(Topics).all, $topics =>
+15 -30
View File
@@ -1,18 +1,8 @@
import {
SEARCH_RELAYS,
asDecryptedEvent,
readList,
getRelaysFromList,
makeList,
makeEvent,
addToListPublicly,
removeFromList,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {SEARCH_RELAYS} from "@welshman/util"
import {SearchRelayList, SearchRelayListBuilder} from "@welshman/domain"
import {DerivedPlugin} from "./base.js"
import type {Projection} from "./base.js"
import {Network} from "./network.js"
import {Router} from "./router.js"
import {User} from "../user.js"
import {Thunks} from "./thunk.js"
import type {IApp} from "../app.js"
@@ -22,12 +12,12 @@ import type {IApp} from "../app.js"
* outbox model (the author's write relays), so it depends on the relay-list
* collection.
*/
export class SearchRelayLists extends DerivedPlugin<ReturnType<typeof readList>> {
export class SearchRelayLists extends DerivedPlugin<SearchRelayList> {
constructor(app: IApp) {
super(app, {
filters: [{kinds: [SEARCH_RELAYS]}],
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
getKey: searchRelayList => searchRelayList.event.pubkey,
eventToItem: SearchRelayList.factory(app.user?.signer),
getKey: list => list.author(),
})
}
@@ -36,27 +26,22 @@ export class SearchRelayLists extends DerivedPlugin<ReturnType<typeof readList>>
}
urls = (pubkey: string): Projection<string[]> =>
this.project(pubkey, list => getRelaysFromList(list))
this.project(pubkey, list => list?.urls() ?? [])
addRelay = async (url: string) => {
update = async (fn: (builder: SearchRelayListBuilder) => void) => {
const user = User.require(this.app)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: SEARCH_RELAYS})
const event = await addToListPublicly(list, ["relay", url]).reconcile(user.nip44EncryptToSelf)
const builder = new SearchRelayListBuilder(await this.forceLoad(user.pubkey))
fn(builder)
const event = await builder.toTemplate(user.signer)
return this.app.use(Thunks).publishToOutbox({event})
}
removeRelay = async (url: string) => {
const user = User.require(this.app)
const list = (await this.forceLoad(user.pubkey)) || makeList({kind: SEARCH_RELAYS})
const event = await removeFromList(list, url).reconcile(user.nip44EncryptToSelf)
addUrl = (url: string) => this.update(builder => builder.addUrl(url))
return this.app.use(Thunks).publishToOutbox({event})
}
removeUrl = (url: string) => this.update(builder => builder.removeUrl(url))
setRelays = (urls: string[]) =>
this.app.use(Thunks).publish({
event: makeEvent(SEARCH_RELAYS, {tags: urls.map(url => ["relay", url])}),
relays: this.app.use(Router).FromUser().getUrls(),
})
setUrls = (urls: string[]) => this.update(builder => builder.setUrls(urls))
}
+27 -25
View File
@@ -1,15 +1,12 @@
import {readable, derived} from "svelte/store"
import {max, throttle, addToMapKey, inc, dec} from "@welshman/lib"
import {getListTags, getPubkeyTagValues} from "@welshman/util"
import type {List} from "@welshman/util"
import type {FollowList, MuteList} from "@welshman/domain"
import type {IApp} from "../app.js"
import {projection, projectFrom} from "./base.js"
import type {Projection} from "./base.js"
import {FollowLists} from "./follows.js"
import {MuteLists} from "./mutes.js"
const listPubkeys = (list: List | undefined) => getPubkeyTagValues(getListTags(list))
/**
* Web-of-trust scoring derived from follow and mute lists. The trust graph is
* built from the perspective of the app's user (or, with no user, the union
@@ -32,8 +29,8 @@ export class Wot {
const $followersByPubkey = new Map<string, Set<string>>()
for (const list of lists.values()) {
for (const pubkey of getPubkeyTagValues(getListTags(list))) {
addToMapKey($followersByPubkey, pubkey, list.event.pubkey)
for (const pubkey of list?.pubkeys() ?? []) {
addToMapKey($followersByPubkey, pubkey, list.author())
}
}
@@ -48,8 +45,8 @@ export class Wot {
const $mutersByPubkey = new Map<string, Set<string>>()
for (const list of lists.values()) {
for (const pubkey of getPubkeyTagValues(getListTags(list))) {
addToMapKey($mutersByPubkey, pubkey, list.event.pubkey)
for (const pubkey of list?.pubkeys() ?? []) {
addToMapKey($mutersByPubkey, pubkey, list.author())
}
}
@@ -64,14 +61,16 @@ export class Wot {
const $muteLists = this.app.use(MuteLists).index.get()
const $pubkey = this.app.user?.pubkey
const $graph = new Map<string, number>()
const roots = $pubkey ? listPubkeys($followLists.get($pubkey)) : Array.from($followLists.keys())
const roots = $pubkey
? ($followLists.get($pubkey)?.pubkeys() ?? [])
: Array.from($followLists.keys())
for (const follow of roots) {
for (const pubkey of listPubkeys($followLists.get(follow))) {
for (const pubkey of $followLists.get(follow)?.pubkeys() ?? []) {
$graph.set(pubkey, inc($graph.get(pubkey)))
}
for (const pubkey of listPubkeys($muteLists.get(follow))) {
for (const pubkey of $muteLists.get(follow)?.pubkeys() ?? []) {
$graph.set(pubkey, dec($graph.get(pubkey)))
}
}
@@ -96,18 +95,18 @@ export class Wot {
}
follows = (pubkey: string): Projection<string[]> =>
projectFrom(this.app.use(FollowLists).index, $lists => listPubkeys($lists.get(pubkey)))
projectFrom(this.app.use(FollowLists).index, $lists => $lists.get(pubkey)?.pubkeys() ?? [])
mutes = (pubkey: string): Projection<string[]> =>
projectFrom(this.app.use(MuteLists).index, $lists => listPubkeys($lists.get(pubkey)))
projectFrom(this.app.use(MuteLists).index, $lists => $lists.get(pubkey)?.pubkeys() ?? [])
network = (pubkey: string): Projection<string[]> =>
projectFrom(this.app.use(FollowLists).index, $lists => {
const pubkeys = new Set(listPubkeys($lists.get(pubkey)))
const pubkeys = new Set($lists.get(pubkey)?.pubkeys() ?? [])
const network = new Set<string>()
for (const follow of pubkeys) {
for (const tpk of listPubkeys($lists.get(follow))) {
for (const tpk of $lists.get(follow)?.pubkeys() ?? []) {
if (!pubkeys.has(tpk)) {
network.add(tpk)
}
@@ -125,15 +124,18 @@ export class Wot {
followsWhoFollow = (pubkey: string, target: string): Projection<string[]> =>
projectFrom(this.app.use(FollowLists).index, $lists =>
listPubkeys($lists.get(pubkey)).filter(other =>
listPubkeys($lists.get(other)).includes(target),
($lists.get(pubkey)?.pubkeys() ?? []).filter(other =>
($lists.get(other)?.pubkeys() ?? []).includes(target),
),
)
followsWhoMute = (pubkey: string, target: string): Projection<string[]> => {
const read = ($follows: ReadonlyMap<string, List>, $mutes: ReadonlyMap<string, List>) =>
listPubkeys($follows.get(pubkey)).filter(other =>
listPubkeys($mutes.get(other)).includes(target),
const read = (
$follows: ReadonlyMap<string, FollowList>,
$mutes: ReadonlyMap<string, MuteList>,
) =>
($follows.get(pubkey)?.pubkeys() ?? []).filter(other =>
($mutes.get(other)?.pubkeys() ?? []).includes(target),
)
return projection(
@@ -147,8 +149,8 @@ export class Wot {
wotScore = (pubkey: string, target: string): Projection<number> => {
const read = (
$follows: ReadonlyMap<string, List>,
$mutes: ReadonlyMap<string, List>,
$follows: ReadonlyMap<string, FollowList>,
$mutes: ReadonlyMap<string, MuteList>,
$followers: ReadonlyMap<string, Set<string>>,
$muters: ReadonlyMap<string, Set<string>>,
) => {
@@ -156,10 +158,10 @@ export class Wot {
let mutes: string[]
if (pubkey) {
const theirFollows = listPubkeys($follows.get(pubkey))
const theirFollows = $follows.get(pubkey)?.pubkeys() ?? []
follows = theirFollows.filter(other => listPubkeys($follows.get(other)).includes(target))
mutes = theirFollows.filter(other => listPubkeys($mutes.get(other)).includes(target))
follows = theirFollows.filter(other => ($follows.get(other)?.pubkeys() ?? []).includes(target))
mutes = theirFollows.filter(other => ($mutes.get(other)?.pubkeys() ?? []).includes(target))
} else {
follows = Array.from($followers.get(target) || [])
mutes = Array.from($muters.get(target) || [])
+2 -2
View File
@@ -1,6 +1,6 @@
import {get, writable} from "svelte/store"
import {TaskQueue, uniq, now} from "@welshman/lib"
import {getPubkeyTagValues, getRelaysFromList, prep} from "@welshman/util"
import {getPubkeyTagValues, prep} from "@welshman/util"
import type {TrustedEvent, SignedEvent, EventTemplate} from "@welshman/util"
import {Nip59} from "@welshman/signer"
import {MergedThunk, Thunks} from "./thunk.js"
@@ -70,7 +70,7 @@ export class Wraps {
return new MergedThunk(
await Promise.all(
uniq(recipients).map(async recipient => {
const relays = getRelaysFromList(await this.app.use(MessagingRelayLists).load(recipient))
const relays = (await this.app.use(MessagingRelayLists).load(recipient))?.urls() ?? []
return this.app.use(Thunks).publish({event: stableEvent, relays, recipient, ...options})
}),
+11 -5
View File
@@ -11,6 +11,7 @@ import {
import type {Maybe} from "@welshman/lib"
import {getTagValue, getZapSplits, zapFromEvent} from "@welshman/util"
import type {Zapper, Zap, TrustedEvent} from "@welshman/util"
import type {Profile} from "@welshman/domain"
import {deriveDeduplicated, deriveDeduplicatedByValue} from "@welshman/store"
import {LoadableMapPlugin, projection} from "./base.js"
import type {Projection} from "./base.js"
@@ -66,14 +67,19 @@ export class Zappers extends LoadableMapPlugin<Zapper> {
loadForPubkey = async (pubkey: string, relays: string[] = []) => {
const $profile = await this.app.use(Profiles).load(pubkey, relays)
return $profile?.lnurl ? this.load($profile.lnurl) : undefined
const lnurl = $profile?.lnurl()
return lnurl ? this.load(lnurl) : undefined
}
forPubkey = (pubkey: string, relays: string[] = []): Projection<Maybe<Zapper>> => {
this.loadForPubkey(pubkey, relays)
const read = ([$zappersByLnurl, $profile]: [ReadonlyMap<string, Zapper>, Maybe<{lnurl?: string}>]) =>
$profile?.lnurl ? $zappersByLnurl.get($profile.lnurl) : undefined
const read = ([$zappersByLnurl, $profile]: [ReadonlyMap<string, Zapper>, Maybe<Profile>]) => {
const lnurl = $profile?.lnurl()
return lnurl ? $zappersByLnurl.get(lnurl) : undefined
}
return projection(
deriveDeduplicated([this.index.$, this.app.use(Profiles).one(pubkey, relays)], read),
@@ -120,12 +126,12 @@ export class Zappers extends LoadableMapPlugin<Zapper> {
const read = (values: any[]) => {
const $zappersByLnurl = values[0] as Map<string, Zapper>
const $profiles = values.slice(1) as Array<{lnurl?: string} | undefined>
const $profiles = values.slice(1) as Array<Profile | undefined>
const zapperByPubkey = new Map<string, Zapper>()
splits.forEach((split, i) => {
const lnurl = $profiles[i]?.lnurl
const lnurl = $profiles[i]?.lnurl()
const zapper = lnurl ? $zappersByLnurl.get(lnurl) : undefined
if (zapper) zapperByPubkey.set(split.pubkey, zapper)
+1
View File
@@ -4,6 +4,7 @@
"compilerOptions": {
"outDir": "./dist",
"paths": {
"@welshman/domain": ["../domain/src/index.js"],
"@welshman/feeds": ["../feeds/src/index.js"],
"@welshman/lib": ["../lib/src/index.js"],
"@welshman/net": ["../net/src/index.js"],