This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import type {Unsubscriber} from "svelte/store"
|
||||
import {call} from "@welshman/lib"
|
||||
import {Pool, Tracker, Repository, WrapManager} from "@welshman/net"
|
||||
import type {NetContext, AdapterFactory} from "@welshman/net"
|
||||
import type {User} from "./user.js"
|
||||
import type {AppPolicy} from "./policy.js"
|
||||
|
||||
export type AppConfig = {
|
||||
dufflepudUrl?: string
|
||||
getDefaultRelays?: () => string[]
|
||||
getIndexerRelays?: () => string[]
|
||||
getSearchRelays?: () => string[]
|
||||
}
|
||||
|
||||
export type AppOptions = {
|
||||
user?: User
|
||||
config?: AppConfig
|
||||
getAdapter?: AdapterFactory
|
||||
policies?: AppPolicy[]
|
||||
}
|
||||
|
||||
export interface IApp {
|
||||
user?: User
|
||||
config: AppConfig
|
||||
use: <T>(Ctor: new (app: IApp) => T) => T
|
||||
netContext: NetContext
|
||||
pool: Pool
|
||||
tracker: Tracker
|
||||
repository: Repository
|
||||
wrapManager: WrapManager
|
||||
}
|
||||
|
||||
/**
|
||||
* The core of an application instance. Owns the primitives a single identity
|
||||
* needs (so data never bleeds across sessions) — a private repository, a socket
|
||||
* pool, a tracker, a wrap manager — and a `use` registry that resolves data
|
||||
* modules (including net/store helpers) on demand.
|
||||
*/
|
||||
export class App implements IApp {
|
||||
user?: User
|
||||
config: AppConfig
|
||||
netContext: NetContext
|
||||
pool: Pool
|
||||
tracker: Tracker
|
||||
repository: Repository
|
||||
wrapManager: WrapManager
|
||||
|
||||
private singletons = new Map<Function, unknown>()
|
||||
private unsubscribers: Unsubscriber[] = []
|
||||
|
||||
constructor(options: AppOptions = {}) {
|
||||
this.user = options.user
|
||||
this.config = options.config ?? {}
|
||||
this.pool = new Pool()
|
||||
this.tracker = new Tracker()
|
||||
this.repository = new Repository()
|
||||
this.wrapManager = new WrapManager({
|
||||
tracker: this.tracker,
|
||||
repository: this.repository,
|
||||
})
|
||||
this.netContext = {
|
||||
pool: this.pool,
|
||||
repository: this.repository,
|
||||
getAdapter: options.getAdapter,
|
||||
}
|
||||
|
||||
for (const policy of options.policies ?? []) {
|
||||
this.unsubscribers.push(policy(this))
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the per-app singleton of a data module, constructing it on first
|
||||
// use. This is how modules reach their dependencies (e.g. app.use(RelayLists)),
|
||||
// replacing constructor injection and letting cycles resolve lazily.
|
||||
use = <T>(Ctor: new (app: IApp) => T): T => {
|
||||
let instance = this.singletons.get(Ctor) as T | undefined
|
||||
|
||||
if (!instance) {
|
||||
this.singletons.set(Ctor, (instance = new Ctor(this)))
|
||||
}
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
this.unsubscribers.forEach(call)
|
||||
this.pool.clear()
|
||||
this.tracker.clear()
|
||||
this.repository.clear()
|
||||
this.wrapManager.clear()
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import {BLOCKED_RELAYS, asDecryptedEvent, readList} from "@welshman/util"
|
||||
import {TrustedEvent} from "@welshman/util"
|
||||
import {
|
||||
deriveItemsByKey,
|
||||
deriveItems,
|
||||
makeForceLoadItem,
|
||||
makeLoadItem,
|
||||
makeDeriveItem,
|
||||
getter,
|
||||
} from "@welshman/store"
|
||||
import {repository} from "./core.js"
|
||||
import {makeOutboxLoader} from "./relayLists.js"
|
||||
|
||||
export const blockedRelayListsByPubkey = deriveItemsByKey({
|
||||
repository,
|
||||
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
||||
filters: [{kinds: [BLOCKED_RELAYS]}],
|
||||
getKey: blockedRelayLists => blockedRelayLists.event.pubkey,
|
||||
})
|
||||
|
||||
export const blockedRelayLists = deriveItems(blockedRelayListsByPubkey)
|
||||
|
||||
export const getBlockedRelayListsByPubkey = getter(blockedRelayListsByPubkey)
|
||||
|
||||
export const getBlockedRelayLists = getter(blockedRelayLists)
|
||||
|
||||
export const getBlockedRelayList = (pubkey: string) => getBlockedRelayListsByPubkey().get(pubkey)
|
||||
|
||||
export const forceLoadBlockedRelayList = makeForceLoadItem(
|
||||
makeOutboxLoader(BLOCKED_RELAYS),
|
||||
getBlockedRelayList,
|
||||
)
|
||||
|
||||
export const loadBlockedRelayList = makeLoadItem(
|
||||
makeOutboxLoader(BLOCKED_RELAYS),
|
||||
getBlockedRelayList,
|
||||
)
|
||||
|
||||
export const deriveBlockedRelayList = makeDeriveItem(
|
||||
blockedRelayListsByPubkey,
|
||||
loadBlockedRelayList,
|
||||
)
|
||||
@@ -1,40 +0,0 @@
|
||||
import {BLOSSOM_SERVERS, asDecryptedEvent, readList} from "@welshman/util"
|
||||
import {TrustedEvent} from "@welshman/util"
|
||||
import {
|
||||
deriveItemsByKey,
|
||||
deriveItems,
|
||||
makeForceLoadItem,
|
||||
makeLoadItem,
|
||||
makeDeriveItem,
|
||||
getter,
|
||||
} from "@welshman/store"
|
||||
import {repository} from "./core.js"
|
||||
import {makeOutboxLoader} from "./relayLists.js"
|
||||
|
||||
export const blossomServerListsByPubkey = deriveItemsByKey({
|
||||
repository,
|
||||
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
||||
filters: [{kinds: [BLOSSOM_SERVERS]}],
|
||||
getKey: blossomServerList => blossomServerList.event.pubkey,
|
||||
})
|
||||
|
||||
export const blossomServerLists = deriveItems(blossomServerListsByPubkey)
|
||||
|
||||
export const getBlossomServerListsByPubkey = getter(blossomServerListsByPubkey)
|
||||
|
||||
export const getBlossomServerList = (pubkey: string) => getBlossomServerListsByPubkey().get(pubkey)
|
||||
|
||||
export const forceLoadBlossomServerList = makeForceLoadItem(
|
||||
makeOutboxLoader(BLOSSOM_SERVERS),
|
||||
getBlossomServerList,
|
||||
)
|
||||
|
||||
export const loadBlossomServerList = makeLoadItem(
|
||||
makeOutboxLoader(BLOSSOM_SERVERS),
|
||||
getBlossomServerList,
|
||||
)
|
||||
|
||||
export const deriveBlossomServerList = makeDeriveItem(
|
||||
blossomServerListsByPubkey,
|
||||
loadBlossomServerList,
|
||||
)
|
||||
@@ -1,378 +0,0 @@
|
||||
import {get} from "svelte/store"
|
||||
import {uniq, reject, nth, now, nthNe, removeUndefined, nthEq} from "@welshman/lib"
|
||||
import {
|
||||
sendManagementRequest,
|
||||
ManagementRequest,
|
||||
addToListPublicly,
|
||||
addToListPrivately,
|
||||
updateList,
|
||||
EventTemplate,
|
||||
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 {RoomMeta, Profile} from "@welshman/util"
|
||||
import {Router, addMaximalFallbacks} from "@welshman/router"
|
||||
import {
|
||||
userRelayList,
|
||||
forceLoadUserRelayList,
|
||||
userMessagingRelayList,
|
||||
forceLoadUserMessagingRelayList,
|
||||
userBlockedRelayList,
|
||||
forceLoadUserBlockedRelayList,
|
||||
userSearchRelayList,
|
||||
forceLoadUserSearchRelayList,
|
||||
userFollowList,
|
||||
forceLoadUserFollowList,
|
||||
userMuteList,
|
||||
forceLoadUserMuteList,
|
||||
userPinList,
|
||||
forceLoadUserPinList,
|
||||
} from "./user.js"
|
||||
import {nip44EncryptToSelf, signer, pubkey} from "./session.js"
|
||||
import {ThunkOptions, MergedThunk, publishThunk} from "./thunk.js"
|
||||
import {loadMessagingRelayList} from "./messagingRelayLists.js"
|
||||
|
||||
// NIP 65
|
||||
|
||||
export const removeRelay = async (url: string, mode: RelayMode) => {
|
||||
await forceLoadUserRelayList([])
|
||||
|
||||
const list = get(userRelayList) || 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 = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
|
||||
|
||||
// Make sure to notify the old relay too
|
||||
relays.push(url)
|
||||
|
||||
return publishThunk({event, relays})
|
||||
}
|
||||
|
||||
export const addRelay = async (url: string, mode: RelayMode) => {
|
||||
await forceLoadUserRelayList([])
|
||||
|
||||
const list = get(userRelayList) || 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 relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
|
||||
|
||||
return publishThunk({event, relays})
|
||||
}
|
||||
|
||||
export const setRelays = async (tags: string[][]) => {
|
||||
const router = Router.get()
|
||||
const event = makeEvent(RELAYS, {tags})
|
||||
const relays = router
|
||||
.merge([router.Index(), router.FromRelays(getRelayTagValues(tags))])
|
||||
.getUrls()
|
||||
|
||||
return publishThunk({event, relays})
|
||||
}
|
||||
|
||||
export const setReadRelays = async (urls: string[]) => {
|
||||
await forceLoadUserRelayList([])
|
||||
|
||||
const list = get(userRelayList) || 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}
|
||||
const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
|
||||
|
||||
return publishThunk({event, relays})
|
||||
}
|
||||
|
||||
export const setWriteRelays = async (urls: string[]) => {
|
||||
await forceLoadUserRelayList([])
|
||||
|
||||
const list = get(userRelayList) || 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}
|
||||
const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
|
||||
|
||||
return publishThunk({event, relays})
|
||||
}
|
||||
|
||||
// NIP 17
|
||||
|
||||
export const removeMessagingRelay = async (url: string) => {
|
||||
await forceLoadUserMessagingRelayList([])
|
||||
|
||||
const list = get(userMessagingRelayList) || makeList({kind: MESSAGING_RELAYS})
|
||||
const event = await removeFromList(list, url).reconcile(nip44EncryptToSelf)
|
||||
const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
|
||||
|
||||
return publishThunk({event, relays})
|
||||
}
|
||||
|
||||
export const addMessagingRelay = async (url: string) => {
|
||||
await forceLoadUserMessagingRelayList([])
|
||||
|
||||
const list = get(userMessagingRelayList) || makeList({kind: MESSAGING_RELAYS})
|
||||
const event = await addToListPublicly(list, ["relay", url]).reconcile(nip44EncryptToSelf)
|
||||
const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
|
||||
|
||||
return publishThunk({event, relays})
|
||||
}
|
||||
|
||||
export const setMessagingRelays = async (urls: string[]) => {
|
||||
const event = makeEvent(MESSAGING_RELAYS, {tags: urls.map(url => ["relay", url])})
|
||||
const relays = Router.get().FromUser().getUrls()
|
||||
|
||||
return publishThunk({event, relays})
|
||||
}
|
||||
|
||||
// Blocked Relays
|
||||
|
||||
export const removeBlockedRelay = async (url: string) => {
|
||||
await forceLoadUserBlockedRelayList([])
|
||||
|
||||
const list = get(userBlockedRelayList) || makeList({kind: BLOCKED_RELAYS})
|
||||
const event = await removeFromList(list, url).reconcile(nip44EncryptToSelf)
|
||||
const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
|
||||
|
||||
return publishThunk({event, relays})
|
||||
}
|
||||
|
||||
export const addBlockedRelay = async (url: string) => {
|
||||
await forceLoadUserBlockedRelayList([])
|
||||
|
||||
const list = get(userBlockedRelayList) || makeList({kind: BLOCKED_RELAYS})
|
||||
const event = await addToListPublicly(list, ["relay", url]).reconcile(nip44EncryptToSelf)
|
||||
const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
|
||||
|
||||
return publishThunk({event, relays})
|
||||
}
|
||||
|
||||
export const setBlockedRelays = async (urls: string[]) => {
|
||||
const event = makeEvent(BLOCKED_RELAYS, {tags: urls.map(url => ["relay", url])})
|
||||
const relays = Router.get().FromUser().getUrls()
|
||||
|
||||
return publishThunk({event, relays})
|
||||
}
|
||||
|
||||
// Search Relays
|
||||
|
||||
export const removeSearchRelay = async (url: string) => {
|
||||
await forceLoadUserSearchRelayList([])
|
||||
|
||||
const list = get(userSearchRelayList) || makeList({kind: SEARCH_RELAYS})
|
||||
const event = await removeFromList(list, url).reconcile(nip44EncryptToSelf)
|
||||
const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
|
||||
|
||||
return publishThunk({event, relays})
|
||||
}
|
||||
|
||||
export const addSearchRelay = async (url: string) => {
|
||||
await forceLoadUserSearchRelayList([])
|
||||
|
||||
const list = get(userSearchRelayList) || makeList({kind: SEARCH_RELAYS})
|
||||
const event = await addToListPublicly(list, ["relay", url]).reconcile(nip44EncryptToSelf)
|
||||
const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
|
||||
|
||||
return publishThunk({event, relays})
|
||||
}
|
||||
|
||||
export const setSearchRelays = async (urls: string[]) => {
|
||||
const event = makeEvent(SEARCH_RELAYS, {tags: urls.map(url => ["relay", url])})
|
||||
const relays = Router.get().FromUser().getUrls()
|
||||
|
||||
return publishThunk({event, relays})
|
||||
}
|
||||
|
||||
// NIP 01
|
||||
|
||||
export const setProfile = (profile: Profile) => {
|
||||
const router = Router.get()
|
||||
const relays = router.merge([router.Index(), router.FromUser()]).getUrls()
|
||||
const event = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile)
|
||||
|
||||
return publishThunk({event, relays})
|
||||
}
|
||||
|
||||
// NIP 02
|
||||
|
||||
export const unfollow = async (value: string) => {
|
||||
await forceLoadUserFollowList([])
|
||||
|
||||
const list = get(userFollowList) || makeList({kind: FOLLOWS})
|
||||
const event = await removeFromList(list, value).reconcile(nip44EncryptToSelf)
|
||||
const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
|
||||
|
||||
return publishThunk({event, relays})
|
||||
}
|
||||
|
||||
export const follow = async (tag: string[]) => {
|
||||
await forceLoadUserFollowList([])
|
||||
|
||||
const list = get(userFollowList) || makeList({kind: FOLLOWS})
|
||||
const event = await addToListPublicly(list, tag).reconcile(nip44EncryptToSelf)
|
||||
const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
|
||||
|
||||
return publishThunk({event, relays})
|
||||
}
|
||||
|
||||
export const unmute = async (value: string) => {
|
||||
await forceLoadUserMuteList([])
|
||||
|
||||
const list = get(userMuteList) || makeList({kind: MUTES})
|
||||
const event = await removeFromList(list, value).reconcile(nip44EncryptToSelf)
|
||||
const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
|
||||
|
||||
return publishThunk({event, relays})
|
||||
}
|
||||
|
||||
export const mutePublicly = async (tag: string[]) => {
|
||||
await forceLoadUserMuteList([])
|
||||
|
||||
const list = get(userMuteList) || makeList({kind: MUTES})
|
||||
const event = await addToListPublicly(list, tag).reconcile(nip44EncryptToSelf)
|
||||
const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
|
||||
|
||||
return publishThunk({event, relays})
|
||||
}
|
||||
|
||||
export const mutePrivately = async (tag: string[]) => {
|
||||
await forceLoadUserMuteList([])
|
||||
|
||||
const list = get(userMuteList) || makeList({kind: MUTES})
|
||||
const event = await addToListPrivately(list, tag).reconcile(nip44EncryptToSelf)
|
||||
const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
|
||||
|
||||
return publishThunk({event, relays})
|
||||
}
|
||||
|
||||
export const setMutes = async ({
|
||||
publicTags,
|
||||
privateTags,
|
||||
}: {
|
||||
publicTags?: string[][]
|
||||
privateTags?: string[][]
|
||||
}) => {
|
||||
await forceLoadUserMuteList([])
|
||||
|
||||
const list = get(userMuteList) || makeList({kind: MUTES})
|
||||
const event = await updateList(list, {publicTags, privateTags}).reconcile(nip44EncryptToSelf)
|
||||
const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
|
||||
|
||||
return publishThunk({event, relays})
|
||||
}
|
||||
|
||||
export const unpin = async (value: string) => {
|
||||
await forceLoadUserPinList([])
|
||||
|
||||
const list = get(userPinList) || makeList({kind: PINS})
|
||||
const event = await removeFromList(list, value).reconcile(nip44EncryptToSelf)
|
||||
const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
|
||||
|
||||
return publishThunk({event, relays})
|
||||
}
|
||||
|
||||
export const pin = async (tag: string[]) => {
|
||||
await forceLoadUserPinList([])
|
||||
|
||||
const list = get(userPinList) || makeList({kind: PINS})
|
||||
const event = await addToListPublicly(list, tag).reconcile(nip44EncryptToSelf)
|
||||
const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
|
||||
|
||||
return publishThunk({event, relays})
|
||||
}
|
||||
|
||||
// NIP 59
|
||||
|
||||
export type SendWrappedOptions = Omit<ThunkOptions, "event" | "relays"> & {
|
||||
event: EventTemplate
|
||||
recipients: string[]
|
||||
}
|
||||
|
||||
export const sendWrapped = async ({event, recipients, ...options}: SendWrappedOptions) => {
|
||||
const $pubkey = pubkey.get()
|
||||
|
||||
// Stabilize the event id across different wraps
|
||||
if ($pubkey) {
|
||||
event = prep(event, $pubkey, now())
|
||||
}
|
||||
|
||||
return new MergedThunk(
|
||||
await Promise.all(
|
||||
uniq(recipients).map(async recipient => {
|
||||
const relays = getRelaysFromList(await loadMessagingRelayList(recipient))
|
||||
|
||||
return publishThunk({event, relays, recipient, ...options})
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// NIP 86
|
||||
|
||||
export const manageRelay = async (url: string, request: ManagementRequest) => {
|
||||
url = url.replace(/^ws/, "http")
|
||||
|
||||
const authTemplate = await makeHttpAuth(url, "POST", JSON.stringify(request))
|
||||
const authEvent = await signer.get()!.sign(authTemplate)
|
||||
|
||||
return sendManagementRequest(url, request, authEvent)
|
||||
}
|
||||
|
||||
// NIP 29
|
||||
|
||||
export const createRoom = (url: string, room: RoomMeta) =>
|
||||
publishThunk({event: makeRoomCreateEvent(room), relays: [url]})
|
||||
|
||||
export const deleteRoom = (url: string, room: RoomMeta) =>
|
||||
publishThunk({event: makeRoomDeleteEvent(room), relays: [url]})
|
||||
|
||||
export const editRoom = (url: string, room: RoomMeta) =>
|
||||
publishThunk({event: makeRoomEditEvent(room), relays: [url]})
|
||||
|
||||
export const joinRoom = (url: string, room: RoomMeta) =>
|
||||
publishThunk({event: makeRoomJoinEvent(room), relays: [url]})
|
||||
|
||||
export const leaveRoom = (url: string, room: RoomMeta) =>
|
||||
publishThunk({event: makeRoomLeaveEvent(room), relays: [url]})
|
||||
|
||||
export const addRoomMember = (url: string, room: RoomMeta, pubkey: string) =>
|
||||
publishThunk({event: makeRoomAddMemberEvent(room, pubkey), relays: [url]})
|
||||
|
||||
export const removeRoomMember = (url: string, room: RoomMeta, pubkey: string) =>
|
||||
publishThunk({event: makeRoomRemoveMemberEvent(room, pubkey), relays: [url]})
|
||||
@@ -1,5 +0,0 @@
|
||||
export type AppContext = {
|
||||
dufflepudUrl?: string
|
||||
}
|
||||
|
||||
export const appContext: AppContext = {}
|
||||
@@ -1,5 +0,0 @@
|
||||
import {Repository, Tracker} from "@welshman/net"
|
||||
|
||||
export const tracker = new Tracker()
|
||||
|
||||
export const repository = Repository.get()
|
||||
@@ -0,0 +1,14 @@
|
||||
import {App} from "./app.js"
|
||||
import type {AppOptions} from "./app.js"
|
||||
import {defaultAppPolicies} from "./policy.js"
|
||||
|
||||
/**
|
||||
* Creates a batteries-included app: an `App` wired with the default app
|
||||
* policies (event ingestion, relay-stats collection, gift-wrap unwrapping).
|
||||
* Reach data modules via `app.use(Profiles)`, `app.use(FollowLists)`, etc.
|
||||
*
|
||||
* For a bare app (no default side effects) construct `new App(...)`
|
||||
* directly, or pass your own `policies`.
|
||||
*/
|
||||
export const createApp = (options: AppOptions = {}) =>
|
||||
new App({...options, policies: options.policies ?? defaultAppPolicies})
|
||||
@@ -1,44 +0,0 @@
|
||||
import {Scope, FeedController, FeedControllerOptions, Feed} from "@welshman/feeds"
|
||||
import {pubkey, signer} from "./session.js"
|
||||
import {getWotGraph, getMaxWot, getFollows, getNetwork, getFollowers} from "./wot.js"
|
||||
|
||||
export const getPubkeysForScope = (scope: string) => {
|
||||
const $pubkey = pubkey.get()
|
||||
|
||||
if (!$pubkey) {
|
||||
return []
|
||||
}
|
||||
|
||||
switch (scope) {
|
||||
case Scope.Self:
|
||||
return [$pubkey]
|
||||
case Scope.Follows:
|
||||
return getFollows($pubkey)
|
||||
case Scope.Network:
|
||||
return getNetwork($pubkey)
|
||||
case Scope.Followers:
|
||||
return getFollowers($pubkey)
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const getPubkeysForWOTRange = (min: number, max: number) => {
|
||||
const pubkeys = []
|
||||
const $maxWot = getMaxWot()
|
||||
const thresholdMin = $maxWot * min
|
||||
const thresholdMax = $maxWot * max
|
||||
|
||||
for (const [tpk, score] of getWotGraph().entries()) {
|
||||
if (score >= thresholdMin && score <= thresholdMax) {
|
||||
pubkeys.push(tpk)
|
||||
}
|
||||
}
|
||||
|
||||
return pubkeys
|
||||
}
|
||||
|
||||
type MakeFeedControllerOptions = Partial<Omit<FeedControllerOptions, "feed">> & {feed: Feed}
|
||||
|
||||
export const makeFeedController = (options: MakeFeedControllerOptions) =>
|
||||
new FeedController({getPubkeysForScope, getPubkeysForWOTRange, signer: signer.get(), ...options})
|
||||
@@ -1,33 +0,0 @@
|
||||
import {FOLLOWS, asDecryptedEvent, readList} from "@welshman/util"
|
||||
import {TrustedEvent} from "@welshman/util"
|
||||
import {
|
||||
deriveItemsByKey,
|
||||
deriveItems,
|
||||
makeForceLoadItem,
|
||||
makeLoadItem,
|
||||
makeDeriveItem,
|
||||
getter,
|
||||
} from "@welshman/store"
|
||||
import {repository} from "./core.js"
|
||||
import {makeOutboxLoader} from "./relayLists.js"
|
||||
|
||||
export const followListsByPubkey = deriveItemsByKey({
|
||||
repository,
|
||||
filters: [{kinds: [FOLLOWS]}],
|
||||
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
||||
getKey: followList => followList.event.pubkey,
|
||||
})
|
||||
|
||||
export const followLists = deriveItems(followListsByPubkey)
|
||||
|
||||
export const getFollowListsByPubkey = getter(followListsByPubkey)
|
||||
|
||||
export const getFollowLists = getter(followLists)
|
||||
|
||||
export const getFollowList = (pubkey: string) => getFollowListsByPubkey().get(pubkey)
|
||||
|
||||
export const forceLoadFollowList = makeForceLoadItem(makeOutboxLoader(FOLLOWS), getFollowList)
|
||||
|
||||
export const loadFollowList = makeLoadItem(makeOutboxLoader(FOLLOWS), getFollowList)
|
||||
|
||||
export const deriveFollowList = makeDeriveItem(followListsByPubkey, loadFollowList)
|
||||
@@ -1,150 +0,0 @@
|
||||
import {writable, Subscriber} from "svelte/store"
|
||||
import {tryCatch, fetchJson, batcher, postJson, last} from "@welshman/lib"
|
||||
import {
|
||||
getter,
|
||||
deriveItems,
|
||||
deriveDeduplicated,
|
||||
makeForceLoadItem,
|
||||
makeLoadItem,
|
||||
makeDeriveItem,
|
||||
} from "@welshman/store"
|
||||
import {deriveProfile, loadProfile} from "./profiles.js"
|
||||
import {appContext} from "./context.js"
|
||||
|
||||
export type Handle = {
|
||||
nip05: string
|
||||
pubkey?: string
|
||||
nip46?: string[]
|
||||
relays?: string[]
|
||||
}
|
||||
|
||||
export async function queryProfile(nip05: string) {
|
||||
const parts = nip05.split("@")
|
||||
const name = parts.length > 1 ? parts[0] : "_"
|
||||
const domain = last(parts)
|
||||
|
||||
try {
|
||||
const {
|
||||
names,
|
||||
relays = {},
|
||||
nip46 = {},
|
||||
} = await fetchJson(`https://${domain}/.well-known/nostr.json?name=${name}`)
|
||||
|
||||
const pubkey = names[name]
|
||||
|
||||
if (!pubkey) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
nip05,
|
||||
pubkey,
|
||||
nip46: nip46[pubkey],
|
||||
relays: relays[pubkey],
|
||||
}
|
||||
} catch (_e) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const handlesByNip05 = writable(new Map<string, Handle>())
|
||||
|
||||
export const handles = deriveItems(handlesByNip05)
|
||||
|
||||
export const getHandlesByNip05 = getter(handlesByNip05)
|
||||
|
||||
export const getHandles = getter(handles)
|
||||
|
||||
export const getHandle = (nip05: string) => getHandlesByNip05().get(nip05)
|
||||
|
||||
export const handleSubscribers: Subscriber<Handle>[] = []
|
||||
|
||||
export const notifyHandle = (handle: Handle) => handleSubscribers.forEach(sub => sub(handle))
|
||||
|
||||
export const onHandle = (sub: (handle: Handle) => void) => {
|
||||
handleSubscribers.push(sub)
|
||||
|
||||
return () => {
|
||||
const i = handleSubscribers.findIndex(s => s === sub)
|
||||
|
||||
if (i !== -1) handleSubscribers.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
export const fetchHandle = batcher(800, async (nip05s: string[]) => {
|
||||
const result = new Map<string, Handle>()
|
||||
|
||||
// Use dufflepud if we it's set up to protect user privacy, otherwise fetch directly
|
||||
if (appContext.dufflepudUrl) {
|
||||
const res: any = await tryCatch(
|
||||
async () => await postJson(`${appContext.dufflepudUrl}/handle/info`, {handles: nip05s}),
|
||||
)
|
||||
|
||||
for (const {handle: nip05, info} of res?.data || []) {
|
||||
if (info) {
|
||||
result.set(nip05, {...info, nip05})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const results = await Promise.all(
|
||||
nip05s.map(async nip05 => ({
|
||||
nip05,
|
||||
info: await tryCatch(async () => await queryProfile(nip05)),
|
||||
})),
|
||||
)
|
||||
|
||||
for (const {nip05, info} of results) {
|
||||
if (info) {
|
||||
result.set(nip05, {...info, nip05})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handlesByNip05.update($handlesByNip05 => {
|
||||
for (const [nip05, info] of result) {
|
||||
$handlesByNip05.set(nip05, info)
|
||||
}
|
||||
|
||||
return $handlesByNip05
|
||||
})
|
||||
|
||||
for (const info of result.values()) {
|
||||
notifyHandle(info)
|
||||
}
|
||||
|
||||
return nip05s.map(nip05 => result.get(nip05))
|
||||
})
|
||||
|
||||
export const forceLoadHandle = makeForceLoadItem(fetchHandle, getHandle)
|
||||
|
||||
export const loadHandle = makeLoadItem(fetchHandle, getHandle)
|
||||
|
||||
export const deriveHandle = makeDeriveItem(handlesByNip05, loadHandle)
|
||||
|
||||
export const loadHandleForPubkey = async (pubkey: string, relays: string[] = []) => {
|
||||
const $profile = await loadProfile(pubkey, relays)
|
||||
|
||||
return $profile?.nip05 ? loadHandle($profile.nip05) : undefined
|
||||
}
|
||||
|
||||
export const deriveHandleForPubkey = (pubkey: string, relays: string[] = []) => {
|
||||
loadHandleForPubkey(pubkey, relays)
|
||||
|
||||
return deriveDeduplicated(
|
||||
[handlesByNip05, deriveProfile(pubkey, relays)],
|
||||
([$handlesByNip05, $profile]) => {
|
||||
if (!$profile?.nip05) return undefined
|
||||
|
||||
const handle = $handlesByNip05.get($profile.nip05)
|
||||
|
||||
if (handle?.pubkey !== pubkey) return undefined
|
||||
|
||||
return handle
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export const displayNip05 = (nip05: string) =>
|
||||
nip05?.startsWith("_@") ? last(nip05.split("@")) : nip05
|
||||
|
||||
export const displayHandle = (handle: Handle) => displayNip05(handle.nip05)
|
||||
+33
-111
@@ -1,112 +1,34 @@
|
||||
export * from "./blossom.js"
|
||||
export * from "./context.js"
|
||||
export * from "./core.js"
|
||||
export * from "./commands.js"
|
||||
export * from "./feeds.js"
|
||||
export * from "./follows.js"
|
||||
export * from "./handles.js"
|
||||
export * from "./mutes.js"
|
||||
export * from "./plaintext.js"
|
||||
export * from "./profiles.js"
|
||||
export * from "./pins.js"
|
||||
export * from "./relays.js"
|
||||
export * from "./relayStats.js"
|
||||
export * from "./relayLists.js"
|
||||
export * from "./blockedRelayLists.js"
|
||||
export * from "./messagingRelayLists.js"
|
||||
export * from "./search.js"
|
||||
export * from "./session.js"
|
||||
export * from "./sync.js"
|
||||
export * from "./tags.js"
|
||||
export * from "./thunk.js"
|
||||
export * from "./topics.js"
|
||||
export * from "./app.js"
|
||||
export * from "./policy.js"
|
||||
export * from "./user.js"
|
||||
export * from "./wot.js"
|
||||
export * from "./zappers.js"
|
||||
|
||||
import {derived} from "svelte/store"
|
||||
import {sortBy, throttleWithValue} from "@welshman/lib"
|
||||
import {
|
||||
isEphemeralKind,
|
||||
isDVMKind,
|
||||
WRAP,
|
||||
RelayMode,
|
||||
RelayProfile,
|
||||
getRelaysFromList,
|
||||
} from "@welshman/util"
|
||||
import {routerContext} from "@welshman/router"
|
||||
import {Pool, SocketEvent, isRelayEvent, netContext} from "@welshman/net"
|
||||
import {pubkey, unwrapAndStore} from "./session.js"
|
||||
import {repository, tracker} from "./core.js"
|
||||
import {getRelays, loadRelay} from "./relays.js"
|
||||
import {trackRelayStats, getRelayQuality} from "./relayStats.js"
|
||||
import {deriveRelayList, getRelayList} from "./relayLists.js"
|
||||
import {deriveSearchRelayList, getSearchRelayList} from "./searchRelayLists.js"
|
||||
import {deriveBlockedRelayList, getBlockedRelayList} from "./blockedRelayLists.js"
|
||||
import {deriveMessagingRelayList, getMessagingRelayList} from "./messagingRelayLists.js"
|
||||
|
||||
// Sync relays with our database
|
||||
|
||||
Pool.get().subscribe(socket => {
|
||||
loadRelay(socket.url)
|
||||
trackRelayStats(socket)
|
||||
|
||||
socket.on(SocketEvent.Receive, message => {
|
||||
if (isRelayEvent(message)) {
|
||||
const event = message[2]
|
||||
|
||||
if (
|
||||
!isDVMKind(event.kind) &&
|
||||
!isEphemeralKind(event.kind) &&
|
||||
netContext.isEventValid(event, socket.url)
|
||||
) {
|
||||
tracker.track(event.id, socket.url)
|
||||
|
||||
if (event.kind === WRAP) {
|
||||
unwrapAndStore(event)
|
||||
} else {
|
||||
repository.publish(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Configure the router and add a few other relay utils
|
||||
|
||||
const _relayGetter = (fn?: (relay: RelayProfile) => any) =>
|
||||
throttleWithValue(200, () => {
|
||||
let _relays = getRelays()
|
||||
|
||||
if (fn) {
|
||||
_relays = _relays.filter(fn)
|
||||
}
|
||||
|
||||
return sortBy(r => -getRelayQuality(r.url), _relays)
|
||||
.slice(0, 5)
|
||||
.map(r => r.url)
|
||||
})
|
||||
|
||||
export const getPubkeyRelays = (pubkey: string, mode?: RelayMode) => {
|
||||
if (mode === RelayMode.Search) return getRelaysFromList(getSearchRelayList(pubkey))
|
||||
if (mode === RelayMode.Blocked) return getRelaysFromList(getBlockedRelayList(pubkey))
|
||||
if (mode === RelayMode.Messaging) return getRelaysFromList(getMessagingRelayList(pubkey))
|
||||
return getRelaysFromList(getRelayList(pubkey), mode)
|
||||
}
|
||||
|
||||
export const derivePubkeyRelays = (pubkey: string, mode?: RelayMode) => {
|
||||
if (mode === RelayMode.Search)
|
||||
return derived(deriveSearchRelayList(pubkey), list => getRelaysFromList(list))
|
||||
if (mode === RelayMode.Blocked)
|
||||
return derived(deriveBlockedRelayList(pubkey), list => getRelaysFromList(list))
|
||||
if (mode === RelayMode.Messaging)
|
||||
return derived(deriveMessagingRelayList(pubkey), list => getRelaysFromList(list))
|
||||
return derived(deriveRelayList(pubkey), list => getRelaysFromList(list, mode))
|
||||
}
|
||||
|
||||
routerContext.getUserPubkey = () => pubkey.get()
|
||||
routerContext.getPubkeyRelays = getPubkeyRelays
|
||||
routerContext.getRelayQuality = getRelayQuality
|
||||
routerContext.getDefaultRelays = _relayGetter()
|
||||
routerContext.getIndexerRelays = _relayGetter()
|
||||
routerContext.getSearchRelays = _relayGetter(r => r?.supported_nips?.includes?.("50"))
|
||||
export * from "./session.js"
|
||||
export * from "./logging.js"
|
||||
export * from "./createApp.js"
|
||||
export * from "./plugins/base.js"
|
||||
export * from "./plugins/network.js"
|
||||
export * from "./plugins/stores.js"
|
||||
export * from "./plugins/router.js"
|
||||
export * from "./plugins/relays.js"
|
||||
export * from "./plugins/relayStats.js"
|
||||
export * from "./plugins/relayLists.js"
|
||||
export * from "./plugins/blockedRelayLists.js"
|
||||
export * from "./plugins/plaintext.js"
|
||||
export * from "./plugins/profiles.js"
|
||||
export * from "./plugins/follows.js"
|
||||
export * from "./plugins/mutes.js"
|
||||
export * from "./plugins/pins.js"
|
||||
export * from "./plugins/blossom.js"
|
||||
export * from "./plugins/messagingRelayLists.js"
|
||||
export * from "./plugins/searchRelayLists.js"
|
||||
export * from "./plugins/handles.js"
|
||||
export * from "./plugins/zappers.js"
|
||||
export * from "./plugins/topics.js"
|
||||
export * from "./plugins/tags.js"
|
||||
export * from "./plugins/wot.js"
|
||||
export * from "./plugins/feeds.js"
|
||||
export * from "./plugins/search.js"
|
||||
export * from "./plugins/sync.js"
|
||||
export * from "./plugins/wraps.js"
|
||||
export * from "./plugins/rooms.js"
|
||||
export * from "./plugins/relayManagement.js"
|
||||
export * from "./plugins/thunk.js"
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import {randomId} from "@welshman/lib"
|
||||
import {WrappedSigner} from "@welshman/signer"
|
||||
import type {ISigner} from "@welshman/signer"
|
||||
|
||||
/**
|
||||
* A structured, extensible log event. The built-in `signer` variant tracks each
|
||||
* signer operation (sign/encrypt/decrypt/getPubkey); the open variant lets
|
||||
* callers emit their own event types — it's not just a string.
|
||||
*/
|
||||
export type LogMessage =
|
||||
| {
|
||||
type: "signer"
|
||||
id: string
|
||||
method: string
|
||||
status: "pending" | "success" | "failure"
|
||||
error?: unknown
|
||||
at: number
|
||||
}
|
||||
| {type: string; at: number; [key: string]: unknown}
|
||||
|
||||
/**
|
||||
* An `ISigner` wrapper that emits a structured `LogMessage` (as a "message"
|
||||
* event on itself) for every operation it performs. `User.fromSigner` wraps
|
||||
* signers in this so they're observable; subscribe via `makeAppPolicyLogger`.
|
||||
*/
|
||||
export class LoggingSigner extends WrappedSigner {
|
||||
constructor(signer: ISigner) {
|
||||
super(signer, async (method, thunk) => {
|
||||
const id = randomId()
|
||||
|
||||
this.emit("message", {type: "signer", id, method, status: "pending", at: Date.now()})
|
||||
|
||||
try {
|
||||
const result = await thunk()
|
||||
|
||||
this.emit("message", {type: "signer", id, method, status: "success", at: Date.now()})
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
this.emit("message", {type: "signer", id, method, status: "failure", error, at: Date.now()})
|
||||
|
||||
throw error
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import {MESSAGING_RELAYS, asDecryptedEvent, readList} from "@welshman/util"
|
||||
import {TrustedEvent} from "@welshman/util"
|
||||
import {
|
||||
deriveItemsByKey,
|
||||
deriveItems,
|
||||
makeForceLoadItem,
|
||||
makeLoadItem,
|
||||
makeDeriveItem,
|
||||
getter,
|
||||
} from "@welshman/store"
|
||||
import {repository} from "./core.js"
|
||||
import {makeOutboxLoader} from "./relayLists.js"
|
||||
|
||||
export const messagingRelayListsByPubkey = deriveItemsByKey({
|
||||
repository,
|
||||
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
||||
filters: [{kinds: [MESSAGING_RELAYS]}],
|
||||
getKey: messagingRelayLists => messagingRelayLists.event.pubkey,
|
||||
})
|
||||
|
||||
export const messagingRelayLists = deriveItems(messagingRelayListsByPubkey)
|
||||
|
||||
export const getMessagingRelayListsByPubkey = getter(messagingRelayListsByPubkey)
|
||||
|
||||
export const getMessagingRelayLists = getter(messagingRelayLists)
|
||||
|
||||
export const getMessagingRelayList = (pubkey: string) =>
|
||||
getMessagingRelayListsByPubkey().get(pubkey)
|
||||
|
||||
export const forceLoadMessagingRelayList = makeForceLoadItem(
|
||||
makeOutboxLoader(MESSAGING_RELAYS),
|
||||
getMessagingRelayList,
|
||||
)
|
||||
|
||||
export const loadMessagingRelayList = makeLoadItem(
|
||||
makeOutboxLoader(MESSAGING_RELAYS),
|
||||
getMessagingRelayList,
|
||||
)
|
||||
|
||||
export const deriveMessagingRelayList = makeDeriveItem(
|
||||
messagingRelayListsByPubkey,
|
||||
loadMessagingRelayList,
|
||||
)
|
||||
@@ -1,49 +0,0 @@
|
||||
import {MUTES, asDecryptedEvent, readList} from "@welshman/util"
|
||||
import {TrustedEvent, PublishedList} from "@welshman/util"
|
||||
import {
|
||||
deriveItemsByKey,
|
||||
deriveItems,
|
||||
makeForceLoadItem,
|
||||
makeLoadItem,
|
||||
makeDeriveItem,
|
||||
getter,
|
||||
} from "@welshman/store"
|
||||
import {repository} from "./core.js"
|
||||
import {ensurePlaintext} from "./plaintext.js"
|
||||
import {getSession} from "./session.js"
|
||||
import {makeOutboxLoader} from "./relayLists.js"
|
||||
|
||||
export const muteListsByPubkey = deriveItemsByKey<PublishedList>({
|
||||
repository,
|
||||
eventToItem: async (event: TrustedEvent) => {
|
||||
const content = await ensurePlaintext(event)
|
||||
|
||||
// If this is our own mute list (we have a session for it) but it couldn't be
|
||||
// decrypted yet because no signer is available, don't cache a result with empty
|
||||
// private tags — that would get stuck permanently since deriveItemsByKey won't
|
||||
// re-process an already-seen event id. Returning undefined leaves it uncached so it's
|
||||
// retried once a signer is available. For other pubkeys' lists (no session) we fall
|
||||
// through and read just the public tags, as before.
|
||||
if (event.content && content === undefined && getSession(event.pubkey)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return readList(asDecryptedEvent(event, {content}))
|
||||
},
|
||||
filters: [{kinds: [MUTES]}],
|
||||
getKey: mute => mute.event.pubkey,
|
||||
})
|
||||
|
||||
export const muteLists = deriveItems(muteListsByPubkey)
|
||||
|
||||
export const getMuteListsByPubkey = getter(muteListsByPubkey)
|
||||
|
||||
export const getMuteLists = getter(muteLists)
|
||||
|
||||
export const getMuteList = (pubkey: string) => getMuteListsByPubkey().get(pubkey)
|
||||
|
||||
export const forceLoadMuteList = makeForceLoadItem(makeOutboxLoader(MUTES), getMuteList)
|
||||
|
||||
export const loadMuteList = makeLoadItem(makeOutboxLoader(MUTES), getMuteList)
|
||||
|
||||
export const deriveMuteList = makeDeriveItem(muteListsByPubkey, loadMuteList)
|
||||
@@ -1,33 +0,0 @@
|
||||
import {PINS, asDecryptedEvent, readList} from "@welshman/util"
|
||||
import {TrustedEvent} from "@welshman/util"
|
||||
import {
|
||||
deriveItemsByKey,
|
||||
deriveItems,
|
||||
makeForceLoadItem,
|
||||
makeLoadItem,
|
||||
makeDeriveItem,
|
||||
getter,
|
||||
} from "@welshman/store"
|
||||
import {repository} from "./core.js"
|
||||
import {makeOutboxLoader} from "./relayLists.js"
|
||||
|
||||
export const pinListsByPubkey = deriveItemsByKey({
|
||||
repository,
|
||||
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
||||
filters: [{kinds: [PINS]}],
|
||||
getKey: pins => pins.event.pubkey,
|
||||
})
|
||||
|
||||
export const pinLists = deriveItems(pinListsByPubkey)
|
||||
|
||||
export const getPinListsByPubkey = getter(pinListsByPubkey)
|
||||
|
||||
export const getPinLists = getter(pinLists)
|
||||
|
||||
export const getPinList = (pubkey: string) => getPinListsByPubkey().get(pubkey)
|
||||
|
||||
export const forceLoadPinList = makeForceLoadItem(makeOutboxLoader(PINS), getPinList)
|
||||
|
||||
export const loadPinList = makeLoadItem(makeOutboxLoader(PINS), getPinList)
|
||||
|
||||
export const derivePinList = makeDeriveItem(pinListsByPubkey, loadPinList)
|
||||
@@ -1,44 +0,0 @@
|
||||
import {writable} from "svelte/store"
|
||||
import {assoc} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {withGetter} from "@welshman/store"
|
||||
import {decrypt} from "@welshman/signer"
|
||||
import {getSigner, getSession} from "./session.js"
|
||||
|
||||
export const plaintext = withGetter(writable<Record<string, string>>({}))
|
||||
|
||||
export const getPlaintext = (e: TrustedEvent) => plaintext.get()[e.id]
|
||||
|
||||
export const setPlaintext = (e: TrustedEvent, content: string) =>
|
||||
plaintext.update(assoc(e.id, content))
|
||||
|
||||
export const ensurePlaintext = async (e: TrustedEvent) => {
|
||||
// Check for key presence rather than truthiness so a legitimately empty decrypted
|
||||
// result ("") is treated as cached and we don't re-decrypt (and re-hit the signer) on
|
||||
// every call.
|
||||
if (e.content && plaintext.get()[e.id] === undefined) {
|
||||
const $session = getSession(e.pubkey)
|
||||
|
||||
if (!$session) return
|
||||
|
||||
const $signer = getSigner($session)
|
||||
|
||||
if (!$signer) return
|
||||
|
||||
let result
|
||||
|
||||
try {
|
||||
result = await decrypt($signer, e.pubkey, e.content)
|
||||
} catch (e: any) {
|
||||
if (!String(e).match(/invalid base64/)) {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
if (result !== undefined) {
|
||||
setPlaintext(e, result)
|
||||
}
|
||||
}
|
||||
|
||||
return getPlaintext(e)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import {writable, derived} from "svelte/store"
|
||||
import type {Readable, Unsubscriber} from "svelte/store"
|
||||
import type {Maybe} from "@welshman/lib"
|
||||
import type {Filter} from "@welshman/util"
|
||||
import {deriveItems, getter, makeDeriveItem, makeLoadItem, makeForceLoadItem} from "@welshman/store"
|
||||
import type {EventToItem, ItemsByKey, MakeLoadItemOptions} from "@welshman/store"
|
||||
import type {IApp} from "../app.js"
|
||||
import {Stores} from "./stores.js"
|
||||
|
||||
/**
|
||||
* Utility type which allows for using the same value both for hot gets and derived subscriptions
|
||||
*/
|
||||
export type Projection<T> = {
|
||||
get: () => T
|
||||
$: Readable<T>
|
||||
}
|
||||
|
||||
export const projection = <T>($: Readable<T>, get = getter($)) => ({$, get})
|
||||
|
||||
/**
|
||||
* Build a `Projection` derived from another `Projection`: re-read `src`
|
||||
* reactively via `.$` or synchronously via `.get()`.
|
||||
*/
|
||||
export const projectFrom = <S, U>(src: Projection<S>, read: ($: S) => U): Projection<U> =>
|
||||
projection(derived(src.$, read), () => read(src.get()))
|
||||
|
||||
/**
|
||||
* Base class for a reactive, keyed collection of "local" (non-event) data —
|
||||
* things like relay stats or NIP-11 profiles that aren't backed by the
|
||||
* repository. The collection owns its own map.
|
||||
*
|
||||
* `index` (map) and `all` (values) are `Projection`s — subscribe via `.$`,
|
||||
* snapshot via `.get()`. Per-key access is `one(key)`, a plain on-demand store
|
||||
* (snapshot with svelte's `get(...)`, or read `get(key)` directly).
|
||||
*/
|
||||
export class MapPlugin<T> {
|
||||
protected store = writable(new Map<string, T>())
|
||||
index: Projection<ItemsByKey<T>>
|
||||
all: Projection<T[]>
|
||||
one: (key?: string, ...args: any[]) => Readable<Maybe<T>>
|
||||
subs: ((key: string, value: Maybe<T>) => void)[] = []
|
||||
|
||||
constructor(protected readonly app: IApp) {
|
||||
this.index = projection(this.store)
|
||||
this.all = projection(deriveItems(this.store))
|
||||
this.one = makeDeriveItem(this.store)
|
||||
}
|
||||
|
||||
get = (key: string) => this.index.get().get(key)
|
||||
|
||||
project = <U>(key: string, read: (item: Maybe<T>) => U): Projection<U> =>
|
||||
projection(derived(this.one(key), read), () => read(this.get(key)))
|
||||
|
||||
set = (key: string, value: T) => {
|
||||
this.store.update($items => {
|
||||
$items.set(key, value)
|
||||
|
||||
return $items
|
||||
})
|
||||
|
||||
this.emitItem(key, value)
|
||||
}
|
||||
|
||||
delete = (key: string) => {
|
||||
this.store.update($items => {
|
||||
$items.delete(key)
|
||||
|
||||
return $items
|
||||
})
|
||||
|
||||
this.emitItem(key, undefined)
|
||||
}
|
||||
|
||||
clear = () => {
|
||||
const keys = Array.from(this.index.get().keys())
|
||||
|
||||
this.store.set(new Map())
|
||||
|
||||
for (const key of keys) {
|
||||
this.emitItem(key, undefined)
|
||||
}
|
||||
}
|
||||
|
||||
onItem = (subscriber: (key: string, value: Maybe<T>) => void): Unsubscriber => {
|
||||
this.subs.push(subscriber)
|
||||
|
||||
return () => {
|
||||
const i = this.subs.indexOf(subscriber)
|
||||
|
||||
if (i !== -1) this.subs.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
protected emitItem = (key: string, value: Maybe<T>) => {
|
||||
for (const subscriber of this.subs) {
|
||||
subscriber(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A `MapPlugin` collection that knows how to lazily load items by key from the
|
||||
* network. Subclasses implement `fetch`; `load`/`forceLoad`/`one` are derived
|
||||
* from it (with per-key caching and backoff via `makeLoadItem`).
|
||||
*/
|
||||
export abstract class LoadableMapPlugin<T> extends MapPlugin<T> {
|
||||
load: (key: string, ...args: any[]) => Promise<Maybe<T>>
|
||||
forceLoad: (key: string, ...args: any[]) => Promise<Maybe<T>>
|
||||
|
||||
abstract fetch(key: string, ...args: any[]): Promise<unknown>
|
||||
|
||||
constructor(app: IApp, options: MakeLoadItemOptions = {}) {
|
||||
super(app)
|
||||
|
||||
// Subclasses implement `fetch` as an arrow field, whose initializer runs
|
||||
// *after* super() — so `this.fetch` is undefined here. makeLoadItem captures
|
||||
// its loadItem eagerly, so we defer the lookup to call time via this wrapper.
|
||||
const fetch = (key: string, ...args: any[]) => this.fetch(key, ...args)
|
||||
const read = (key: string) => this.index.get().get(key)
|
||||
|
||||
this.load = makeLoadItem(fetch, read, options)
|
||||
this.forceLoad = makeForceLoadItem(fetch, read)
|
||||
this.one = makeDeriveItem(this.store, this.load)
|
||||
}
|
||||
}
|
||||
|
||||
export type DerivedPluginOptions<T> = {
|
||||
filters: Filter[]
|
||||
eventToItem: EventToItem<T>
|
||||
getKey: (item: T) => string
|
||||
loadOptions?: MakeLoadItemOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for a reactive, keyed collection of data derived from nostr events.
|
||||
* The repository is the single source of truth — the collection is a live view
|
||||
* over `app.itemsByKey`, never a duplicated map. Subclasses implement `fetch`
|
||||
* (how to load an item by key from the network) and pass the filters/decoder via
|
||||
* `super`.
|
||||
*
|
||||
* `index` (map) and `all` (values) are `Projection`s — subscribe via `.$`,
|
||||
* snapshot via `.get()`. Per-key access is `one(key)`, a plain on-demand store.
|
||||
*/
|
||||
export abstract class DerivedPlugin<T> {
|
||||
index: Projection<ItemsByKey<T>>
|
||||
all: Projection<T[]>
|
||||
one: (key?: string, ...args: any[]) => Readable<Maybe<T>>
|
||||
load: (key: string, ...args: any[]) => Promise<Maybe<T>>
|
||||
forceLoad: (key: string, ...args: any[]) => Promise<Maybe<T>>
|
||||
|
||||
abstract fetch(key: string, ...args: any[]): Promise<unknown>
|
||||
|
||||
constructor(
|
||||
protected readonly app: IApp,
|
||||
options: DerivedPluginOptions<T>,
|
||||
) {
|
||||
const index = app.use(Stores).itemsByKey<T>({
|
||||
filters: options.filters,
|
||||
eventToItem: options.eventToItem,
|
||||
getKey: options.getKey,
|
||||
})
|
||||
|
||||
this.index = projection(index)
|
||||
this.all = projection(deriveItems(index))
|
||||
|
||||
const fetch = (key: string, ...args: any[]) => this.fetch(key, ...args)
|
||||
const read = (key: string) => this.index.get().get(key)
|
||||
|
||||
this.load = makeLoadItem(fetch, read, options.loadOptions)
|
||||
this.forceLoad = makeForceLoadItem(fetch, read)
|
||||
this.one = makeDeriveItem(index, this.load)
|
||||
}
|
||||
|
||||
get = (key: string) => this.index.get().get(key)
|
||||
|
||||
project = <U>(key: string, read: (item: Maybe<T>) => U): Projection<U> =>
|
||||
projection(derived(this.one(key), read), () => read(this.get(key)))
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
BLOCKED_RELAYS,
|
||||
asDecryptedEvent,
|
||||
readList,
|
||||
getRelaysFromList,
|
||||
makeList,
|
||||
makeEvent,
|
||||
addToListPublicly,
|
||||
removeFromList,
|
||||
} from "@welshman/util"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
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"
|
||||
|
||||
/**
|
||||
* Kind-10006 blocked-relay lists, keyed by pubkey. Loaded via the outbox model,
|
||||
* so it depends on the relay-list collection. Feeds `RelayStats.getQuality` so
|
||||
* blocked relays are never selected.
|
||||
*/
|
||||
export class BlockedRelayLists extends DerivedPlugin<ReturnType<typeof readList>> {
|
||||
constructor(app: IApp) {
|
||||
super(app, {
|
||||
filters: [{kinds: [BLOCKED_RELAYS]}],
|
||||
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
||||
getKey: list => list.event.pubkey,
|
||||
})
|
||||
}
|
||||
|
||||
fetch(pubkey: string, relayHints: string[] = []) {
|
||||
return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [BLOCKED_RELAYS]}, relayHints)
|
||||
}
|
||||
|
||||
urls = (pubkey: string): Projection<string[]> =>
|
||||
this.project(pubkey, list => getRelaysFromList(list))
|
||||
|
||||
addRelay = async (url: string) => {
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
return this.app.use(Thunks).publishToOutbox({event})
|
||||
}
|
||||
|
||||
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(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {BLOSSOM_SERVERS, asDecryptedEvent, readList} from "@welshman/util"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {DerivedPlugin} from "./base.js"
|
||||
import {Network} from "./network.js"
|
||||
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>> {
|
||||
constructor(app: IApp) {
|
||||
super(app, {
|
||||
filters: [{kinds: [BLOSSOM_SERVERS]}],
|
||||
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
||||
getKey: list => list.event.pubkey,
|
||||
})
|
||||
}
|
||||
|
||||
fetch(pubkey: string, relayHints: string[] = []) {
|
||||
return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [BLOSSOM_SERVERS]}, relayHints)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import {Scope, FeedController} from "@welshman/feeds"
|
||||
import type {FeedControllerOptions, Feed} from "@welshman/feeds"
|
||||
import type {AdapterContext} from "@welshman/net"
|
||||
import type {IApp} from "../app.js"
|
||||
import {Router} from "./router.js"
|
||||
import {Wot} from "./wot.js"
|
||||
|
||||
export type MakeFeedControllerOptions = Partial<Omit<FeedControllerOptions, "feed">> & {feed: Feed}
|
||||
|
||||
/**
|
||||
* Builds `FeedController`s wired to this app. Scope/WOT pubkey resolution is
|
||||
* delegated to `Wot`, and feeds fetch through THIS app's net context (pool +
|
||||
* repository) rather than the global one.
|
||||
*/
|
||||
export class Feeds {
|
||||
constructor(readonly app: IApp) {}
|
||||
|
||||
getPubkeysForScope = (scope: Scope): string[] => {
|
||||
const $pubkey = this.app.user?.pubkey
|
||||
|
||||
if (!$pubkey) {
|
||||
return []
|
||||
}
|
||||
|
||||
switch (scope) {
|
||||
case Scope.Self:
|
||||
return [$pubkey]
|
||||
case Scope.Follows:
|
||||
return this.app.use(Wot).follows($pubkey).get()
|
||||
case Scope.Network:
|
||||
return this.app.use(Wot).network($pubkey).get()
|
||||
case Scope.Followers:
|
||||
return this.app.use(Wot).followers($pubkey).get()
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
getPubkeysForWOTRange = (min: number, max: number): string[] => {
|
||||
const pubkeys = []
|
||||
const $maxWot = this.app.use(Wot).max.get() ?? 0
|
||||
const thresholdMin = $maxWot * min
|
||||
const thresholdMax = $maxWot * max
|
||||
|
||||
for (const [tpk, score] of this.app.use(Wot).graph.get().entries()) {
|
||||
if (score >= thresholdMin && score <= thresholdMax) {
|
||||
pubkeys.push(tpk)
|
||||
}
|
||||
}
|
||||
|
||||
return pubkeys
|
||||
}
|
||||
|
||||
// The net seam: route feed requests through this app's pool/repository so
|
||||
// feeds fetch through THIS app rather than the global net context.
|
||||
get netContext(): AdapterContext {
|
||||
return {pool: this.app.pool, repository: this.app.repository}
|
||||
}
|
||||
|
||||
makeFeedController = (options: MakeFeedControllerOptions) =>
|
||||
new FeedController({
|
||||
router: this.app.use(Router),
|
||||
getPubkeysForScope: this.getPubkeysForScope,
|
||||
getPubkeysForWOTRange: this.getPubkeysForWOTRange,
|
||||
signer: this.app.user?.signer,
|
||||
context: this.netContext,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
FOLLOWS,
|
||||
asDecryptedEvent,
|
||||
readList,
|
||||
makeList,
|
||||
addToListPublicly,
|
||||
removeFromList,
|
||||
} from "@welshman/util"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {DerivedPlugin} from "./base.js"
|
||||
import {Network} from "./network.js"
|
||||
import {Thunks} from "./thunk.js"
|
||||
import {User} from "../user.js"
|
||||
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>> {
|
||||
constructor(app: IApp) {
|
||||
super(app, {
|
||||
filters: [{kinds: [FOLLOWS]}],
|
||||
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
||||
getKey: followList => followList.event.pubkey,
|
||||
})
|
||||
}
|
||||
|
||||
fetch(pubkey: string, relayHints: string[] = []) {
|
||||
return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [FOLLOWS]}, relayHints)
|
||||
}
|
||||
|
||||
follow = async (tag: string[]) => {
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
return this.app.use(Thunks).publishToOutbox({event})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import {tryCatch, batcher, postJson} from "@welshman/lib"
|
||||
import type {Maybe} from "@welshman/lib"
|
||||
import {queryProfile, displayNip05} from "@welshman/util"
|
||||
import type {Handle} from "@welshman/util"
|
||||
import {deriveDeduplicated} from "@welshman/store"
|
||||
import {LoadableMapPlugin, projection} from "./base.js"
|
||||
import type {Projection} from "./base.js"
|
||||
import type {IApp} from "../app.js"
|
||||
import {Profiles} from "./profiles.js"
|
||||
|
||||
/**
|
||||
* NIP-05 handles, keyed by nip05 identifier. A "local" loadable collection:
|
||||
* items aren't nostr events, they're fetched over HTTP (either directly from
|
||||
* each domain's `.well-known/nostr.json`, or via a dufflepud proxy to protect
|
||||
* user privacy). Depends on the profiles collection to resolve a pubkey's
|
||||
* handle.
|
||||
*/
|
||||
export class Handles extends LoadableMapPlugin<Handle> {
|
||||
constructor(app: IApp) {
|
||||
super(app)
|
||||
}
|
||||
|
||||
fetch = batcher(800, async (nip05s: string[]) => {
|
||||
const result = new Map<string, Handle>()
|
||||
|
||||
// Use dufflepud if it's set up to protect user privacy, otherwise fetch directly
|
||||
if (this.app.config.dufflepudUrl) {
|
||||
const res: any = await tryCatch(
|
||||
async () =>
|
||||
await postJson(`${this.app.config.dufflepudUrl}/handle/info`, {handles: nip05s}),
|
||||
)
|
||||
|
||||
for (const {handle: nip05, info} of res?.data || []) {
|
||||
if (info) {
|
||||
result.set(nip05, {...info, nip05})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const results = await Promise.all(
|
||||
nip05s.map(async nip05 => ({
|
||||
nip05,
|
||||
info: await tryCatch(async () => await queryProfile(nip05)),
|
||||
})),
|
||||
)
|
||||
|
||||
for (const {nip05, info} of results) {
|
||||
if (info) {
|
||||
result.set(nip05, {...info, nip05})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [nip05, info] of result) {
|
||||
this.set(nip05, info)
|
||||
}
|
||||
|
||||
return nip05s.map(nip05 => result.get(nip05))
|
||||
})
|
||||
|
||||
loadForPubkey = async (pubkey: string, relays: string[] = []) => {
|
||||
const $profile = await this.app.use(Profiles).load(pubkey, relays)
|
||||
|
||||
return $profile?.nip05 ? this.load($profile.nip05) : undefined
|
||||
}
|
||||
|
||||
forPubkey = (pubkey: string, relays: string[] = []): Projection<Maybe<Handle>> => {
|
||||
this.loadForPubkey(pubkey, relays)
|
||||
|
||||
const read = ([$handlesByNip05, $profile]: [ReadonlyMap<string, Handle>, Maybe<{nip05?: string}>]) => {
|
||||
if (!$profile?.nip05) return undefined
|
||||
|
||||
const handle = $handlesByNip05.get($profile.nip05)
|
||||
|
||||
if (handle?.pubkey !== pubkey) return undefined
|
||||
|
||||
return handle
|
||||
}
|
||||
|
||||
return projection(
|
||||
deriveDeduplicated([this.index.$, this.app.use(Profiles).one(pubkey, relays)], read),
|
||||
() => read([this.index.get(), this.app.use(Profiles).get(pubkey)]),
|
||||
)
|
||||
}
|
||||
|
||||
display = (nip05: string) => displayNip05(nip05)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
MESSAGING_RELAYS,
|
||||
asDecryptedEvent,
|
||||
readList,
|
||||
getRelaysFromList,
|
||||
makeList,
|
||||
makeEvent,
|
||||
addToListPublicly,
|
||||
removeFromList,
|
||||
} from "@welshman/util"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
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"
|
||||
|
||||
/**
|
||||
* Kind-10050 messaging relay lists (NIP-17), keyed by pubkey. Loaded via the
|
||||
* outbox model (the author's write relays), so it depends on the relay-list
|
||||
* collection.
|
||||
*/
|
||||
export class MessagingRelayLists extends DerivedPlugin<ReturnType<typeof readList>> {
|
||||
constructor(app: IApp) {
|
||||
super(app, {
|
||||
filters: [{kinds: [MESSAGING_RELAYS]}],
|
||||
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
||||
getKey: list => list.event.pubkey,
|
||||
})
|
||||
}
|
||||
|
||||
fetch(pubkey: string, relayHints: string[] = []) {
|
||||
return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [MESSAGING_RELAYS]}, relayHints)
|
||||
}
|
||||
|
||||
urls = (pubkey: string): Projection<string[]> =>
|
||||
this.project(pubkey, list => getRelaysFromList(list))
|
||||
|
||||
addRelay = async (url: string) => {
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
return this.app.use(Thunks).publishToOutbox({event})
|
||||
}
|
||||
|
||||
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(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
MUTES,
|
||||
asDecryptedEvent,
|
||||
readList,
|
||||
makeList,
|
||||
addToListPublicly,
|
||||
addToListPrivately,
|
||||
removeFromList,
|
||||
updateList,
|
||||
} from "@welshman/util"
|
||||
import type {TrustedEvent, PublishedList} from "@welshman/util"
|
||||
import {DerivedPlugin} from "./base.js"
|
||||
import type {IApp} from "../app.js"
|
||||
import {Network} from "./network.js"
|
||||
import {Thunks} from "./thunk.js"
|
||||
import {Plaintext} from "./plaintext.js"
|
||||
import {User} from "../user.js"
|
||||
|
||||
/**
|
||||
* Kind-10000 mute lists, keyed by pubkey. Mute lists carry private entries in
|
||||
* encrypted content, so decoding goes through the plaintext cache.
|
||||
*/
|
||||
export class MuteLists extends DerivedPlugin<PublishedList> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
fetch(pubkey: string, relayHints: string[] = []) {
|
||||
return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [MUTES]}, relayHints)
|
||||
}
|
||||
|
||||
mutePublicly = async (tag: string[]) => {
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
return this.app.use(Thunks).publishToOutbox({event})
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import {chunk, first} from "@welshman/lib"
|
||||
import {RelayMode, getRelaysFromList, sortEventsDesc} from "@welshman/util"
|
||||
import type {Filter} from "@welshman/util"
|
||||
import {request, publish, diff, pull, push, makeLoader} from "@welshman/net"
|
||||
import type {
|
||||
Loader,
|
||||
LoaderOptions,
|
||||
RequestOptions,
|
||||
PublishOptions,
|
||||
DiffOptions,
|
||||
PullOptions,
|
||||
PushOptions,
|
||||
} from "@welshman/net"
|
||||
import {addMinimalFallbacks} from "@welshman/router"
|
||||
import {Router} from "./router.js"
|
||||
import {RelayLists} from "./relayLists.js"
|
||||
import type {IApp} from "../app.js"
|
||||
|
||||
/**
|
||||
* Net utilities bound to the app's net context (its pool + repository). Reach
|
||||
* it via `app.use(Network)`; `load` is a shared, batched loader.
|
||||
*/
|
||||
export class Network {
|
||||
load: Loader
|
||||
|
||||
constructor(readonly app: IApp) {
|
||||
this.load = this.makeLoader({delay: 50, timeout: 3000, threshold: 0.5})
|
||||
}
|
||||
|
||||
makeLoader = (options: Omit<LoaderOptions, "context">): Loader =>
|
||||
makeLoader({...options, context: this.app.netContext})
|
||||
|
||||
request = (options: Omit<RequestOptions, "context">) =>
|
||||
request({...options, context: this.app.netContext})
|
||||
|
||||
publish = (options: Omit<PublishOptions, "context">) =>
|
||||
publish({...options, context: this.app.netContext})
|
||||
|
||||
diff = (options: Omit<DiffOptions, "context">) => diff({...options, context: this.app.netContext})
|
||||
|
||||
pull = (options: Omit<PullOptions, "context">) => pull({...options, context: this.app.netContext})
|
||||
|
||||
push = (options: Omit<PushOptions, "context">) => push({...options, context: this.app.netContext})
|
||||
|
||||
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 allRelays = this.app
|
||||
.use(Router)
|
||||
.FromRelays([...relayHints, ...writeRelays])
|
||||
.policy(addMinimalFallbacks)
|
||||
.limit(8)
|
||||
.getUrls()
|
||||
|
||||
for (const relays of chunk(2, allRelays)) {
|
||||
const events = await this.load({filters, relays})
|
||||
|
||||
if (events.length > 0) {
|
||||
return first(sortEventsDesc(events))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
PINS,
|
||||
asDecryptedEvent,
|
||||
readList,
|
||||
makeList,
|
||||
addToListPublicly,
|
||||
removeFromList,
|
||||
} from "@welshman/util"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {DerivedPlugin} from "./base.js"
|
||||
import {Network} from "./network.js"
|
||||
import {Thunks} from "./thunk.js"
|
||||
import {User} from "../user.js"
|
||||
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>> {
|
||||
constructor(app: IApp) {
|
||||
super(app, {
|
||||
filters: [{kinds: [PINS]}],
|
||||
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
||||
getKey: pins => pins.event.pubkey,
|
||||
})
|
||||
}
|
||||
|
||||
fetch(pubkey: string, relayHints: string[] = []) {
|
||||
return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [PINS]}, relayHints)
|
||||
}
|
||||
|
||||
pin = async (tag: string[]) => {
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
return this.app.use(Thunks).publishToOutbox({event})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {decrypt} from "@welshman/signer"
|
||||
import type {Maybe} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {MapPlugin} from "./base.js"
|
||||
|
||||
/**
|
||||
* A cache of decrypted event content, keyed by event id.
|
||||
*/
|
||||
export class Plaintext extends MapPlugin<string> {
|
||||
ensure = async (event: TrustedEvent): Promise<Maybe<string>> => {
|
||||
if (this.app.user?.pubkey !== event.pubkey) return
|
||||
|
||||
let result = this.get(event.id)
|
||||
if (event.content && result === undefined) {
|
||||
try {
|
||||
result = await decrypt(this.app.user.signer, event.pubkey, event.content)
|
||||
this.set(event.id, result)
|
||||
} catch (e: any) {
|
||||
if (!String(e).match(/invalid base64/)) {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {derived, readable} from "svelte/store"
|
||||
import {
|
||||
readProfile,
|
||||
displayProfile,
|
||||
displayPubkey,
|
||||
isPublishedProfile,
|
||||
createProfile,
|
||||
editProfile,
|
||||
PROFILE,
|
||||
} from "@welshman/util"
|
||||
import type {Profile} from "@welshman/util"
|
||||
import type {Maybe} from "@welshman/lib"
|
||||
import {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 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>> {
|
||||
constructor(app: IApp) {
|
||||
super(app, {
|
||||
filters: [{kinds: [PROFILE]}],
|
||||
eventToItem: readProfile,
|
||||
getKey: profile => profile.event.pubkey,
|
||||
})
|
||||
}
|
||||
|
||||
fetch(pubkey: string, relayHints: string[] = []) {
|
||||
return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [PROFILE]}, relayHints)
|
||||
}
|
||||
|
||||
publish = (profile: Profile) => {
|
||||
const router = this.app.use(Router)
|
||||
const relays = router.merge([router.Index(), router.FromUser()]).getUrls()
|
||||
const event = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile)
|
||||
|
||||
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)) : ""
|
||||
|
||||
return projection(
|
||||
pubkey ? derived(this.one(pubkey, ...args), read) : readable(""),
|
||||
() => read(pubkey ? this.get(pubkey) : undefined),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
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 {DerivedPlugin} from "./base.js"
|
||||
import type {Projection} from "./base.js"
|
||||
import {addMinimalFallbacks} from "@welshman/router"
|
||||
import {Router} from "./router.js"
|
||||
import {Network} from "./network.js"
|
||||
import {User} from "../user.js"
|
||||
import {Thunks} from "./thunk.js"
|
||||
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> {
|
||||
constructor(app: IApp) {
|
||||
super(app, {
|
||||
filters: [{kinds: [RELAYS]}],
|
||||
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
||||
getKey: (list: PublishedList) => list.event.pubkey,
|
||||
})
|
||||
}
|
||||
|
||||
fetch(pubkey: string, relayHints: string[] = []) {
|
||||
const filters = [{kinds: [RELAYS], authors: [pubkey], limit: 1}]
|
||||
const networking = this.app.use(Network)
|
||||
const router = this.app.use(Router)
|
||||
|
||||
return Promise.all([
|
||||
networking.load({filters, relays: router.FromRelays(relayHints).getUrls()}),
|
||||
networking.load({filters, relays: router.FromPubkey(pubkey).getUrls()}),
|
||||
networking.load({filters, relays: router.Index().getUrls()}),
|
||||
])
|
||||
}
|
||||
|
||||
urls = (pubkey: string): Projection<string[]> =>
|
||||
this.project(pubkey, list => getRelaysFromList(list))
|
||||
|
||||
readUrls = (pubkey: string): Projection<string[]> =>
|
||||
this.project(pubkey, list => getRelaysFromList(list, RelayMode.Read))
|
||||
|
||||
writeUrls = (pubkey: string): Projection<string[]> =>
|
||||
this.project(pubkey, list => getRelaysFromList(list, RelayMode.Write))
|
||||
|
||||
// NIP-65 relay-list mutations for the app's user
|
||||
|
||||
addRelay = 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 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.app.use(Thunks).publishToOutbox({event})
|
||||
}
|
||||
|
||||
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}
|
||||
|
||||
// publishToOutbox is outbox-only, so build relays here to also notify the
|
||||
// removed relay of its removal
|
||||
const relays = [url, ...this.app.use(Router).FromUser().policy(addMinimalFallbacks).getUrls()]
|
||||
|
||||
return this.app.use(Thunks).publish({event, relays})
|
||||
}
|
||||
|
||||
setRelays = (tags: string[][]) => {
|
||||
const router = this.app.use(Router)
|
||||
const event = makeEvent(RELAYS, {tags})
|
||||
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})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import {makeHttpAuth, sendManagementRequest} from "@welshman/util"
|
||||
import type {ManagementRequest} from "@welshman/util"
|
||||
import {User} from "../user.js"
|
||||
import type {IApp} from "../app.js"
|
||||
|
||||
/**
|
||||
* NIP-86 relay management. Signs an HTTP-auth event as the app's user and
|
||||
* sends an admin request to a relay's management endpoint.
|
||||
*/
|
||||
export class RelayManagement {
|
||||
constructor(readonly app: IApp) {}
|
||||
|
||||
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.app).sign(authTemplate)
|
||||
|
||||
return sendManagementRequest(url, request, authEvent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import {groupBy, batch, now, uniq, ago, DAY, HOUR, MINUTE} from "@welshman/lib"
|
||||
import {isOnionUrl, isLocalUrl, isIPAddress, isRelayUrl} from "@welshman/util"
|
||||
import {SocketStatus, SocketEvent} from "@welshman/net"
|
||||
import type {ClientMessage, RelayMessage, Socket} from "@welshman/net"
|
||||
import {MapPlugin} from "./base.js"
|
||||
import {BlockedRelayLists} from "./blockedRelayLists.js"
|
||||
|
||||
export type RelayStatsUpdate = [string, (stats: RelayStatsItem) => void]
|
||||
|
||||
export type RelayStatsItem = {
|
||||
url: string
|
||||
first_seen: number
|
||||
recent_errors: number[]
|
||||
open_count: number
|
||||
close_count: number
|
||||
publish_count: number
|
||||
request_count: number
|
||||
event_count: number
|
||||
last_open: number
|
||||
last_close: number
|
||||
last_error: number
|
||||
last_publish: number
|
||||
last_request: number
|
||||
last_event: number
|
||||
last_auth: number
|
||||
publish_success_count: number
|
||||
publish_failure_count: number
|
||||
eose_count: number
|
||||
notice_count: number
|
||||
}
|
||||
|
||||
export const makeRelayStatsItem = (url: string): RelayStatsItem => ({
|
||||
url,
|
||||
first_seen: now(),
|
||||
recent_errors: [],
|
||||
open_count: 0,
|
||||
close_count: 0,
|
||||
publish_count: 0,
|
||||
request_count: 0,
|
||||
event_count: 0,
|
||||
last_open: 0,
|
||||
last_close: 0,
|
||||
last_error: 0,
|
||||
last_publish: 0,
|
||||
last_request: 0,
|
||||
last_event: 0,
|
||||
last_auth: 0,
|
||||
publish_success_count: 0,
|
||||
publish_failure_count: 0,
|
||||
eose_count: 0,
|
||||
notice_count: 0,
|
||||
})
|
||||
|
||||
/**
|
||||
* Per-relay connection statistics, keyed by url, plus the `getQuality` heuristic
|
||||
* the router uses to rank relays. A pure store — the socket wiring that fills it
|
||||
* lives in `appPolicyRelayStats`.
|
||||
*/
|
||||
export class RelayStats extends MapPlugin<RelayStatsItem> {
|
||||
getQuality = (url: string) => {
|
||||
// Skip non-relays entirely
|
||||
if (!isRelayUrl(url)) return 0
|
||||
|
||||
// Skip relays the user has blocked
|
||||
const pubkey = this.app.user?.pubkey
|
||||
|
||||
if (pubkey && this.app.use(BlockedRelayLists).urls(pubkey).get().includes(url)) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const stats = this.get(url)
|
||||
|
||||
// If we have recent errors, skip it
|
||||
if (stats) {
|
||||
if (stats.recent_errors.filter(n => n > ago(MINUTE)).length > 0) return 0
|
||||
if (stats.recent_errors.filter(n => n > ago(HOUR)).length > 3) return 0
|
||||
if (stats.recent_errors.filter(n => n > ago(DAY)).length > 10) return 0
|
||||
}
|
||||
|
||||
// Prefer stuff we're connected to
|
||||
if (this.app.pool.has(url)) return 1
|
||||
|
||||
// Prefer stuff we've connected to in the past
|
||||
if (stats) return 0.9
|
||||
|
||||
// If it's not a weird url give it an ok score
|
||||
if (!isIPAddress(url) && !isLocalUrl(url) && !isOnionUrl(url) && !url.startsWith("ws://")) {
|
||||
return 0.8
|
||||
}
|
||||
|
||||
// Default to a "meh" score
|
||||
return 0.7
|
||||
}
|
||||
|
||||
private update = batch(150, (batched: RelayStatsUpdate[]) => {
|
||||
for (const [url, updates] of groupBy(([url]) => url, batched)) {
|
||||
if (!url || !isRelayUrl(url)) {
|
||||
console.warn(`Attempted to update stats for an invalid relay url: ${url}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const prev = this.get(url)
|
||||
const next = prev ? {...prev} : makeRelayStatsItem(url)
|
||||
|
||||
for (const [, update] of updates) {
|
||||
update(next)
|
||||
}
|
||||
|
||||
this.set(url, next)
|
||||
}
|
||||
})
|
||||
|
||||
private onSocketSend = ([verb]: ClientMessage, url: string) => {
|
||||
if (verb === "REQ") {
|
||||
this.update([
|
||||
url,
|
||||
stats => {
|
||||
stats.request_count++
|
||||
stats.last_request = now()
|
||||
},
|
||||
])
|
||||
} else if (verb === "EVENT") {
|
||||
this.update([
|
||||
url,
|
||||
stats => {
|
||||
stats.publish_count++
|
||||
stats.last_publish = now()
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
private onSocketReceive = ([verb, ...extra]: RelayMessage, url: string) => {
|
||||
if (verb === "OK") {
|
||||
const [, ok] = extra
|
||||
|
||||
this.update([
|
||||
url,
|
||||
stats => {
|
||||
if (ok) {
|
||||
stats.publish_success_count++
|
||||
} else {
|
||||
stats.publish_failure_count++
|
||||
}
|
||||
},
|
||||
])
|
||||
} else if (verb === "AUTH") {
|
||||
this.update([url, stats => (stats.last_auth = now())])
|
||||
} else if (verb === "EVENT") {
|
||||
this.update([
|
||||
url,
|
||||
stats => {
|
||||
stats.event_count++
|
||||
stats.last_event = now()
|
||||
},
|
||||
])
|
||||
} else if (verb === "EOSE") {
|
||||
this.update([url, stats => stats.eose_count++])
|
||||
} else if (verb === "NOTICE") {
|
||||
this.update([url, stats => stats.notice_count++])
|
||||
}
|
||||
}
|
||||
|
||||
private onSocketStatus = (status: string, url: string) => {
|
||||
if (status === SocketStatus.Open) {
|
||||
this.update([
|
||||
url,
|
||||
stats => {
|
||||
stats.last_open = now()
|
||||
stats.open_count++
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
if (status === SocketStatus.Closed) {
|
||||
this.update([
|
||||
url,
|
||||
stats => {
|
||||
stats.last_close = now()
|
||||
stats.close_count++
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
if (status === SocketStatus.Error) {
|
||||
this.update([
|
||||
url,
|
||||
stats => {
|
||||
stats.last_error = now()
|
||||
stats.recent_errors = uniq(stats.recent_errors.concat(now())).slice(-10)
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
monitorSocket = (socket: Socket) => {
|
||||
socket.on(SocketEvent.Send, this.onSocketSend)
|
||||
socket.on(SocketEvent.Receive, this.onSocketReceive)
|
||||
socket.on(SocketEvent.Status, this.onSocketStatus)
|
||||
|
||||
return () => {
|
||||
socket.off(SocketEvent.Send, this.onSocketSend)
|
||||
socket.off(SocketEvent.Receive, this.onSocketReceive)
|
||||
socket.off(SocketEvent.Status, this.onSocketStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {fetchJson} from "@welshman/lib"
|
||||
import type {Maybe} from "@welshman/lib"
|
||||
import {displayRelayUrl, displayRelayProfile} from "@welshman/util"
|
||||
import type {RelayProfile} from "@welshman/util"
|
||||
import {LoadableMapPlugin} from "./base.js"
|
||||
import type {Projection} from "./base.js"
|
||||
|
||||
/**
|
||||
* NIP-11 relay profiles, keyed by url. A "local" loadable collection: items
|
||||
* aren't nostr events, they're fetched over HTTP from each relay.
|
||||
*/
|
||||
export class Relays extends LoadableMapPlugin<RelayProfile> {
|
||||
fetch = async (url: string): Promise<Maybe<RelayProfile>> => {
|
||||
try {
|
||||
const json = await fetchJson(url.replace(/^ws/, "http"), {
|
||||
headers: {
|
||||
Accept: "application/nostr+json",
|
||||
},
|
||||
})
|
||||
|
||||
if (json) {
|
||||
const info = {...json, url} as RelayProfile
|
||||
|
||||
if (!Array.isArray(info.supported_nips)) {
|
||||
info.supported_nips = []
|
||||
}
|
||||
|
||||
info.supported_nips = info.supported_nips.map(String)
|
||||
|
||||
this.set(url, info)
|
||||
|
||||
return info
|
||||
}
|
||||
} catch (e) {
|
||||
// pass
|
||||
}
|
||||
}
|
||||
|
||||
display = (url: string): Projection<string> =>
|
||||
this.project(url, $relay => displayRelayProfile($relay, displayRelayUrl(url)))
|
||||
|
||||
hasNegentropy = async (url: string) => {
|
||||
const relay = await this.load(url)
|
||||
|
||||
if (relay?.negentropy) return true
|
||||
if (relay?.supported_nips?.includes("77")) return true
|
||||
if (relay?.software?.includes?.("strfry") && !relay?.version?.match(/^0\./)) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
hasNip = async (url: string, nip: number | string) =>
|
||||
(await this.load(url))?.supported_nips?.includes(String(nip)) ?? false
|
||||
}
|
||||
@@ -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 {IApp} from "../app.js"
|
||||
|
||||
/**
|
||||
* NIP-29 relay-based group (room) management. Each method publishes the relevant
|
||||
* room event to the given relay as the app's user.
|
||||
*/
|
||||
export class Rooms {
|
||||
constructor(readonly app: IApp) {}
|
||||
|
||||
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))
|
||||
|
||||
delete = (url: string, room: RoomMeta) => this.publish(url, makeRoomDeleteEvent(room))
|
||||
|
||||
edit = (url: string, room: RoomMeta) => this.publish(url, makeRoomEditEvent(room))
|
||||
|
||||
join = (url: string, room: RoomMeta) => this.publish(url, makeRoomJoinEvent(room))
|
||||
|
||||
leave = (url: string, room: RoomMeta) => this.publish(url, makeRoomLeaveEvent(room))
|
||||
|
||||
addMember = (url: string, room: RoomMeta, pubkey: string) =>
|
||||
this.publish(url, makeRoomAddMemberEvent(room, pubkey))
|
||||
|
||||
removeMember = (url: string, room: RoomMeta, pubkey: string) =>
|
||||
this.publish(url, makeRoomRemoveMemberEvent(room, pubkey))
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import {RelayMode} from "@welshman/util"
|
||||
import {Router as BaseRouter} from "@welshman/router"
|
||||
import {RelayLists} from "./relayLists.js"
|
||||
import {RelayStats} from "./relayStats.js"
|
||||
import type {IApp} from "../app.js"
|
||||
|
||||
/**
|
||||
* The upstream `@welshman/router` Router, wired to this app: relay lists come
|
||||
* from the `RelayLists` collection, quality from `RelayStats`, and the user
|
||||
* pubkey + relay-getters from the app (via `app.config`). Reach it via
|
||||
* `app.use(Router)`. This replaces the old forked copy — one source of truth,
|
||||
* no global `routerContext`/`Router.get()`.
|
||||
*/
|
||||
export class Router extends BaseRouter {
|
||||
constructor(app: IApp) {
|
||||
super({
|
||||
getUserPubkey: () => app.user?.pubkey,
|
||||
getPubkeyRelays: (pubkey, mode) =>
|
||||
(mode === RelayMode.Read
|
||||
? app.use(RelayLists).readUrls(pubkey)
|
||||
: app.use(RelayLists).writeUrls(pubkey)
|
||||
).get(),
|
||||
getRelayQuality: url => app.use(RelayStats).getQuality(url),
|
||||
getDefaultRelays: app.config.getDefaultRelays,
|
||||
getIndexerRelays: app.config.getIndexerRelays,
|
||||
getSearchRelays: app.config.getSearchRelays,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import Fuse from "fuse.js"
|
||||
import type {IFuseOptions, FuseResult} from "fuse.js"
|
||||
import {debounce} from "throttle-debounce"
|
||||
import {derived} from "svelte/store"
|
||||
import type {Readable} from "svelte/store"
|
||||
import {dec, inc, sortBy} from "@welshman/lib"
|
||||
import {PROFILE} from "@welshman/util"
|
||||
import type {PublishedProfile, RelayProfile} from "@welshman/util"
|
||||
import {throttled} from "@welshman/store"
|
||||
import type {IApp} from "../app.js"
|
||||
import {Network} from "./network.js"
|
||||
import {Router} from "./router.js"
|
||||
import {Profiles} from "./profiles.js"
|
||||
import {Topics} from "./topics.js"
|
||||
import type {Topic} from "./topics.js"
|
||||
import {Relays} from "./relays.js"
|
||||
import {Handles} from "./handles.js"
|
||||
import {Wot} from "./wot.js"
|
||||
|
||||
export type SearchOptions<V, T> = {
|
||||
getValue: (item: T) => V
|
||||
fuseOptions?: IFuseOptions<T>
|
||||
onSearch?: (term: string) => void
|
||||
sortFn?: (items: FuseResult<T>) => any
|
||||
}
|
||||
|
||||
export type Search<V, T> = {
|
||||
options: T[]
|
||||
getValue: (item: T) => V
|
||||
getOption: (value: V) => T | undefined
|
||||
searchOptions: (term: string) => T[]
|
||||
searchValues: (term: string) => V[]
|
||||
}
|
||||
|
||||
export const createSearch = <V, T>(options: T[], opts: SearchOptions<V, T>): Search<V, T> => {
|
||||
const fuse = new Fuse(options, {...opts.fuseOptions, includeScore: true})
|
||||
const map = new Map<V, T>(options.map(item => [opts.getValue(item), item]))
|
||||
|
||||
const search = (term: string) => {
|
||||
opts.onSearch?.(term)
|
||||
|
||||
let results = term ? fuse.search(term) : options.map(item => ({item}) as FuseResult<T>)
|
||||
|
||||
if (opts.sortFn) {
|
||||
results = sortBy(opts.sortFn, results)
|
||||
}
|
||||
|
||||
return results.map(result => result.item)
|
||||
}
|
||||
|
||||
return {
|
||||
options,
|
||||
getValue: opts.getValue,
|
||||
getOption: (value: V) => map.get(value),
|
||||
searchOptions: (term: string) => search(term),
|
||||
searchValues: (term: string) => search(term).map(opts.getValue),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive fuzzy searches over the app's profiles, topics, and relays.
|
||||
* `profileSearch` blends fuse scores with web-of-trust weight (via `Wot`) and
|
||||
* fires a debounced NIP-50 network search through the app's loader.
|
||||
*/
|
||||
export class Searches {
|
||||
profileSearch: Readable<Search<string, PublishedProfile>>
|
||||
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, {
|
||||
onSearch: this.searchProfiles,
|
||||
getValue: (profile: PublishedProfile) => profile.event.pubkey,
|
||||
sortFn: ({score = 1, item}) => {
|
||||
const wotScore = this.app.use(Wot).graph.get().get(item.event.pubkey) || 0
|
||||
|
||||
return dec(score) * inc(wotScore / (this.app.use(Wot).max.get() || 1))
|
||||
},
|
||||
fuseOptions: {
|
||||
keys: [
|
||||
"nip05",
|
||||
{name: "name", weight: 0.8},
|
||||
{name: "display_name", weight: 0.5},
|
||||
{name: "about", weight: 0.3},
|
||||
],
|
||||
threshold: 0.3,
|
||||
shouldSort: false,
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
this.topicSearch = derived(this.app.use(Topics).all, $topics =>
|
||||
createSearch($topics, {
|
||||
getValue: (topic: Topic) => topic.name,
|
||||
fuseOptions: {keys: ["name"]},
|
||||
}),
|
||||
)
|
||||
|
||||
this.relaySearch = derived(this.app.use(Relays).all.$, $relays =>
|
||||
createSearch($relays, {
|
||||
getValue: (relay: RelayProfile) => relay.url,
|
||||
fuseOptions: {
|
||||
keys: ["url", "name", {name: "description", weight: 0.3}],
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
searchProfiles = debounce(500, (search: string) => {
|
||||
if (search.length > 2) {
|
||||
this.app.use(Network).load({
|
||||
filters: [{kinds: [PROFILE], search}],
|
||||
relays: this.app.use(Router).Search().getUrls(),
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
SEARCH_RELAYS,
|
||||
asDecryptedEvent,
|
||||
readList,
|
||||
getRelaysFromList,
|
||||
makeList,
|
||||
makeEvent,
|
||||
addToListPublicly,
|
||||
removeFromList,
|
||||
} from "@welshman/util"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
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"
|
||||
|
||||
/**
|
||||
* NIP-51 search relay lists (kind 10007), keyed by pubkey. Loaded via the
|
||||
* outbox model (the author's write relays), so it depends on the relay-list
|
||||
* collection.
|
||||
*/
|
||||
export class SearchRelayLists extends DerivedPlugin<ReturnType<typeof readList>> {
|
||||
constructor(app: IApp) {
|
||||
super(app, {
|
||||
filters: [{kinds: [SEARCH_RELAYS]}],
|
||||
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
||||
getKey: searchRelayList => searchRelayList.event.pubkey,
|
||||
})
|
||||
}
|
||||
|
||||
fetch(pubkey: string, relayHints: string[] = []) {
|
||||
return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [SEARCH_RELAYS]}, relayHints)
|
||||
}
|
||||
|
||||
urls = (pubkey: string): Projection<string[]> =>
|
||||
this.project(pubkey, list => getRelaysFromList(list))
|
||||
|
||||
addRelay = async (url: string) => {
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
return this.app.use(Thunks).publishToOutbox({event})
|
||||
}
|
||||
|
||||
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(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
getEventsById,
|
||||
deriveEventsById,
|
||||
deriveEvents,
|
||||
makeDeriveEvent,
|
||||
getEventsByIdByUrl,
|
||||
deriveEventsByIdByUrl,
|
||||
getEventsByIdForUrl,
|
||||
deriveEventsByIdForUrl,
|
||||
deriveItemsByKey,
|
||||
deriveIsDeleted,
|
||||
} from "@welshman/store"
|
||||
import type {
|
||||
EventsByIdOptions,
|
||||
EventOptions,
|
||||
EventsByIdByUrlOptions,
|
||||
EventsByIdForUrlOptions,
|
||||
ItemsByKeyOptions,
|
||||
} from "@welshman/store"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import type {IApp} from "../app.js"
|
||||
|
||||
/**
|
||||
* Store/derivation utilities bound to the app's repository and tracker. Reach
|
||||
* it via `app.use(Stores)`.
|
||||
*/
|
||||
export class Stores {
|
||||
constructor(readonly app: IApp) {}
|
||||
|
||||
getEventsById = (options: Omit<EventsByIdOptions, "repository">) =>
|
||||
getEventsById({...options, repository: this.app.repository})
|
||||
|
||||
eventsById = (options: Omit<EventsByIdOptions, "repository">) =>
|
||||
deriveEventsById({...options, repository: this.app.repository})
|
||||
|
||||
events = (options: Omit<EventsByIdOptions, "repository">) =>
|
||||
deriveEvents({...options, repository: this.app.repository})
|
||||
|
||||
makeEvent = (options: Omit<EventOptions, "repository">) =>
|
||||
makeDeriveEvent({...options, repository: this.app.repository})
|
||||
|
||||
getEventsByIdByUrl = (options: Omit<EventsByIdByUrlOptions, "tracker" | "repository">) =>
|
||||
getEventsByIdByUrl({...options, tracker: this.app.tracker, repository: this.app.repository})
|
||||
|
||||
eventsByIdByUrl = (options: Omit<EventsByIdByUrlOptions, "tracker" | "repository">) =>
|
||||
deriveEventsByIdByUrl({...options, tracker: this.app.tracker, repository: this.app.repository})
|
||||
|
||||
getEventsByIdForUrl = (options: Omit<EventsByIdForUrlOptions, "tracker" | "repository">) =>
|
||||
getEventsByIdForUrl({...options, tracker: this.app.tracker, repository: this.app.repository})
|
||||
|
||||
eventsByIdForUrl = (options: Omit<EventsByIdForUrlOptions, "tracker" | "repository">) =>
|
||||
deriveEventsByIdForUrl({...options, tracker: this.app.tracker, repository: this.app.repository})
|
||||
|
||||
itemsByKey = <T>(options: Omit<ItemsByKeyOptions<T>, "repository">) =>
|
||||
deriveItemsByKey<T>({...options, repository: this.app.repository})
|
||||
|
||||
isDeleted = (event: TrustedEvent) => deriveIsDeleted(this.app.repository, event)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {isSignedEvent} from "@welshman/util"
|
||||
import type {Filter, SignedEvent} from "@welshman/util"
|
||||
import type {IApp} from "../app.js"
|
||||
import {Network} from "./network.js"
|
||||
import {Relays} from "./relays.js"
|
||||
|
||||
export type AppSyncOpts = {
|
||||
relays: string[]
|
||||
filters: Filter[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Negentropy-aware sync. Pulls/pushes events between the local repository and a
|
||||
* set of relays, using NIP-77 reconciliation where the relay supports it and
|
||||
* falling back to plain request/publish otherwise. Reads NIP-11 relay profiles
|
||||
* from the `Relays` collection to detect negentropy support.
|
||||
*/
|
||||
export class Sync {
|
||||
constructor(readonly app: IApp) {}
|
||||
|
||||
query = (filters: Filter[]) =>
|
||||
this.app.repository.query(filters, {shouldSort: filters.every(f => f.limit === undefined)})
|
||||
|
||||
pull = async ({relays, filters}: AppSyncOpts) => {
|
||||
const net = this.app.use(Network)
|
||||
const events = this.query(filters).filter(isSignedEvent)
|
||||
|
||||
await Promise.all(
|
||||
relays.map(async relay => {
|
||||
if (await this.app.use(Relays).hasNegentropy(relay)) {
|
||||
await net.pull({filters, events, relays: [relay]})
|
||||
} else {
|
||||
await net.request({filters, relays: [relay], autoClose: true})
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
push = async ({relays, filters}: AppSyncOpts) => {
|
||||
const net = this.app.use(Network)
|
||||
const events = this.query(filters).filter(isSignedEvent)
|
||||
|
||||
await Promise.all(
|
||||
relays.map(async relay => {
|
||||
if (await this.app.use(Relays).hasNegentropy(relay)) {
|
||||
await net.push({filters, events, relays: [relay]})
|
||||
} else {
|
||||
await Promise.all(events.map((event: SignedEvent) => net.publish({event, relays: [relay]})))
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import {uniq, remove} from "@welshman/lib"
|
||||
import {
|
||||
getAddress,
|
||||
isReplaceable,
|
||||
getReplyTags,
|
||||
getPubkeyTagValues,
|
||||
isReplaceableKind,
|
||||
isShareableRelayUrl,
|
||||
} from "@welshman/util"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {Router} from "./router.js"
|
||||
import {Profiles} from "./profiles.js"
|
||||
import type {IApp} from "../app.js"
|
||||
|
||||
/**
|
||||
* Builders for nostr tags (p/e/a/q/zap/reply/comment/reaction). Needs the router
|
||||
* for relay hints, the profiles collection for display names, and the app's
|
||||
* user to avoid self-tagging.
|
||||
*/
|
||||
export class Tags {
|
||||
constructor(readonly app: IApp) {}
|
||||
|
||||
tagZapSplit = (pubkey: string, split = 1) => [
|
||||
"zap",
|
||||
pubkey,
|
||||
this.app.use(Router).FromPubkey(pubkey).getUrl() || "",
|
||||
String(split),
|
||||
]
|
||||
|
||||
tagPubkey = (pubkey: string) => [
|
||||
"p",
|
||||
pubkey,
|
||||
this.app.use(Router).FromPubkey(pubkey).getUrl() || "",
|
||||
this.app.use(Profiles).display(pubkey).get(),
|
||||
]
|
||||
|
||||
tagEvent = (event: TrustedEvent, url = "", mark = "") => {
|
||||
if (!url) {
|
||||
url = this.app.use(Router).Event(event).getUrl() || ""
|
||||
}
|
||||
|
||||
const tags = [["e", event.id, url, mark, event.pubkey]]
|
||||
|
||||
if (isReplaceable(event)) {
|
||||
tags.push(["a", getAddress(event), url, mark, event.pubkey])
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
tagEventPubkeys = (event: TrustedEvent) =>
|
||||
uniq(
|
||||
remove(this.app.user?.pubkey ?? "", [event.pubkey, ...getPubkeyTagValues(event.tags)]),
|
||||
).map(pubkey => this.tagPubkey(pubkey))
|
||||
|
||||
tagEventForQuote = (event: TrustedEvent, relay?: string) => {
|
||||
const hint = relay || this.app.use(Router).Event(event).getUrl() || ""
|
||||
|
||||
return ["q", event.id, hint, event.pubkey]
|
||||
}
|
||||
|
||||
tagEventForReply = (event: TrustedEvent, relay?: string) => {
|
||||
const tags = this.tagEventPubkeys(event)
|
||||
const {roots, replies} = getReplyTags(event.tags)
|
||||
const parents = roots.length > 0 ? roots : replies
|
||||
const mark = parents.length > 0 ? "reply" : "root"
|
||||
const hint = relay || this.app.use(Router).Event(event).getUrl() || ""
|
||||
|
||||
// If the parent included roots use them, otherwise use replies as a fallback
|
||||
for (const [k, id, originalHint = "", _, pubkey = ""] of parents) {
|
||||
const hint = isShareableRelayUrl(originalHint)
|
||||
? originalHint
|
||||
: this.app.use(Router).EventRoots(event).getUrl()
|
||||
|
||||
tags.push([k, id, hint || "", "root", pubkey])
|
||||
}
|
||||
|
||||
// e-tag the event
|
||||
tags.push(["e", event.id, hint, mark, event.pubkey])
|
||||
|
||||
// a-tag the event
|
||||
if (isReplaceable(event)) {
|
||||
tags.push(["a", getAddress(event), hint, mark, event.pubkey])
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
tagEventForComment = (event: TrustedEvent, relay?: string) => {
|
||||
const pubkeyHint = this.app.use(Router).FromPubkey(event.pubkey).getUrl() || ""
|
||||
const eventHint = relay || this.app.use(Router).Event(event).getUrl() || ""
|
||||
const address = getAddress(event)
|
||||
const seenRoots = new Set<string>()
|
||||
const tags: string[][] = []
|
||||
|
||||
for (const [t, ...tag] of event.tags) {
|
||||
if (["K", "E", "A", "I", "P"].includes(t)) {
|
||||
tags.push([t, ...tag])
|
||||
seenRoots.add(t)
|
||||
}
|
||||
}
|
||||
|
||||
if (seenRoots.size === 0) {
|
||||
tags.push(["K", String(event.kind)])
|
||||
tags.push(["P", event.pubkey, pubkeyHint])
|
||||
tags.push(["E", event.id, eventHint, event.pubkey])
|
||||
|
||||
if (isReplaceableKind(event.kind)) {
|
||||
tags.push(["A", address, eventHint, event.pubkey])
|
||||
}
|
||||
}
|
||||
|
||||
tags.push(["k", String(event.kind)])
|
||||
tags.push(["p", event.pubkey, pubkeyHint])
|
||||
tags.push(["e", event.id, eventHint, event.pubkey])
|
||||
|
||||
if (isReplaceableKind(event.kind)) {
|
||||
tags.push(["a", address, eventHint, event.pubkey])
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
tagEventForReaction = (event: TrustedEvent, relay?: string) => {
|
||||
const hint = relay || this.app.use(Router).Event(event).getUrl() || ""
|
||||
const tags: string[][] = []
|
||||
|
||||
// Mention the event's author
|
||||
if (event.pubkey !== this.app.user?.pubkey) {
|
||||
tags.push(this.tagPubkey(event.pubkey))
|
||||
}
|
||||
|
||||
tags.push(["k", String(event.kind)])
|
||||
tags.push(["e", event.id, hint])
|
||||
|
||||
if (isReplaceable(event)) {
|
||||
tags.push(["a", getAddress(event), hint])
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
import type {Subscriber} from "svelte/store"
|
||||
import {writable} from "svelte/store"
|
||||
import type {Override} from "@welshman/lib"
|
||||
import {append, TaskQueue, ensurePlural, remove, defer, sleep, nth, uniq, without} from "@welshman/lib"
|
||||
import {
|
||||
HashedEvent,
|
||||
EventTemplate,
|
||||
SignedEvent,
|
||||
isSignedEvent,
|
||||
WRAPPED_KINDS,
|
||||
prep,
|
||||
makePow,
|
||||
} from "@welshman/util"
|
||||
import {PublishStatus, PublishResult, PublishOptions, PublishResultsByRelay} from "@welshman/net"
|
||||
import {Nip01Signer, Nip59} from "@welshman/signer"
|
||||
import type {IApp} from "../app.js"
|
||||
import {Network} from "./network.js"
|
||||
import {addMinimalFallbacks} from "@welshman/router"
|
||||
import {Router} from "./router.js"
|
||||
import {User} from "../user.js"
|
||||
|
||||
export type ThunkOptions = Override<
|
||||
PublishOptions,
|
||||
{
|
||||
app: IApp
|
||||
event: EventTemplate
|
||||
recipient?: string
|
||||
delay?: number
|
||||
pow?: number
|
||||
}
|
||||
>
|
||||
|
||||
/**
|
||||
* Shared base for `Thunk` and `MergedThunk`: a subscribable bag of per-relay
|
||||
* publish `results`.
|
||||
*/
|
||||
export abstract class BaseThunk {
|
||||
_subs: Subscriber<any>[] = []
|
||||
results: PublishResultsByRelay = {}
|
||||
|
||||
abstract abort(): void
|
||||
|
||||
_notify() {
|
||||
for (const subscriber of this._subs) {
|
||||
subscriber(this)
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(subscriber: Subscriber<this>) {
|
||||
this._subs.push(subscriber)
|
||||
|
||||
subscriber(this)
|
||||
|
||||
return () => {
|
||||
this._subs = remove(subscriber, this._subs)
|
||||
}
|
||||
}
|
||||
|
||||
getUrlsWithStatus(statuses: PublishStatus | PublishStatus[]) {
|
||||
const matches = ensurePlural(statuses)
|
||||
|
||||
return Object.entries(this.results)
|
||||
.filter(([_, {status}]) => matches.includes(status))
|
||||
.map(nth(0)) as string[]
|
||||
}
|
||||
|
||||
getCompleteUrls() {
|
||||
return this.getUrlsWithStatus(
|
||||
without([PublishStatus.Sending, PublishStatus.Pending], Object.values(PublishStatus)),
|
||||
)
|
||||
}
|
||||
|
||||
getIncompleteUrls() {
|
||||
return this.getUrlsWithStatus([PublishStatus.Sending, PublishStatus.Pending])
|
||||
}
|
||||
|
||||
getFailedUrls() {
|
||||
return this.getUrlsWithStatus([PublishStatus.Failure, PublishStatus.Timeout])
|
||||
}
|
||||
|
||||
hasStatus(statuses: PublishStatus | PublishStatus[]) {
|
||||
return this.getUrlsWithStatus(statuses).length > 0
|
||||
}
|
||||
|
||||
isComplete() {
|
||||
return !this.hasStatus([PublishStatus.Sending, PublishStatus.Pending])
|
||||
}
|
||||
|
||||
getError() {
|
||||
for (const [_, {status, detail}] of Object.entries(this.results)) {
|
||||
if (status === PublishStatus.Failure) {
|
||||
return detail
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isComplete()) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
waitForError() {
|
||||
return new Promise<string>(resolve => {
|
||||
this.subscribe(thunk => {
|
||||
const error = thunk.getError()
|
||||
|
||||
if (error !== undefined) {
|
||||
resolve(error)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
waitForCompletion() {
|
||||
return new Promise<void>(resolve => {
|
||||
this.subscribe(thunk => {
|
||||
if (thunk.isComplete()) {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Thunk extends BaseThunk {
|
||||
event: HashedEvent
|
||||
complete = defer<void>()
|
||||
controller = new AbortController()
|
||||
wrap?: SignedEvent
|
||||
|
||||
constructor(readonly options: ThunkOptions) {
|
||||
super()
|
||||
|
||||
if (!options.recipient && WRAPPED_KINDS.includes(options.event.kind)) {
|
||||
throw new Error(`Attempted to publish a kind ${options.event.kind} without wrapping it`)
|
||||
}
|
||||
|
||||
this.event = prep(options.event, this.user.pubkey)
|
||||
|
||||
for (const relay of options.relays) {
|
||||
this.results[relay] = {
|
||||
relay,
|
||||
status: PublishStatus.Sending,
|
||||
detail: "sending...",
|
||||
}
|
||||
}
|
||||
|
||||
this.controller.signal.addEventListener("abort", () => {
|
||||
for (const relay of options.relays) {
|
||||
this._setAborted({
|
||||
relay,
|
||||
status: PublishStatus.Aborted,
|
||||
detail: "aborted",
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
get user() {
|
||||
return User.require(this.options.app)
|
||||
}
|
||||
|
||||
_fail(detail: string) {
|
||||
for (const relay of this.options.relays) {
|
||||
this.results[relay] = {
|
||||
relay,
|
||||
status: PublishStatus.Failure,
|
||||
detail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
this._notify()
|
||||
}
|
||||
|
||||
_setPending = (result: PublishResult) => {
|
||||
this.options.onPending?.(result)
|
||||
this.results[result.relay] = result
|
||||
this._notify()
|
||||
}
|
||||
|
||||
_setTimeout = (result: PublishResult) => {
|
||||
this.options.onTimeout?.(result)
|
||||
this.results[result.relay] = result
|
||||
this._notify()
|
||||
}
|
||||
|
||||
_setAborted = (result: PublishResult) => {
|
||||
this.options.onAborted?.(result)
|
||||
this.results[result.relay] = result
|
||||
this._notify()
|
||||
}
|
||||
|
||||
async _publish(event: SignedEvent) {
|
||||
// Wait if the thunk is to be delayed
|
||||
if (this.options.delay) {
|
||||
await sleep(this.options.delay)
|
||||
}
|
||||
|
||||
// Skip publishing if aborted
|
||||
if (this.controller.signal.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
// Send it off
|
||||
await this.options.app.use(Network).publish({
|
||||
...this.options,
|
||||
event,
|
||||
onSuccess: (result: PublishResult) => {
|
||||
this.options.onSuccess?.(result)
|
||||
this.results[result.relay] = result
|
||||
this._notify()
|
||||
},
|
||||
onFailure: (result: PublishResult) => {
|
||||
this.options.onFailure?.(result)
|
||||
this.results[result.relay] = result
|
||||
this._notify()
|
||||
},
|
||||
onPending: this._setPending,
|
||||
onTimeout: this._setTimeout,
|
||||
onAborted: this._setAborted,
|
||||
onComplete: (result: PublishResult) => {
|
||||
if (result.status !== PublishStatus.Success) {
|
||||
this.options.app.tracker.removeRelay(event.id, result.relay)
|
||||
}
|
||||
|
||||
this.options.onComplete?.(result)
|
||||
this._subs = []
|
||||
},
|
||||
})
|
||||
|
||||
// Notify the caller that we're done
|
||||
this.complete.resolve()
|
||||
}
|
||||
|
||||
async publish() {
|
||||
// Handle abort immediately if possible
|
||||
if (this.controller.signal.aborted) return
|
||||
|
||||
const {recipient} = this.options
|
||||
|
||||
// If we're sending it privately, wrap the event using nip 59
|
||||
if (recipient) {
|
||||
const wrapper = Nip01Signer.ephemeral()
|
||||
const nip59 = new Nip59(this.user.signer, wrapper)
|
||||
|
||||
this.wrap = await nip59.wrap(recipient, this.event)
|
||||
|
||||
// If we're calculating pow, update the hash and re-sign
|
||||
if (this.options.pow) {
|
||||
this.wrap = await wrapper.sign(await makePow(this.wrap, this.options.pow).result, {
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
})
|
||||
}
|
||||
|
||||
this.options.app.wrapManager.add({recipient, wrap: this.wrap, rumor: this.event})
|
||||
|
||||
return this._publish(this.wrap)
|
||||
}
|
||||
|
||||
// If the event has been signed, we're good to go
|
||||
if (isSignedEvent(this.event)) {
|
||||
if (this.options.pow) {
|
||||
console.warn("Event is already signed, skipping proof of work calculation")
|
||||
}
|
||||
|
||||
return this._publish(this.event)
|
||||
}
|
||||
|
||||
// Allow for lazily signing/powing events in order to decrease apparent latency in the UI
|
||||
// that results from waiting for remote signers
|
||||
try {
|
||||
if (this.options.pow) {
|
||||
this.event = await makePow(this.event, this.options.pow).result
|
||||
}
|
||||
|
||||
const signedEvent = await this.user.signer.sign(this.event, {
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
})
|
||||
|
||||
// Update tracker and repository with the signed event since the id will have changed
|
||||
if (this.options.pow) {
|
||||
for (const url of this.options.relays) {
|
||||
this.options.app.tracker.removeRelay(this.event.id, url)
|
||||
this.options.app.tracker.track(signedEvent.id, url)
|
||||
}
|
||||
}
|
||||
|
||||
this.options.app.repository.removeEvent(this.event.id)
|
||||
this.options.app.repository.publish(signedEvent)
|
||||
|
||||
return this._publish(signedEvent)
|
||||
} catch (e: any) {
|
||||
console.error("Failed to sign event", e)
|
||||
return this._fail(String(e || "Failed to sign event"))
|
||||
}
|
||||
}
|
||||
|
||||
abort() {
|
||||
this.controller.abort()
|
||||
}
|
||||
}
|
||||
|
||||
export class MergedThunk extends BaseThunk {
|
||||
constructor(readonly thunks: Thunk[]) {
|
||||
super()
|
||||
|
||||
const {Aborted, Failure, Timeout, Pending, Sending, Success} = PublishStatus
|
||||
const relays = new Set(thunks.flatMap(thunk => thunk.options.relays))
|
||||
|
||||
for (const thunk of thunks) {
|
||||
thunk.subscribe(() => {
|
||||
this.results = {}
|
||||
|
||||
for (const relay of relays) {
|
||||
for (const status of [Aborted, Failure, Timeout, Pending, Sending, Success]) {
|
||||
const match = thunks.find(t => t.results[relay]?.status === status)
|
||||
|
||||
if (match) {
|
||||
this.results[relay] = match.results[relay]!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._notify()
|
||||
|
||||
if (thunks.every(t => t.isComplete())) {
|
||||
this._subs = []
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
abort() {
|
||||
this.thunks.forEach(thunk => thunk.abort())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-app thunk manager — the publish-side counterpart of `Network`. Owns
|
||||
* the app's optimistic-publish `history` store and the `queue` that paces
|
||||
* publishing. Reach it via `app.use(Thunks)`; `publish` fills in the app
|
||||
* (the acting user is derived from it), enqueues the thunk (optimistically
|
||||
* writing it to the repository), and returns it.
|
||||
*/
|
||||
export class Thunks {
|
||||
history = writable<Thunk[]>([])
|
||||
|
||||
queue = new TaskQueue<Thunk>({
|
||||
batchSize: 10,
|
||||
batchDelay: 100,
|
||||
processItem: (thunk: Thunk) => {
|
||||
thunk.publish()
|
||||
},
|
||||
})
|
||||
|
||||
constructor(readonly app: IApp) {}
|
||||
|
||||
enqueue(thunk: Thunk) {
|
||||
this.queue.push(thunk)
|
||||
|
||||
for (const url of thunk.options.relays) {
|
||||
this.app.tracker.track(thunk.event.id, url)
|
||||
}
|
||||
|
||||
this.app.repository.publish(thunk.event)
|
||||
this.history.update($history => append(thunk, $history))
|
||||
|
||||
thunk.controller.signal.addEventListener("abort", () => {
|
||||
if (thunk.wrap) {
|
||||
this.app.wrapManager.remove(thunk.wrap.id)
|
||||
} else {
|
||||
this.app.repository.removeEvent(thunk.event.id)
|
||||
}
|
||||
|
||||
this.history.update($history => remove(thunk, $history))
|
||||
})
|
||||
}
|
||||
|
||||
publish = (options: Omit<ThunkOptions, "app">) => {
|
||||
const thunk = new Thunk({...options, app: this.app})
|
||||
|
||||
this.enqueue(thunk)
|
||||
|
||||
return thunk
|
||||
}
|
||||
|
||||
// Publish as the user to their outbox (write) relays
|
||||
publishToOutbox = (options: Omit<ThunkOptions, "app" | "relays">) =>
|
||||
this.publish({
|
||||
...options,
|
||||
relays: this.app.use(Router).FromUser().policy(addMinimalFallbacks).getUrls(),
|
||||
})
|
||||
|
||||
retry = (thunk: BaseThunk) =>
|
||||
thunk instanceof MergedThunk
|
||||
? new MergedThunk(thunk.thunks.map(t => this.publish(t.options)))
|
||||
: this.publish((thunk as Thunk).options)
|
||||
|
||||
merge(thunks: BaseThunk[]) {
|
||||
return new MergedThunk(Array.from(this.flatten(thunks)))
|
||||
}
|
||||
|
||||
*flatten(thunks: BaseThunk[]): Iterable<Thunk> {
|
||||
for (const thunk of thunks) {
|
||||
if (thunk instanceof MergedThunk) {
|
||||
yield* this.flatten(thunk.thunks)
|
||||
} else if (thunk instanceof Thunk) {
|
||||
yield thunk
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {readable} from "svelte/store"
|
||||
import type {Readable} from "svelte/store"
|
||||
import {on} from "@welshman/lib"
|
||||
import {getTopicTagValues} from "@welshman/util"
|
||||
import type {RepositoryUpdate} from "@welshman/net"
|
||||
import {deriveItems} from "@welshman/store"
|
||||
import type {IApp} from "../app.js"
|
||||
|
||||
export type Topic = {
|
||||
name: string
|
||||
count: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Hashtag topics with occurrence counts, derived live from the app's
|
||||
* repository tag index.
|
||||
*/
|
||||
export class Topics {
|
||||
byName: Readable<Map<string, Topic>>
|
||||
all: Readable<Topic[]>
|
||||
|
||||
constructor(readonly app: IApp) {
|
||||
const topicsByName = new Map<string, Topic>()
|
||||
|
||||
const addTopic = (name: string) => {
|
||||
const topic = topicsByName.get(name)
|
||||
|
||||
if (topic) {
|
||||
topic.count++
|
||||
} else {
|
||||
topicsByName.set(name, {name, count: 1})
|
||||
}
|
||||
}
|
||||
|
||||
for (const tagString of app.repository.eventsByTag.keys()) {
|
||||
if (tagString.startsWith("t:")) {
|
||||
addTopic(tagString.slice(2).toLowerCase())
|
||||
}
|
||||
}
|
||||
|
||||
this.byName = readable(topicsByName, set =>
|
||||
on(app.repository, "update", ({added}: RepositoryUpdate) => {
|
||||
let dirty = false
|
||||
|
||||
for (const event of added) {
|
||||
for (const name of getTopicTagValues(event.tags)) {
|
||||
addTopic(name)
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
|
||||
if (dirty) {
|
||||
set(topicsByName)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
this.all = deriveItems(this.byName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
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 {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
|
||||
* of every known follow list) and updated reactively as lists change.
|
||||
*
|
||||
* The aggregate `*ByPubkey`/`graph`/`max` fields and the parameterized methods
|
||||
* (`follows`, `wotScore`, …) are all `Projection`s — subscribe via `.$`, snapshot
|
||||
* via `.get()`.
|
||||
*/
|
||||
export class Wot {
|
||||
followersByPubkey: Projection<Map<string, Set<string>>>
|
||||
mutersByPubkey: Projection<Map<string, Set<string>>>
|
||||
graph: Projection<Map<string, number>>
|
||||
max: Projection<number | undefined>
|
||||
|
||||
constructor(readonly app: IApp) {
|
||||
const followersByPubkeyStore = readable(new Map<string, Set<string>>(), set =>
|
||||
this.app.use(FollowLists).index.$.subscribe(
|
||||
throttle(1000, lists => {
|
||||
const $followersByPubkey = new Map<string, Set<string>>()
|
||||
|
||||
for (const list of lists.values()) {
|
||||
for (const pubkey of getPubkeyTagValues(getListTags(list))) {
|
||||
addToMapKey($followersByPubkey, pubkey, list.event.pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
set($followersByPubkey)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const mutersByPubkeyStore = readable(new Map<string, Set<string>>(), set =>
|
||||
this.app.use(MuteLists).index.$.subscribe(
|
||||
throttle(1000, lists => {
|
||||
const $mutersByPubkey = new Map<string, Set<string>>()
|
||||
|
||||
for (const list of lists.values()) {
|
||||
for (const pubkey of getPubkeyTagValues(getListTags(list))) {
|
||||
addToMapKey($mutersByPubkey, pubkey, list.event.pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
set($mutersByPubkey)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const graphStore = readable(new Map<string, number>(), set => {
|
||||
const rebuild = throttle(1000, () => {
|
||||
const $followLists = this.app.use(FollowLists).index.get()
|
||||
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())
|
||||
|
||||
for (const follow of roots) {
|
||||
for (const pubkey of listPubkeys($followLists.get(follow))) {
|
||||
$graph.set(pubkey, inc($graph.get(pubkey)))
|
||||
}
|
||||
|
||||
for (const pubkey of listPubkeys($muteLists.get(follow))) {
|
||||
$graph.set(pubkey, dec($graph.get(pubkey)))
|
||||
}
|
||||
}
|
||||
|
||||
set($graph)
|
||||
})
|
||||
|
||||
const unsubscribers = [
|
||||
this.app.use(FollowLists).index.$.subscribe(rebuild),
|
||||
this.app.use(MuteLists).index.$.subscribe(rebuild),
|
||||
]
|
||||
|
||||
return () => unsubscribers.forEach(unsubscribe => unsubscribe())
|
||||
})
|
||||
|
||||
const maxStore = derived(graphStore, $g => max(Array.from($g.values())))
|
||||
|
||||
this.followersByPubkey = projection(followersByPubkeyStore)
|
||||
this.mutersByPubkey = projection(mutersByPubkeyStore)
|
||||
this.graph = projection(graphStore)
|
||||
this.max = projection(maxStore)
|
||||
}
|
||||
|
||||
follows = (pubkey: string): Projection<string[]> =>
|
||||
projectFrom(this.app.use(FollowLists).index, $lists => listPubkeys($lists.get(pubkey)))
|
||||
|
||||
mutes = (pubkey: string): Projection<string[]> =>
|
||||
projectFrom(this.app.use(MuteLists).index, $lists => listPubkeys($lists.get(pubkey)))
|
||||
|
||||
network = (pubkey: string): Projection<string[]> =>
|
||||
projectFrom(this.app.use(FollowLists).index, $lists => {
|
||||
const pubkeys = new Set(listPubkeys($lists.get(pubkey)))
|
||||
const network = new Set<string>()
|
||||
|
||||
for (const follow of pubkeys) {
|
||||
for (const tpk of listPubkeys($lists.get(follow))) {
|
||||
if (!pubkeys.has(tpk)) {
|
||||
network.add(tpk)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(network)
|
||||
})
|
||||
|
||||
followers = (pubkey: string): Projection<string[]> =>
|
||||
projectFrom(this.followersByPubkey, $followers => Array.from($followers.get(pubkey) || []))
|
||||
|
||||
muters = (pubkey: string): Projection<string[]> =>
|
||||
projectFrom(this.mutersByPubkey, $muters => Array.from($muters.get(pubkey) || []))
|
||||
|
||||
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),
|
||||
),
|
||||
)
|
||||
|
||||
followsWhoMute = (pubkey: string, target: string): Projection<string[]> => {
|
||||
const read = ($follows: ReadonlyMap<string, List>, $mutes: ReadonlyMap<string, List>) =>
|
||||
listPubkeys($follows.get(pubkey)).filter(other =>
|
||||
listPubkeys($mutes.get(other)).includes(target),
|
||||
)
|
||||
|
||||
return projection(
|
||||
derived(
|
||||
[this.app.use(FollowLists).index.$, this.app.use(MuteLists).index.$],
|
||||
([$follows, $mutes]) => read($follows, $mutes),
|
||||
),
|
||||
() => read(this.app.use(FollowLists).index.get(), this.app.use(MuteLists).index.get()),
|
||||
)
|
||||
}
|
||||
|
||||
wotScore = (pubkey: string, target: string): Projection<number> => {
|
||||
const read = (
|
||||
$follows: ReadonlyMap<string, List>,
|
||||
$mutes: ReadonlyMap<string, List>,
|
||||
$followers: ReadonlyMap<string, Set<string>>,
|
||||
$muters: ReadonlyMap<string, Set<string>>,
|
||||
) => {
|
||||
let follows: string[]
|
||||
let mutes: string[]
|
||||
|
||||
if (pubkey) {
|
||||
const theirFollows = listPubkeys($follows.get(pubkey))
|
||||
|
||||
follows = theirFollows.filter(other => listPubkeys($follows.get(other)).includes(target))
|
||||
mutes = theirFollows.filter(other => listPubkeys($mutes.get(other)).includes(target))
|
||||
} else {
|
||||
follows = Array.from($followers.get(target) || [])
|
||||
mutes = Array.from($muters.get(target) || [])
|
||||
}
|
||||
|
||||
return follows.length - mutes.length
|
||||
}
|
||||
|
||||
return projection(
|
||||
derived(
|
||||
[
|
||||
this.app.use(FollowLists).index.$,
|
||||
this.app.use(MuteLists).index.$,
|
||||
this.followersByPubkey.$,
|
||||
this.mutersByPubkey.$,
|
||||
],
|
||||
([$follows, $mutes, $followers, $muters]) => read($follows, $mutes, $followers, $muters),
|
||||
),
|
||||
() =>
|
||||
read(
|
||||
this.app.use(FollowLists).index.get(),
|
||||
this.app.use(MuteLists).index.get(),
|
||||
this.followersByPubkey.get(),
|
||||
this.mutersByPubkey.get(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import {get, writable} from "svelte/store"
|
||||
import {TaskQueue, uniq, now} from "@welshman/lib"
|
||||
import {getPubkeyTagValues, getRelaysFromList, prep} from "@welshman/util"
|
||||
import type {TrustedEvent, SignedEvent, EventTemplate} from "@welshman/util"
|
||||
import {Nip59} from "@welshman/signer"
|
||||
import {MergedThunk, Thunks} from "./thunk.js"
|
||||
import type {ThunkOptions} from "./thunk.js"
|
||||
import {User} from "../user.js"
|
||||
import {MessagingRelayLists} from "./messagingRelayLists.js"
|
||||
import type {IApp} from "../app.js"
|
||||
|
||||
export type SendWrappedOptions = Omit<
|
||||
ThunkOptions,
|
||||
"event" | "relays" | "recipient" | "app" | "user"
|
||||
> & {
|
||||
event: EventTemplate
|
||||
recipients: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-app wrap (NIP-59) state: the unwrap queue plus failure/dedup
|
||||
* tracking. Scoped to `app.user`, so an app only ever unwraps its own user's
|
||||
* messages into its own repository — which is what keeps DM history from being
|
||||
* merged across identities. The repository subscription that feeds it lives in
|
||||
* `appPolicyWraps`.
|
||||
*/
|
||||
export class Wraps {
|
||||
failedUnwraps = new Set<string>()
|
||||
queue: TaskQueue<TrustedEvent>
|
||||
|
||||
constructor(readonly app: IApp) {
|
||||
this.queue = new TaskQueue<TrustedEvent>({
|
||||
batchSize: 50,
|
||||
batchDelay: 30,
|
||||
processItem: async (wrap: TrustedEvent) => {
|
||||
const signer = this.app.user?.signer
|
||||
const recipient = this.app.user?.pubkey
|
||||
|
||||
// Only unwrap messages addressed to our user
|
||||
if (!signer || !recipient || !getPubkeyTagValues(wrap.tags).includes(recipient)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const rumor = await Nip59.fromSigner(signer).unwrap(wrap as SignedEvent)
|
||||
|
||||
this.app.wrapManager.add({wrap: wrap as SignedEvent, rumor, recipient})
|
||||
} catch (e) {
|
||||
this.failedUnwraps.add(wrap.id)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
enqueue = (wrap: TrustedEvent) => {
|
||||
if (this.failedUnwraps.has(wrap.id)) return
|
||||
if (this.app.wrapManager.getRumor(wrap.id)) return
|
||||
|
||||
this.queue.push(wrap)
|
||||
}
|
||||
|
||||
// NIP-59: wrap an event for each recipient (using their messaging relays) and
|
||||
// publish the wraps as the app's user.
|
||||
publish = async ({event, recipients, ...options}: SendWrappedOptions) => {
|
||||
const user = User.require(this.app)
|
||||
|
||||
// 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.app.use(MessagingRelayLists).load(recipient))
|
||||
|
||||
return this.app.use(Thunks).publish({event: stableEvent, relays, recipient, ...options})
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import type {Readable} from "svelte/store"
|
||||
import {
|
||||
removeUndefined,
|
||||
fetchJson,
|
||||
bech32ToHex,
|
||||
hexToBech32,
|
||||
tryCatch,
|
||||
batcher,
|
||||
postJson,
|
||||
} from "@welshman/lib"
|
||||
import type {Maybe} from "@welshman/lib"
|
||||
import {getTagValue, getZapSplits, zapFromEvent} from "@welshman/util"
|
||||
import type {Zapper, Zap, TrustedEvent} from "@welshman/util"
|
||||
import {deriveDeduplicated, deriveDeduplicatedByValue} from "@welshman/store"
|
||||
import {LoadableMapPlugin, projection} from "./base.js"
|
||||
import type {Projection} from "./base.js"
|
||||
import type {IApp} from "../app.js"
|
||||
import {Profiles} from "./profiles.js"
|
||||
|
||||
/**
|
||||
* Lightning zapper info, keyed by lnurl. A "local" loadable collection: items
|
||||
* aren't nostr events, they're fetched over HTTP (either directly from each
|
||||
* lnurl, or via a dufflepud proxy to protect user privacy). Depends on the
|
||||
* profiles collection to resolve a pubkey's lnurl.
|
||||
*/
|
||||
export class Zappers extends LoadableMapPlugin<Zapper> {
|
||||
fetch = batcher(800, async (lnurls: string[]) => {
|
||||
const result = new Map<string, Zapper>()
|
||||
const valid = lnurls.filter(lnurl => lnurl.startsWith("lnurl1"))
|
||||
|
||||
const addZapper = (lnurl: string, info: any) => {
|
||||
if (info) {
|
||||
try {
|
||||
result.set(lnurl, {...info, lnurl})
|
||||
} catch (_e) {
|
||||
// pass
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.app.config.dufflepudUrl) {
|
||||
const hexUrls = valid.map(bech32ToHex)
|
||||
const res: any = await tryCatch(
|
||||
async () =>
|
||||
await postJson(`${this.app.config.dufflepudUrl}/zapper/info`, {lnurls: hexUrls}),
|
||||
)
|
||||
|
||||
for (const {lnurl, info} of res?.data || []) {
|
||||
addZapper(hexToBech32("lnurl", lnurl), info)
|
||||
}
|
||||
} else {
|
||||
await Promise.all(
|
||||
valid.map(async lnurl => {
|
||||
addZapper(lnurl, await tryCatch(async () => await fetchJson(bech32ToHex(lnurl))))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const [lnurl, zapper] of result) {
|
||||
this.set(lnurl, zapper)
|
||||
}
|
||||
|
||||
return lnurls.map(lnurl => result.get(lnurl))
|
||||
})
|
||||
|
||||
loadForPubkey = async (pubkey: string, relays: string[] = []) => {
|
||||
const $profile = await this.app.use(Profiles).load(pubkey, relays)
|
||||
|
||||
return $profile?.lnurl ? this.load($profile.lnurl) : undefined
|
||||
}
|
||||
|
||||
forPubkey = (pubkey: string, relays: string[] = []): Projection<Maybe<Zapper>> => {
|
||||
this.loadForPubkey(pubkey, relays)
|
||||
|
||||
const read = ([$zappersByLnurl, $profile]: [ReadonlyMap<string, Zapper>, Maybe<{lnurl?: string}>]) =>
|
||||
$profile?.lnurl ? $zappersByLnurl.get($profile.lnurl) : undefined
|
||||
|
||||
return projection(
|
||||
deriveDeduplicated([this.index.$, this.app.use(Profiles).one(pubkey, relays)], read),
|
||||
() => read([this.index.get(), this.app.use(Profiles).get(pubkey)]),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the zapper a zap receipt should be validated against. A receipt's
|
||||
* `p` tag is the recipient (copied from the zap request), so we honor only
|
||||
* receipts addressed to one of the parent's designated split recipients and
|
||||
* load *that* recipient's zapper. The old lookup always used the first
|
||||
* recipient's lnurl, which silently dropped legitimate zaps to any of the
|
||||
* other split recipients.
|
||||
*/
|
||||
loadZapperForZap = async (zapReceipt: TrustedEvent, parent: TrustedEvent) => {
|
||||
const recipient = getTagValue("p", zapReceipt.tags)
|
||||
const split = getZapSplits(parent).find(split => split.pubkey === recipient)
|
||||
|
||||
if (!split) return
|
||||
|
||||
return this.loadForPubkey(split.pubkey, removeUndefined([split.relay]))
|
||||
}
|
||||
|
||||
validateZapReceipt = async (zapReceipt: TrustedEvent, parent: TrustedEvent) => {
|
||||
const zapper = await this.loadZapperForZap(zapReceipt, parent)
|
||||
|
||||
return zapper ? zapFromEvent(zapReceipt, zapper) : undefined
|
||||
}
|
||||
|
||||
validateZapReceipts = async (zapReceipts: TrustedEvent[], parent: TrustedEvent) =>
|
||||
removeUndefined(
|
||||
await Promise.all(zapReceipts.map(zapReceipt => this.validateZapReceipt(zapReceipt, parent))),
|
||||
)
|
||||
|
||||
validZapReceipts = (zapReceipts: TrustedEvent[], parent: TrustedEvent): Projection<Zap[]> => {
|
||||
const splits = getZapSplits(parent)
|
||||
const profiles = this.app.use(Profiles)
|
||||
|
||||
// Ensure each recipient's profile (-> lnurl) and zapper are being loaded.
|
||||
for (const split of splits) {
|
||||
this.loadForPubkey(split.pubkey, removeUndefined([split.relay]))
|
||||
}
|
||||
|
||||
const read = (values: any[]) => {
|
||||
const $zappersByLnurl = values[0] as Map<string, Zapper>
|
||||
const $profiles = values.slice(1) as Array<{lnurl?: string} | undefined>
|
||||
|
||||
const zapperByPubkey = new Map<string, Zapper>()
|
||||
|
||||
splits.forEach((split, i) => {
|
||||
const lnurl = $profiles[i]?.lnurl
|
||||
const zapper = lnurl ? $zappersByLnurl.get(lnurl) : undefined
|
||||
|
||||
if (zapper) zapperByPubkey.set(split.pubkey, zapper)
|
||||
})
|
||||
|
||||
return removeUndefined(
|
||||
zapReceipts.map(zapReceipt => {
|
||||
const recipient = getTagValue("p", zapReceipt.tags)
|
||||
const zapper = recipient ? zapperByPubkey.get(recipient) : undefined
|
||||
|
||||
return zapper ? zapFromEvent(zapReceipt, zapper) : undefined
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const stores: Readable<any>[] = [this.index.$, ...splits.map(split => profiles.one(split.pubkey))]
|
||||
|
||||
return projection(
|
||||
deriveDeduplicatedByValue(stores, read),
|
||||
() => read([this.index.get(), ...splits.map(split => profiles.get(split.pubkey))]),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import type {Unsubscriber} from "svelte/store"
|
||||
import {on, noop, always, call} from "@welshman/lib"
|
||||
import {WRAP, isDVMKind, isEphemeralKind, verifyEvent} from "@welshman/util"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {SocketEvent, isRelayEvent, makeSocketPolicyAuth} from "@welshman/net"
|
||||
import type {RelayMessage, Socket} from "@welshman/net"
|
||||
import type {IApp} from "./app.js"
|
||||
import {RelayStats} from "./plugins/relayStats.js"
|
||||
import {Wraps} from "./plugins/wraps.js"
|
||||
import {BlockedRelayLists} from "./plugins/blockedRelayLists.js"
|
||||
import {LoggingSigner} from "./logging.js"
|
||||
import type {LogMessage} from "./logging.js"
|
||||
|
||||
/**
|
||||
* An app policy is a side effect applied once per app at construction,
|
||||
* returning a cleanup function — directly analogous to a socket policy. Policies
|
||||
* own everything that subscribes or links components together (event ingestion,
|
||||
* stats collection, gift-wrap unwrapping), so the data classes themselves stay
|
||||
* pure and free of subscriptions, and teardown is centralized in `cleanup()`.
|
||||
*/
|
||||
export type AppPolicy = (app: IApp) => Unsubscriber
|
||||
|
||||
/**
|
||||
* Builds an app policy that authenticates the app's sockets (NIP-42) with
|
||||
* the user's signer. It appends an auth socket policy to the pool's
|
||||
* `socketPolicies`, so every socket the pool creates answers AUTH challenges
|
||||
* according to `shouldAuth`; the policy is spliced back out on cleanup. No-op
|
||||
* when the app has no user.
|
||||
*
|
||||
* Use the `appPolicyAuthAlways` / `appPolicyAuthNever` presets below, or
|
||||
* call this with a custom predicate.
|
||||
*/
|
||||
export const makeAppPolicyAuth =
|
||||
(shouldAuth: (socket: Socket, app: IApp) => boolean): AppPolicy =>
|
||||
app => {
|
||||
if (!app.user) {
|
||||
return noop
|
||||
}
|
||||
|
||||
const policy = makeSocketPolicyAuth({
|
||||
sign: app.user.signer.sign,
|
||||
shouldAuth: socket => shouldAuth(socket, app),
|
||||
})
|
||||
|
||||
app.pool.socketPolicies.push(policy)
|
||||
|
||||
return () => {
|
||||
const index = app.pool.socketPolicies.indexOf(policy)
|
||||
|
||||
if (index !== -1) {
|
||||
app.pool.socketPolicies.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const appPolicyAuthNever = makeAppPolicyAuth(always(false))
|
||||
|
||||
export const appPolicyAuthAlways = makeAppPolicyAuth(always(true))
|
||||
|
||||
export const appPolicyAuthUnlessBlocked = makeAppPolicyAuth((socket, app) => {
|
||||
if (!app.user) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !app
|
||||
.use(BlockedRelayLists)
|
||||
.urls(app.user.pubkey)
|
||||
.get()
|
||||
.includes(socket.url)
|
||||
})
|
||||
|
||||
/**
|
||||
* Ingests every event received on any socket into the app's repository. The
|
||||
* net layer doesn't do this for us, and it's how all the repository-backed
|
||||
* collections (and gift-wrap unwrapping) get populated.
|
||||
*/
|
||||
export const appPolicyIngest: AppPolicy = app =>
|
||||
app.pool.subscribe(socket => {
|
||||
const onReceive = (message: RelayMessage) => {
|
||||
if (!isRelayEvent(message)) return
|
||||
|
||||
const event = message[2]
|
||||
|
||||
if (isDVMKind(event.kind) || isEphemeralKind(event.kind)) return
|
||||
if (!verifyEvent(event)) return
|
||||
|
||||
app.tracker.track(event.id, socket.url)
|
||||
app.repository.publish(event)
|
||||
}
|
||||
|
||||
socket.on(SocketEvent.Receive, onReceive)
|
||||
|
||||
return () => socket.off(SocketEvent.Receive, onReceive)
|
||||
})
|
||||
|
||||
/**
|
||||
* Listens to socket activity on the app's pool into the RelayStats store.
|
||||
*/
|
||||
export const appPolicyRelayStats: AppPolicy = app => {
|
||||
return app.pool.subscribe(app.use(RelayStats).monitorSocket)
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches the app's repository for gift wraps (existing and incoming) and
|
||||
* feeds them to the unwrap queue.
|
||||
*/
|
||||
export const appPolicyWraps: AppPolicy = app => {
|
||||
const wraps = app.use(Wraps)
|
||||
|
||||
for (const wrap of app.repository.query([{kinds: [WRAP]}])) {
|
||||
wraps.enqueue(wrap)
|
||||
}
|
||||
|
||||
return on(app.repository, "update", ({added}: {added: TrustedEvent[]}) => {
|
||||
for (const event of added) {
|
||||
if (event.kind === WRAP) {
|
||||
wraps.enqueue(event)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Forwards "message" events from the user's signer to `onMessage`. Opt-in —
|
||||
* add `makeAppPolicyLogger(handler)` to an app's `policies`.
|
||||
*/
|
||||
export const makeAppPolicyLogger =
|
||||
(onMessage: (message: LogMessage) => void): AppPolicy =>
|
||||
app => {
|
||||
const unsubscribers: Unsubscriber[] = []
|
||||
const signer = app.user?.signer
|
||||
|
||||
if (signer instanceof LoggingSigner) {
|
||||
unsubscribers.push(on(signer, "message", onMessage))
|
||||
}
|
||||
|
||||
return () => unsubscribers.forEach(call)
|
||||
}
|
||||
|
||||
export const defaultAppPolicies: AppPolicy[] = [
|
||||
appPolicyIngest,
|
||||
appPolicyRelayStats,
|
||||
appPolicyWraps,
|
||||
appPolicyAuthUnlessBlocked,
|
||||
]
|
||||
@@ -1,43 +0,0 @@
|
||||
import {derived, readable} from "svelte/store"
|
||||
import {readProfile, displayProfile, displayPubkey, PROFILE} from "@welshman/util"
|
||||
import {
|
||||
deriveItemsByKey,
|
||||
deriveItems,
|
||||
makeForceLoadItem,
|
||||
makeLoadItem,
|
||||
makeDeriveItem,
|
||||
getter,
|
||||
} from "@welshman/store"
|
||||
import {repository} from "./core.js"
|
||||
import {makeOutboxLoader} from "./relayLists.js"
|
||||
|
||||
export const profilesByPubkey = deriveItemsByKey({
|
||||
repository,
|
||||
eventToItem: readProfile,
|
||||
filters: [{kinds: [PROFILE]}],
|
||||
getKey: profile => profile.event.pubkey,
|
||||
})
|
||||
|
||||
export const profiles = deriveItems(profilesByPubkey)
|
||||
|
||||
export const getProfilesByPubkey = getter(profilesByPubkey)
|
||||
|
||||
export const getProfiles = getter(profiles)
|
||||
|
||||
export const getProfile = (pubkey: string) => getProfilesByPubkey().get(pubkey)
|
||||
|
||||
export const forceLoadProfile = makeForceLoadItem(makeOutboxLoader(PROFILE), getProfile)
|
||||
|
||||
export const loadProfile = makeLoadItem(makeOutboxLoader(PROFILE), getProfile)
|
||||
|
||||
export const deriveProfile = makeDeriveItem(profilesByPubkey, loadProfile)
|
||||
|
||||
export const displayProfileByPubkey = (pubkey: string | undefined) =>
|
||||
pubkey ? displayProfile(getProfile(pubkey), displayPubkey(pubkey)) : ""
|
||||
|
||||
export const deriveProfileDisplay = (pubkey: string | undefined, ...args: any[]) =>
|
||||
pubkey
|
||||
? derived(deriveProfile(pubkey, ...args), $profile =>
|
||||
displayProfile($profile, displayPubkey(pubkey)),
|
||||
)
|
||||
: readable("")
|
||||
@@ -1,87 +0,0 @@
|
||||
import {chunk, first} from "@welshman/lib"
|
||||
import {
|
||||
RELAYS,
|
||||
asDecryptedEvent,
|
||||
readList,
|
||||
TrustedEvent,
|
||||
sortEventsDesc,
|
||||
getRelaysFromList,
|
||||
RelayMode,
|
||||
Filter,
|
||||
isPlainReplaceableKind,
|
||||
} from "@welshman/util"
|
||||
import {
|
||||
deriveItemsByKey,
|
||||
deriveItems,
|
||||
makeForceLoadItem,
|
||||
makeLoadItem,
|
||||
makeDeriveItem,
|
||||
getter,
|
||||
} from "@welshman/store"
|
||||
import {load} from "@welshman/net"
|
||||
import {Router, addMinimalFallbacks} from "@welshman/router"
|
||||
import {repository} from "./core.js"
|
||||
|
||||
export const fetchRelayList = async (pubkey: string, relayHints: string[] = []) => {
|
||||
const filters = [{kinds: [RELAYS], authors: [pubkey], limit: 1}]
|
||||
|
||||
await Promise.all([
|
||||
load({filters, relays: Router.get().FromRelays(relayHints).getUrls()}),
|
||||
load({filters, relays: Router.get().FromPubkey(pubkey).getUrls()}),
|
||||
load({filters, relays: Router.get().Index().getUrls()}),
|
||||
])
|
||||
}
|
||||
|
||||
export const relayListsByPubkey = deriveItemsByKey({
|
||||
repository,
|
||||
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
||||
filters: [{kinds: [RELAYS]}],
|
||||
getKey: relayList => relayList.event.pubkey,
|
||||
})
|
||||
|
||||
export const relayLists = deriveItems(relayListsByPubkey)
|
||||
|
||||
export const getRelayListsByPubkey = getter(relayListsByPubkey)
|
||||
|
||||
export const getRelayLists = getter(relayLists)
|
||||
|
||||
export const getRelayList = (pubkey: string) => getRelayListsByPubkey().get(pubkey)
|
||||
|
||||
export const forceLoadRelayList = makeForceLoadItem(fetchRelayList, getRelayList)
|
||||
|
||||
export const loadRelayList = makeLoadItem(fetchRelayList, getRelayList)
|
||||
|
||||
export const deriveRelayList = makeDeriveItem(relayListsByPubkey, loadRelayList)
|
||||
|
||||
// Outbox loader
|
||||
|
||||
export const loadUsingOutbox = async (kind: number, pubkey: string, filter: Filter = {}) => {
|
||||
const filters = [{...filter, kinds: [kind], authors: [pubkey]}]
|
||||
const writeRelays = getRelaysFromList(await loadRelayList(pubkey), RelayMode.Write)
|
||||
const allRelays = Router.get()
|
||||
.FromRelays(writeRelays)
|
||||
.policy(addMinimalFallbacks)
|
||||
.limit(8)
|
||||
.getUrls()
|
||||
|
||||
if (isPlainReplaceableKind(kind)) {
|
||||
filters[0].limit = 1
|
||||
}
|
||||
|
||||
for (const relays of chunk(2, allRelays)) {
|
||||
const events = await load({filters, relays})
|
||||
|
||||
if (events.length > 0) {
|
||||
return first(sortEventsDesc(events))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const makeOutboxLoader =
|
||||
(kind: number, filter: Filter = {}, limit = 1) =>
|
||||
async (pubkey: string, relayHints: string[] = []) => {
|
||||
const filters = [{...filter, kinds: [kind], authors: [pubkey], limit}]
|
||||
const relays = Router.get().FromRelays(relayHints).getUrls()
|
||||
|
||||
await Promise.all([load({filters, relays}), loadUsingOutbox(kind, pubkey, filter)])
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
import {writable, Subscriber} from "svelte/store"
|
||||
import {getter, makeDeriveItem} from "@welshman/store"
|
||||
import {groupBy, batch, now, ago, DAY, HOUR, MINUTE} from "@welshman/lib"
|
||||
import {isOnionUrl, isLocalUrl, isIPAddress, isRelayUrl, getRelaysFromList} from "@welshman/util"
|
||||
import {Pool, Socket, SocketStatus, SocketEvent, ClientMessage, RelayMessage} from "@welshman/net"
|
||||
import {getBlockedRelayList} from "./blockedRelayLists.js"
|
||||
import {pubkey} from "./session.js"
|
||||
|
||||
export type RelayStats = {
|
||||
url: string
|
||||
first_seen: number
|
||||
recent_errors: number[]
|
||||
open_count: number
|
||||
close_count: number
|
||||
publish_count: number
|
||||
request_count: number
|
||||
event_count: number
|
||||
last_open: number
|
||||
last_close: number
|
||||
last_error: number
|
||||
last_publish: number
|
||||
last_request: number
|
||||
last_event: number
|
||||
last_auth: number
|
||||
publish_success_count: number
|
||||
publish_failure_count: number
|
||||
eose_count: number
|
||||
notice_count: number
|
||||
}
|
||||
|
||||
export const makeRelayStats = (url: string): RelayStats => ({
|
||||
url,
|
||||
first_seen: now(),
|
||||
recent_errors: [],
|
||||
open_count: 0,
|
||||
close_count: 0,
|
||||
publish_count: 0,
|
||||
request_count: 0,
|
||||
event_count: 0,
|
||||
last_open: 0,
|
||||
last_close: 0,
|
||||
last_error: 0,
|
||||
last_publish: 0,
|
||||
last_request: 0,
|
||||
last_event: 0,
|
||||
last_auth: 0,
|
||||
publish_success_count: 0,
|
||||
publish_failure_count: 0,
|
||||
eose_count: 0,
|
||||
notice_count: 0,
|
||||
})
|
||||
|
||||
export const relayStatsByUrl = writable(new Map<string, RelayStats>())
|
||||
|
||||
export const getRelayStatsByUrl = getter(relayStatsByUrl)
|
||||
|
||||
export const getRelayStats = (url: string) => getRelayStatsByUrl().get(url)
|
||||
|
||||
export const relayStatsSubscribers: Subscriber<RelayStats>[] = []
|
||||
|
||||
export const notifyRelayStats = (relayStats: RelayStats) =>
|
||||
relayStatsSubscribers.forEach(sub => sub(relayStats))
|
||||
|
||||
export const onRelayStats = (sub: (relayStats: RelayStats) => void) => {
|
||||
relayStatsSubscribers.push(sub)
|
||||
|
||||
return () =>
|
||||
relayStatsSubscribers.splice(
|
||||
relayStatsSubscribers.findIndex(s => s === sub),
|
||||
1,
|
||||
)
|
||||
}
|
||||
|
||||
export const deriveRelayStats = makeDeriveItem(relayStatsByUrl)
|
||||
|
||||
export const getRelayQuality = (url: string) => {
|
||||
// Skip non-relays entirely
|
||||
if (!isRelayUrl(url)) return 0
|
||||
|
||||
const $pubkey = pubkey.get()
|
||||
|
||||
if ($pubkey && getRelaysFromList(getBlockedRelayList($pubkey)).includes(url)) return 0
|
||||
|
||||
const relayStats = getRelayStats(url)
|
||||
|
||||
// If we have recent errors, skip it
|
||||
if (relayStats) {
|
||||
if (relayStats.recent_errors.filter(n => n > ago(MINUTE)).length > 0) return 0
|
||||
if (relayStats.recent_errors.filter(n => n > ago(HOUR)).length > 3) return 0
|
||||
if (relayStats.recent_errors.filter(n => n > ago(DAY)).length > 10) return 0
|
||||
}
|
||||
|
||||
// Prefer stuff we're connected to
|
||||
if (Pool.get().has(url)) return 1
|
||||
|
||||
// Prefer stuff we've connected to in the past
|
||||
if (relayStats) return 0.9
|
||||
|
||||
// If it's not weird url give it an ok score
|
||||
if (!isIPAddress(url) && !isLocalUrl(url) && !isOnionUrl(url) && !url.startsWith("ws://")) {
|
||||
return 0.8
|
||||
}
|
||||
|
||||
// Default to a "meh" score
|
||||
return 0.7
|
||||
}
|
||||
|
||||
// Utilities for syncing stats from connections to relays
|
||||
|
||||
type RelayStatsUpdate = [string, (stats: RelayStats) => void]
|
||||
|
||||
const updateRelayStats = batch(1000, (updates: RelayStatsUpdate[]) => {
|
||||
relayStatsByUrl.update($relayStatsByUrl => {
|
||||
for (const [url, items] of groupBy(([url]) => url, updates)) {
|
||||
if (!url || !isRelayUrl(url)) {
|
||||
console.warn(`Attempted to update stats for an invalid relay url: ${url}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const $relayStatsItem: RelayStats = $relayStatsByUrl.get(url) || makeRelayStats(url)
|
||||
|
||||
for (const [_, update] of items) {
|
||||
update($relayStatsItem)
|
||||
}
|
||||
|
||||
// Copy so the database gets updated, since we're mutating in updates
|
||||
const next = {...$relayStatsItem}
|
||||
$relayStatsByUrl.set(url, next)
|
||||
notifyRelayStats(next)
|
||||
}
|
||||
|
||||
return $relayStatsByUrl
|
||||
})
|
||||
})
|
||||
|
||||
const onSocketSend = ([verb]: ClientMessage, url: string) => {
|
||||
if (verb === "REQ") {
|
||||
updateRelayStats([
|
||||
url,
|
||||
stats => {
|
||||
stats.request_count++
|
||||
stats.last_request = now()
|
||||
},
|
||||
])
|
||||
} else if (verb === "EVENT") {
|
||||
updateRelayStats([
|
||||
url,
|
||||
stats => {
|
||||
stats.publish_count++
|
||||
stats.last_publish = now()
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
const onSocketReceive = ([verb, ...extra]: RelayMessage, url: string) => {
|
||||
if (verb === "OK") {
|
||||
const [_, ok] = extra
|
||||
|
||||
updateRelayStats([
|
||||
url,
|
||||
stats => {
|
||||
if (ok) {
|
||||
stats.publish_success_count++
|
||||
} else {
|
||||
stats.publish_failure_count++
|
||||
}
|
||||
},
|
||||
])
|
||||
} else if (verb === "AUTH") {
|
||||
updateRelayStats([
|
||||
url,
|
||||
stats => {
|
||||
stats.last_auth = now()
|
||||
},
|
||||
])
|
||||
} else if (verb === "EVENT") {
|
||||
updateRelayStats([
|
||||
url,
|
||||
stats => {
|
||||
stats.event_count++
|
||||
stats.last_event = now()
|
||||
},
|
||||
])
|
||||
} else if (verb === "EOSE") {
|
||||
updateRelayStats([
|
||||
url,
|
||||
stats => {
|
||||
stats.eose_count++
|
||||
},
|
||||
])
|
||||
} else if (verb === "NOTICE") {
|
||||
updateRelayStats([
|
||||
url,
|
||||
stats => {
|
||||
stats.notice_count++
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
const onSocketStatus = (status: string, url: string) => {
|
||||
if (status === SocketStatus.Open) {
|
||||
updateRelayStats([
|
||||
url,
|
||||
stats => {
|
||||
stats.last_open = now()
|
||||
stats.open_count++
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
if (status === SocketStatus.Closed) {
|
||||
updateRelayStats([
|
||||
url,
|
||||
stats => {
|
||||
stats.last_close = now()
|
||||
stats.close_count++
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
if (status === SocketStatus.Error) {
|
||||
updateRelayStats([
|
||||
url,
|
||||
stats => {
|
||||
stats.last_error = now()
|
||||
stats.recent_errors = stats.recent_errors.concat(now()).slice(-100)
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
export const trackRelayStats = (socket: Socket) => {
|
||||
socket.on(SocketEvent.Send, onSocketSend)
|
||||
socket.on(SocketEvent.Receive, onSocketReceive)
|
||||
socket.on(SocketEvent.Status, onSocketStatus)
|
||||
|
||||
return () => {
|
||||
socket.off(SocketEvent.Send, onSocketSend)
|
||||
socket.off(SocketEvent.Receive, onSocketReceive)
|
||||
socket.off(SocketEvent.Status, onSocketStatus)
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import {writable, derived, Subscriber} from "svelte/store"
|
||||
import {fetchJson, Maybe} from "@welshman/lib"
|
||||
import {RelayProfile} from "@welshman/util"
|
||||
import {displayRelayUrl, displayRelayProfile} from "@welshman/util"
|
||||
import {getter, deriveItems, makeForceLoadItem, makeLoadItem, makeDeriveItem} from "@welshman/store"
|
||||
|
||||
export const relaysByUrl = writable(new Map<string, RelayProfile>())
|
||||
|
||||
export const relays = deriveItems(relaysByUrl)
|
||||
|
||||
export const getRelaysByUrl = getter(relaysByUrl)
|
||||
|
||||
export const getRelays = getter(relays)
|
||||
|
||||
export const getRelay = (url: string) => getRelaysByUrl().get(url)
|
||||
|
||||
export const relaySubscribers: Subscriber<RelayProfile>[] = []
|
||||
|
||||
export const notifyRelay = (relay: RelayProfile) => relaySubscribers.forEach(sub => sub(relay))
|
||||
|
||||
export const onRelay = (sub: (relay: RelayProfile) => void) => {
|
||||
relaySubscribers.push(sub)
|
||||
|
||||
return () => {
|
||||
const i = relaySubscribers.findIndex(s => s === sub)
|
||||
|
||||
if (i !== -1) relaySubscribers.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
export const fetchRelay = async (url: string): Promise<Maybe<RelayProfile>> => {
|
||||
try {
|
||||
const json = await fetchJson(url.replace(/^ws/, "http"), {
|
||||
headers: {
|
||||
Accept: "application/nostr+json",
|
||||
},
|
||||
})
|
||||
|
||||
if (json) {
|
||||
const info = {...json, url}
|
||||
|
||||
if (!Array.isArray(info.supported_nips)) {
|
||||
info.supported_nips = []
|
||||
}
|
||||
|
||||
info.supported_nips = info.supported_nips.map(String)
|
||||
|
||||
relaysByUrl.update($relaysByUrl => {
|
||||
$relaysByUrl.set(url, info)
|
||||
|
||||
return $relaysByUrl
|
||||
})
|
||||
|
||||
notifyRelay(info)
|
||||
|
||||
return info
|
||||
}
|
||||
} catch (e) {
|
||||
// pass
|
||||
}
|
||||
}
|
||||
|
||||
export const forceLoadRelay = makeForceLoadItem(fetchRelay, getRelay)
|
||||
|
||||
export const loadRelay = makeLoadItem(fetchRelay, getRelay)
|
||||
|
||||
export const deriveRelay = makeDeriveItem(relaysByUrl, loadRelay)
|
||||
|
||||
export const displayRelayByPubkey = (url: string) =>
|
||||
displayRelayProfile(getRelay(url), displayRelayUrl(url))
|
||||
|
||||
export const deriveRelayDisplay = (url: string) =>
|
||||
derived(deriveRelay(url), $relay => displayRelayProfile($relay, displayRelayUrl(url)))
|
||||
@@ -1,110 +0,0 @@
|
||||
import Fuse, {IFuseOptions, FuseResult} from "fuse.js"
|
||||
import {debounce} from "throttle-debounce"
|
||||
import {derived} from "svelte/store"
|
||||
import {dec, inc, sortBy} from "@welshman/lib"
|
||||
import {PROFILE, PublishedProfile, RelayProfile} from "@welshman/util"
|
||||
import {load} from "@welshman/net"
|
||||
import {throttled} from "@welshman/store"
|
||||
import {Router} from "@welshman/router"
|
||||
import {getWotGraph, getMaxWot} from "./wot.js"
|
||||
import {profiles} from "./profiles.js"
|
||||
import {topics, Topic} from "./topics.js"
|
||||
import {relays} from "./relays.js"
|
||||
import {handlesByNip05} from "./handles.js"
|
||||
|
||||
export type SearchOptions<V, T> = {
|
||||
getValue: (item: T) => V
|
||||
fuseOptions?: IFuseOptions<T>
|
||||
onSearch?: (term: string) => void
|
||||
sortFn?: (items: FuseResult<T>) => any
|
||||
}
|
||||
|
||||
export type Search<V, T> = {
|
||||
options: T[]
|
||||
getValue: (item: T) => V
|
||||
getOption: (value: V) => T | undefined
|
||||
searchOptions: (term: string) => T[]
|
||||
searchValues: (term: string) => V[]
|
||||
}
|
||||
|
||||
export const createSearch = <V, T>(options: T[], opts: SearchOptions<V, T>): Search<V, T> => {
|
||||
const fuse = new Fuse(options, {...opts.fuseOptions, includeScore: true})
|
||||
const map = new Map<V, T>(options.map(item => [opts.getValue(item), item]))
|
||||
|
||||
const search = (term: string) => {
|
||||
opts.onSearch?.(term)
|
||||
|
||||
let results = term ? fuse.search(term) : options.map(item => ({item}) as FuseResult<T>)
|
||||
|
||||
if (opts.sortFn) {
|
||||
results = sortBy(opts.sortFn, results)
|
||||
}
|
||||
|
||||
return results.map(result => result.item)
|
||||
}
|
||||
|
||||
return {
|
||||
options,
|
||||
getValue: opts.getValue,
|
||||
getOption: (value: V) => map.get(value),
|
||||
searchOptions: (term: string) => search(term),
|
||||
searchValues: (term: string) => search(term).map(opts.getValue),
|
||||
}
|
||||
}
|
||||
|
||||
export const searchProfiles = debounce(500, (search: string) => {
|
||||
if (search.length > 2) {
|
||||
load({
|
||||
filters: [{kinds: [PROFILE], search}],
|
||||
relays: Router.get().Search().getUrls(),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const profileSearch = derived(
|
||||
[throttled(800, profiles), throttled(800, handlesByNip05)],
|
||||
([$profiles, $handlesByNip05]) => {
|
||||
// Remove invalid nip05's from profiles
|
||||
const options = $profiles.map(p => {
|
||||
const isNip05Valid = !p.nip05 || $handlesByNip05.get(p.nip05)?.pubkey === p.event.pubkey
|
||||
|
||||
return isNip05Valid ? p : {...p, nip05: ""}
|
||||
})
|
||||
|
||||
return createSearch(options, {
|
||||
onSearch: searchProfiles,
|
||||
getValue: (profile: PublishedProfile) => profile.event.pubkey,
|
||||
sortFn: ({score = 1, item}) => {
|
||||
const wotScore = getWotGraph().get(item.event.pubkey) || 0
|
||||
|
||||
return dec(score) * inc(wotScore / getMaxWot())
|
||||
},
|
||||
fuseOptions: {
|
||||
keys: [
|
||||
"nip05",
|
||||
{name: "name", weight: 0.8},
|
||||
{name: "display_name", weight: 0.5},
|
||||
{name: "about", weight: 0.3},
|
||||
],
|
||||
threshold: 0.3,
|
||||
shouldSort: false,
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
export const topicSearch = derived(topics, $topics =>
|
||||
createSearch($topics, {
|
||||
getValue: (topic: Topic) => topic.name,
|
||||
fuseOptions: {keys: ["name"]},
|
||||
}),
|
||||
)
|
||||
|
||||
export const relaySearch = derived(relays, $relays =>
|
||||
createSearch($relays, {
|
||||
getValue: (relay: RelayProfile) => relay.url,
|
||||
fuseOptions: {
|
||||
keys: ["url", "name", {name: "description", weight: 0.3}],
|
||||
},
|
||||
}),
|
||||
)
|
||||
@@ -1,36 +0,0 @@
|
||||
import {SEARCH_RELAYS, asDecryptedEvent, readList} from "@welshman/util"
|
||||
import {TrustedEvent} from "@welshman/util"
|
||||
import {
|
||||
deriveItemsByKey,
|
||||
deriveItems,
|
||||
makeForceLoadItem,
|
||||
makeLoadItem,
|
||||
makeDeriveItem,
|
||||
getter,
|
||||
} from "@welshman/store"
|
||||
import {repository} from "./core.js"
|
||||
import {makeOutboxLoader} from "./relayLists.js"
|
||||
|
||||
export const searchRelayListsByPubkey = deriveItemsByKey({
|
||||
repository,
|
||||
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
||||
filters: [{kinds: [SEARCH_RELAYS]}],
|
||||
getKey: searchRelayLists => searchRelayLists.event.pubkey,
|
||||
})
|
||||
|
||||
export const searchRelayLists = deriveItems(searchRelayListsByPubkey)
|
||||
|
||||
export const getSearchRelayListsByPubkey = getter(searchRelayListsByPubkey)
|
||||
|
||||
export const getSearchRelayLists = getter(searchRelayLists)
|
||||
|
||||
export const getSearchRelayList = (pubkey: string) => getSearchRelayListsByPubkey().get(pubkey)
|
||||
|
||||
export const forceLoadSearchRelayList = makeForceLoadItem(
|
||||
makeOutboxLoader(SEARCH_RELAYS),
|
||||
getSearchRelayList,
|
||||
)
|
||||
|
||||
export const loadSearchRelayList = makeLoadItem(makeOutboxLoader(SEARCH_RELAYS), getSearchRelayList)
|
||||
|
||||
export const deriveSearchRelayList = makeDeriveItem(searchRelayListsByPubkey, loadSearchRelayList)
|
||||
+55
-311
@@ -1,338 +1,82 @@
|
||||
import {Client, ClientOptions, PomadeSigner} from "@pomade/core"
|
||||
import {derived, writable} from "svelte/store"
|
||||
import {cached, randomId, append, omit, equals, assoc, TaskQueue} from "@welshman/lib"
|
||||
import {withGetter} from "@welshman/store"
|
||||
import {
|
||||
Wallet,
|
||||
WRAP,
|
||||
getPubkeyTagValues,
|
||||
StampedEvent,
|
||||
SignedEvent,
|
||||
getPubkey,
|
||||
} from "@welshman/util"
|
||||
import {
|
||||
Nip59,
|
||||
WrappedSigner,
|
||||
Nip46Broker,
|
||||
Nip46Signer,
|
||||
Nip07Signer,
|
||||
Nip01Signer,
|
||||
Nip55Signer,
|
||||
ISigner,
|
||||
} from "@welshman/signer"
|
||||
import {WrapManager} from "@welshman/net"
|
||||
import {tracker, repository} from "./core.js"
|
||||
import {Client as PomadeClient, PomadeSigner} from "@pomade/core"
|
||||
import type {ClientOptions as PomadeClientOptions} from "@pomade/core"
|
||||
import type {MaybeAsync} from "@welshman/lib"
|
||||
import {Nip46Broker, Nip46Signer, Nip07Signer, Nip01Signer, Nip55Signer} from "@welshman/signer"
|
||||
import type {ISigner} from "@welshman/signer"
|
||||
|
||||
export enum SessionMethod {
|
||||
Nip01 = "nip01",
|
||||
Nip07 = "nip07",
|
||||
Nip46 = "nip46",
|
||||
Nip55 = "nip55",
|
||||
Pomade = "pomade",
|
||||
Pubkey = "pubkey",
|
||||
Anonymous = "anonymous",
|
||||
// ── Sessions: serializable {method, data} descriptors ──
|
||||
|
||||
export type Session<M extends string = string, D = unknown> = {
|
||||
method: M
|
||||
data: D
|
||||
}
|
||||
|
||||
export type SessionNip01 = {
|
||||
method: SessionMethod.Nip01
|
||||
pubkey: string
|
||||
secret: string
|
||||
// ── Session handlers: a method string, its data shape, and how to build a signer ──
|
||||
|
||||
export type SessionHandler<M extends string, D> = {
|
||||
method: M
|
||||
getSigner: (data: D) => MaybeAsync<ISigner>
|
||||
}
|
||||
|
||||
export type SessionNip07 = {
|
||||
method: SessionMethod.Nip07
|
||||
pubkey: string
|
||||
}
|
||||
/**
|
||||
* Define a session handler. `M` and `D` are inferred from the arguments, so
|
||||
* `getSigner` is type-checked against the data shape — and the same handler is
|
||||
* used to build typed sessions (`toSession`) and to reconstruct signers.
|
||||
*/
|
||||
export const defineSessionHandler = <M extends string, D>(handler: SessionHandler<M, D>) => handler
|
||||
|
||||
export type SessionNip46 = {
|
||||
method: SessionMethod.Nip46
|
||||
pubkey: string
|
||||
secret: string
|
||||
handler: {
|
||||
pubkey: string
|
||||
relays: string[]
|
||||
}
|
||||
}
|
||||
/** Build a typed, serializable session from a handler and its data. */
|
||||
export const toSession = <M extends string, D>(
|
||||
handler: SessionHandler<M, D>,
|
||||
data: D,
|
||||
): Session<M, D> => ({method: handler.method, data})
|
||||
|
||||
export type SessionNip55 = {
|
||||
method: SessionMethod.Nip55
|
||||
pubkey: string
|
||||
signer: string
|
||||
}
|
||||
// ── Built-in handlers ──
|
||||
|
||||
export type SessionPomade = {
|
||||
method: SessionMethod.Pomade
|
||||
pubkey: string
|
||||
clientOptions: ClientOptions
|
||||
email: string
|
||||
}
|
||||
|
||||
export type SessionPubkey = {
|
||||
method: SessionMethod.Pubkey
|
||||
pubkey: string
|
||||
}
|
||||
|
||||
export type SessionAnonymous = {
|
||||
method: SessionMethod.Anonymous
|
||||
}
|
||||
|
||||
export type SessionAnyMethod =
|
||||
| SessionNip01
|
||||
| SessionNip07
|
||||
| SessionNip46
|
||||
| SessionNip55
|
||||
| SessionPomade
|
||||
| SessionPubkey
|
||||
| SessionAnonymous
|
||||
|
||||
export type Session = SessionAnyMethod & {wallet?: Wallet} & Record<string, any>
|
||||
|
||||
export const pubkey = withGetter(writable<string | undefined>(undefined))
|
||||
|
||||
export const sessions = withGetter(writable<Record<string, Session>>({}))
|
||||
|
||||
export const session = withGetter(
|
||||
derived([pubkey, sessions], ([$pubkey, $sessions]) => ($pubkey ? $sessions[$pubkey] : undefined)),
|
||||
)
|
||||
|
||||
export const getSession = (pubkey: string) => sessions.get()[pubkey]
|
||||
|
||||
export const addSession = (session: Session) => {
|
||||
sessions.update(assoc(session.pubkey, session))
|
||||
pubkey.set(session.pubkey)
|
||||
}
|
||||
|
||||
export const putSession = (session: Session) => {
|
||||
if (!equals(getSession(session.pubkey), session)) {
|
||||
sessions.update(assoc(session.pubkey, session))
|
||||
}
|
||||
}
|
||||
|
||||
export const updateSession = (pubkey: string, f: (session: Session) => Session) =>
|
||||
putSession(f(getSession(pubkey)))
|
||||
|
||||
export const dropSession = (_pubkey: string) => {
|
||||
getSigner.pop(getSession(_pubkey))?.cleanup?.()
|
||||
pubkey.update($pubkey => ($pubkey === _pubkey ? undefined : $pubkey))
|
||||
sessions.update($sessions => omit([_pubkey], $sessions))
|
||||
}
|
||||
|
||||
export const clearSessions = () => {
|
||||
for (const pubkey of Object.keys(sessions.get())) {
|
||||
dropSession(pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
// Session factories
|
||||
|
||||
export const makeNip01Session = (secret: string): SessionNip01 => ({
|
||||
method: SessionMethod.Nip01,
|
||||
secret,
|
||||
pubkey: getPubkey(secret),
|
||||
export const nip01 = defineSessionHandler({
|
||||
method: "nip01",
|
||||
getSigner: (data: {secret: string}) => new Nip01Signer(data.secret),
|
||||
})
|
||||
|
||||
export const makeNip07Session = (pubkey: string): SessionNip07 => ({
|
||||
method: SessionMethod.Nip07,
|
||||
pubkey,
|
||||
export const nip07 = defineSessionHandler({
|
||||
method: "nip07",
|
||||
getSigner: (_data: Record<string, never>) => new Nip07Signer(),
|
||||
})
|
||||
|
||||
export const makeNip46Session = (
|
||||
pubkey: string,
|
||||
clientSecret: string,
|
||||
signerPubkey: string,
|
||||
relays: string[],
|
||||
): SessionNip46 => ({
|
||||
method: SessionMethod.Nip46,
|
||||
pubkey,
|
||||
secret: clientSecret,
|
||||
handler: {pubkey: signerPubkey, relays},
|
||||
export const nip46 = defineSessionHandler({
|
||||
method: "nip46",
|
||||
getSigner: (data: {clientSecret: string; signerPubkey: string; relays: string[]}) =>
|
||||
new Nip46Signer(new Nip46Broker(data)),
|
||||
})
|
||||
|
||||
export const makeNip55Session = (pubkey: string, signer: string): SessionNip55 => ({
|
||||
method: SessionMethod.Nip55,
|
||||
pubkey,
|
||||
signer,
|
||||
export const nip55 = defineSessionHandler({
|
||||
method: "nip55",
|
||||
getSigner: (data: {pubkey: string; signer: string}) => new Nip55Signer(data.signer, data.pubkey),
|
||||
})
|
||||
|
||||
export const makePomadeSession = (
|
||||
pubkey: string,
|
||||
email: string,
|
||||
clientOptions: ClientOptions,
|
||||
): SessionPomade => ({
|
||||
method: SessionMethod.Pomade,
|
||||
pubkey,
|
||||
clientOptions,
|
||||
email,
|
||||
export const pomade = defineSessionHandler({
|
||||
method: "pomade",
|
||||
getSigner: (data: {clientOptions: PomadeClientOptions; email: string}) =>
|
||||
new PomadeSigner(new PomadeClient(data.clientOptions)),
|
||||
})
|
||||
|
||||
export const makePubkeySession = (pubkey: string): SessionPubkey => ({
|
||||
method: SessionMethod.Pubkey,
|
||||
pubkey,
|
||||
})
|
||||
// ── Registry: deserialize a stored session back into a signer ──
|
||||
|
||||
// Type guards
|
||||
export const sessionHandlers = new Map<string, SessionHandler<string, any>>()
|
||||
|
||||
export const isNip01Session = (session?: Session): session is SessionNip01 =>
|
||||
session?.method === SessionMethod.Nip01
|
||||
|
||||
export const isNip07Session = (session?: Session): session is SessionNip07 =>
|
||||
session?.method === SessionMethod.Nip07
|
||||
|
||||
export const isNip46Session = (session?: Session): session is SessionNip46 =>
|
||||
session?.method === SessionMethod.Nip46
|
||||
|
||||
export const isNip55Session = (session?: Session): session is SessionNip55 =>
|
||||
session?.method === SessionMethod.Nip55
|
||||
|
||||
export const isPomadeSession = (session?: Session): session is SessionPomade =>
|
||||
session?.method === SessionMethod.Pomade
|
||||
|
||||
export const isPubkeySession = (session?: Session): session is SessionPubkey =>
|
||||
session?.method === SessionMethod.Pubkey
|
||||
|
||||
// Login utilities
|
||||
|
||||
export const loginWithNip01 = (secret: string) => addSession(makeNip01Session(secret))
|
||||
|
||||
export const loginWithNip07 = (pubkey: string) => addSession(makeNip07Session(pubkey))
|
||||
|
||||
export const loginWithNip46 = (
|
||||
pubkey: string,
|
||||
clientSecret: string,
|
||||
signerPubkey: string,
|
||||
relays: string[],
|
||||
) => addSession(makeNip46Session(pubkey, clientSecret, signerPubkey, relays))
|
||||
|
||||
export const loginWithNip55 = (pubkey: string, signer: string) =>
|
||||
addSession(makeNip55Session(pubkey, signer))
|
||||
|
||||
export const loginWithPomade = (pubkey: string, email: string, clientOptions: ClientOptions) =>
|
||||
addSession(makePomadeSession(pubkey, email, clientOptions))
|
||||
|
||||
export const loginWithPubkey = (pubkey: string) => addSession(makePubkeySession(pubkey))
|
||||
|
||||
// Other stuff
|
||||
|
||||
export const nip46Perms = "sign_event:22242,nip04_encrypt,nip04_decrypt,nip44_encrypt,nip44_decrypt"
|
||||
|
||||
export type SignerLogEntry = {
|
||||
id: string
|
||||
method: string
|
||||
started_at: number
|
||||
finished_at?: number
|
||||
ok?: boolean
|
||||
export const registerSessionHandler = (handler: SessionHandler<string, any>) => {
|
||||
sessionHandlers.set(handler.method, handler)
|
||||
}
|
||||
|
||||
export const signerLog = withGetter(writable<SignerLogEntry[]>([]))
|
||||
|
||||
export const wrapSigner = (signer: ISigner) =>
|
||||
new WrappedSigner(signer, async <T>(method: string, thunk: () => Promise<T>) => {
|
||||
const id = randomId()
|
||||
|
||||
signerLog.update(log => append({id, method, started_at: Date.now()}, log))
|
||||
|
||||
try {
|
||||
const result = await thunk()
|
||||
|
||||
signerLog.update(log =>
|
||||
log.map(x => (x.id === id ? {...x, finished_at: Date.now(), ok: true} : x)),
|
||||
)
|
||||
|
||||
return result
|
||||
} catch (error: any) {
|
||||
signerLog.update(log =>
|
||||
log.map(x => (x.id === id ? {...x, finished_at: Date.now(), ok: false} : x)),
|
||||
)
|
||||
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
export const getSigner = cached({
|
||||
maxSize: 100,
|
||||
getKey: ([session]: [Session | undefined]) => `${session?.method}:${session?.pubkey}`,
|
||||
getValue: ([session]: [Session | undefined]) => {
|
||||
if (isNip07Session(session)) return wrapSigner(new Nip07Signer())
|
||||
if (isNip01Session(session)) return wrapSigner(new Nip01Signer(session.secret))
|
||||
if (isNip55Session(session)) return wrapSigner(new Nip55Signer(session.signer, session.pubkey))
|
||||
if (isPomadeSession(session))
|
||||
return wrapSigner(new PomadeSigner(new Client(session.clientOptions)))
|
||||
if (isNip46Session(session)) {
|
||||
const {
|
||||
secret: clientSecret,
|
||||
handler: {relays, pubkey: signerPubkey},
|
||||
} = session
|
||||
const broker = new Nip46Broker({clientSecret, signerPubkey, relays})
|
||||
const signer = new Nip46Signer(broker)
|
||||
|
||||
return wrapSigner(signer)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const getSignerFromPubkey = (pubkey: string) => {
|
||||
const session = getSession(pubkey)
|
||||
|
||||
if (session) {
|
||||
return getSigner(session)
|
||||
}
|
||||
export const unregisterSessionHandler = (handler: SessionHandler<string, any>) => {
|
||||
sessionHandlers.delete(handler.method)
|
||||
}
|
||||
|
||||
export const signer = withGetter(derived(session, getSigner))
|
||||
export const getSignerFromSession = (session: Session): MaybeAsync<ISigner> | undefined =>
|
||||
sessionHandlers.get(session.method)?.getSigner(session.data)
|
||||
|
||||
export const sign = (event: StampedEvent) => signer.get()?.sign(event)
|
||||
// ── Initialize default session handlers ──
|
||||
|
||||
export const nip44EncryptToSelf = (payload: string) => {
|
||||
const $pubkey = pubkey.get()
|
||||
const $signer = signer.get()
|
||||
|
||||
if (!$signer) {
|
||||
throw new Error("Unable to encrypt to self without valid signer")
|
||||
}
|
||||
|
||||
return $signer.nip44.encrypt($pubkey!, payload)
|
||||
}
|
||||
|
||||
// Gift wrap utilities
|
||||
|
||||
export const wrapManager = new WrapManager({repository, tracker})
|
||||
|
||||
export const shouldUnwrap = withGetter(writable(false))
|
||||
|
||||
export const failedUnwraps = new Set<string>()
|
||||
|
||||
export const wrapQueue = new TaskQueue({
|
||||
batchSize: 5,
|
||||
batchDelay: 30,
|
||||
processItem: async (wrap: SignedEvent) => {
|
||||
for (const recipient of getPubkeyTagValues(wrap.tags)) {
|
||||
const signer = getSignerFromPubkey(recipient)
|
||||
|
||||
if (signer) {
|
||||
try {
|
||||
const rumor = await Nip59.fromSigner(signer).unwrap(wrap)
|
||||
|
||||
wrapManager.add({wrap, rumor, recipient})
|
||||
|
||||
return rumor
|
||||
} catch (e) {
|
||||
failedUnwraps.add(wrap.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const unwrapAndStore = async (wrap: SignedEvent) => {
|
||||
if (wrap.kind !== WRAP) {
|
||||
throw new Error("Tried to unwrap an invalid event")
|
||||
}
|
||||
|
||||
if (!shouldUnwrap.get()) {
|
||||
throw new Error("Discarding wrapped event because `shouldUnwrap` is not enabled")
|
||||
}
|
||||
|
||||
if (!failedUnwraps.has(wrap.id) && !wrapManager.getRumor(wrap.id)) {
|
||||
wrapQueue.push(wrap)
|
||||
}
|
||||
for (const sessionHandler of [nip01, nip07, nip46, nip55, pomade]) {
|
||||
registerSessionHandler(sessionHandler)
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import type {Filter} from "@welshman/util"
|
||||
import {isSignedEvent, SignedEvent} from "@welshman/util"
|
||||
import {push as basePush, pull as basePull, publishOne, requestOne} from "@welshman/net"
|
||||
import {repository} from "./core.js"
|
||||
import {getRelay} from "./relays.js"
|
||||
|
||||
const query = (filters: Filter[]) =>
|
||||
repository.query(filters, {shouldSort: filters.every(f => f.limit === undefined)})
|
||||
|
||||
export const hasNegentropy = (url: string) => {
|
||||
const relay = getRelay(url)
|
||||
|
||||
if (relay?.negentropy) return true
|
||||
if (relay?.supported_nips?.includes?.("77")) return true
|
||||
if (relay?.software?.includes?.("strfry") && !relay?.version?.match(/^0\./)) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export type AppSyncOpts = {
|
||||
relays: string[]
|
||||
filters: Filter[]
|
||||
}
|
||||
|
||||
export const pull = async ({relays, filters}: AppSyncOpts) => {
|
||||
const events = query(filters).filter(isSignedEvent)
|
||||
|
||||
await Promise.all(
|
||||
relays.map(async relay => {
|
||||
await (hasNegentropy(relay)
|
||||
? basePull({filters, events, relays: [relay]})
|
||||
: requestOne({filters, relay, autoClose: true}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const push = async ({relays, filters}: AppSyncOpts) => {
|
||||
const events = query(filters).filter(isSignedEvent)
|
||||
|
||||
await Promise.all(
|
||||
relays.map(async relay => {
|
||||
await (hasNegentropy(relay)
|
||||
? basePush({filters, events, relays: [relay]})
|
||||
: Promise.all(events.map((event: SignedEvent) => publishOne({event, relay}))))
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import {uniq, remove} from "@welshman/lib"
|
||||
import {
|
||||
getAddress,
|
||||
isReplaceable,
|
||||
getReplyTags,
|
||||
getPubkeyTagValues,
|
||||
isReplaceableKind,
|
||||
isShareableRelayUrl,
|
||||
} from "@welshman/util"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {Router} from "@welshman/router"
|
||||
import {displayProfileByPubkey} from "./profiles.js"
|
||||
import {pubkey} from "./session.js"
|
||||
|
||||
export const tagZapSplit = (pubkey: string, split = 1) => [
|
||||
"zap",
|
||||
pubkey,
|
||||
Router.get().FromPubkey(pubkey).getUrl() || "",
|
||||
String(split),
|
||||
]
|
||||
|
||||
export const tagPubkey = (pubkey: string, ...args: unknown[]) => [
|
||||
"p",
|
||||
pubkey,
|
||||
Router.get().FromPubkey(pubkey).getUrl() || "",
|
||||
displayProfileByPubkey(pubkey),
|
||||
]
|
||||
|
||||
export const tagEvent = (event: TrustedEvent, url = "", mark = "") => {
|
||||
if (!url) {
|
||||
url = Router.get().Event(event).getUrl() || ""
|
||||
}
|
||||
|
||||
const tags = [["e", event.id, url, mark, event.pubkey]]
|
||||
|
||||
if (isReplaceable(event)) {
|
||||
tags.push(["a", getAddress(event), url, mark, event.pubkey])
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
export const tagEventPubkeys = (event: TrustedEvent) =>
|
||||
uniq(remove(pubkey.get()!, [event.pubkey, ...getPubkeyTagValues(event.tags)])).map(tagPubkey)
|
||||
|
||||
export const tagEventForQuote = (event: TrustedEvent, relay?: string) => {
|
||||
const hint = relay || Router.get().Event(event).getUrl() || ""
|
||||
|
||||
return ["q", event.id, hint, event.pubkey]
|
||||
}
|
||||
|
||||
export const tagEventForReply = (event: TrustedEvent, relay?: string) => {
|
||||
const tags = tagEventPubkeys(event)
|
||||
const {roots, replies} = getReplyTags(event.tags)
|
||||
const parents = roots.length > 0 ? roots : replies
|
||||
const mark = parents.length > 0 ? "reply" : "root"
|
||||
const hint = relay || Router.get().Event(event).getUrl() || ""
|
||||
|
||||
// If the parent included roots use them, otherwise use replies as a fallback
|
||||
for (const [k, id, originalHint = "", _, pubkey = ""] of parents) {
|
||||
const hint = isShareableRelayUrl(originalHint)
|
||||
? originalHint
|
||||
: Router.get().EventRoots(event).getUrl()
|
||||
|
||||
tags.push([k, id, hint || "", "root", pubkey])
|
||||
}
|
||||
|
||||
// e-tag the event
|
||||
tags.push(["e", event.id, hint, mark, event.pubkey])
|
||||
|
||||
// a-tag the event
|
||||
if (isReplaceable(event)) {
|
||||
tags.push(["a", getAddress(event), hint, mark, event.pubkey])
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
export const tagEventForComment = (event: TrustedEvent, relay?: string) => {
|
||||
const pubkeyHint = Router.get().FromPubkey(event.pubkey).getUrl() || ""
|
||||
const eventHint = relay || Router.get().Event(event).getUrl() || ""
|
||||
const address = getAddress(event)
|
||||
const seenRoots = new Set<string>()
|
||||
const tags: string[][] = []
|
||||
|
||||
for (const [t, ...tag] of event.tags) {
|
||||
if (["K", "E", "A", "I", "P"].includes(t)) {
|
||||
tags.push([t, ...tag])
|
||||
seenRoots.add(t)
|
||||
}
|
||||
}
|
||||
|
||||
if (seenRoots.size === 0) {
|
||||
tags.push(["K", String(event.kind)])
|
||||
tags.push(["P", event.pubkey, pubkeyHint])
|
||||
tags.push(["E", event.id, eventHint, event.pubkey])
|
||||
|
||||
if (isReplaceableKind(event.kind)) {
|
||||
tags.push(["A", address, eventHint, event.pubkey])
|
||||
}
|
||||
}
|
||||
|
||||
tags.push(["k", String(event.kind)])
|
||||
tags.push(["p", event.pubkey, pubkeyHint])
|
||||
tags.push(["e", event.id, eventHint, event.pubkey])
|
||||
|
||||
if (isReplaceableKind(event.kind)) {
|
||||
tags.push(["a", address, eventHint, event.pubkey])
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
export const tagEventForReaction = (event: TrustedEvent, relay?: string) => {
|
||||
const hint = relay || Router.get().Event(event).getUrl() || ""
|
||||
const tags: string[][] = []
|
||||
|
||||
// Mention the event's author
|
||||
if (event.pubkey !== pubkey.get()) {
|
||||
tags.push(tagPubkey(event.pubkey))
|
||||
}
|
||||
|
||||
tags.push(["k", String(event.kind)])
|
||||
tags.push(["e", event.id, hint])
|
||||
|
||||
if (isReplaceable(event)) {
|
||||
tags.push(["a", getAddress(event), hint])
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
@@ -1,431 +0,0 @@
|
||||
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 {
|
||||
HashedEvent,
|
||||
EventTemplate,
|
||||
SignedEvent,
|
||||
isSignedEvent,
|
||||
WRAPPED_KINDS,
|
||||
prep,
|
||||
makePow,
|
||||
} from "@welshman/util"
|
||||
import {
|
||||
publish,
|
||||
PublishStatus,
|
||||
PublishResult,
|
||||
PublishOptions,
|
||||
PublishResultsByRelay,
|
||||
} from "@welshman/net"
|
||||
import {ISigner, Nip01Signer, Nip59} from "@welshman/signer"
|
||||
import {repository, tracker} from "./core.js"
|
||||
import {pubkey, signer, wrapManager} from "./session.js"
|
||||
|
||||
export type ThunkOptions = Override<
|
||||
PublishOptions,
|
||||
{
|
||||
event: EventTemplate
|
||||
recipient?: string
|
||||
delay?: number
|
||||
pow?: number
|
||||
}
|
||||
>
|
||||
|
||||
export class Thunk {
|
||||
_subs: Subscriber<Thunk>[] = []
|
||||
|
||||
pubkey: string
|
||||
signer: ISigner
|
||||
event: HashedEvent
|
||||
results: PublishResultsByRelay = {}
|
||||
complete = defer<void>()
|
||||
controller = new AbortController()
|
||||
wrap?: SignedEvent
|
||||
|
||||
constructor(readonly options: ThunkOptions) {
|
||||
if (!options.recipient && WRAPPED_KINDS.includes(options.event.kind)) {
|
||||
throw new Error(`Attempted to publish a kind ${options.event.kind} without wrapping it`)
|
||||
}
|
||||
|
||||
const $pubkey = pubkey.get()
|
||||
|
||||
if (!$pubkey) {
|
||||
throw new Error(`Attempted to publish an event without an active pubkey`)
|
||||
}
|
||||
|
||||
const $signer = signer.get()
|
||||
|
||||
if (!$signer) {
|
||||
throw new Error(`Attempted to publish an event without an active signer`)
|
||||
}
|
||||
|
||||
this.pubkey = $pubkey
|
||||
this.signer = $signer
|
||||
this.event = prep(options.event, this.pubkey)
|
||||
|
||||
for (const relay of options.relays) {
|
||||
this.results[relay] = {
|
||||
relay,
|
||||
status: PublishStatus.Sending,
|
||||
detail: "sending...",
|
||||
}
|
||||
}
|
||||
|
||||
this.controller.signal.addEventListener("abort", () => {
|
||||
for (const relay of options.relays) {
|
||||
this._setAborted({
|
||||
relay,
|
||||
status: PublishStatus.Aborted,
|
||||
detail: "aborted",
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
_notify() {
|
||||
for (const subscriber of this._subs) {
|
||||
subscriber(this)
|
||||
}
|
||||
}
|
||||
|
||||
_fail(detail: string) {
|
||||
for (const relay of this.options.relays) {
|
||||
this.results[relay] = {
|
||||
relay,
|
||||
status: PublishStatus.Failure,
|
||||
detail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
this._notify()
|
||||
}
|
||||
|
||||
_setPending = (result: PublishResult) => {
|
||||
this.options.onPending?.(result)
|
||||
this.results[result.relay] = result
|
||||
this._notify()
|
||||
}
|
||||
|
||||
_setTimeout = (result: PublishResult) => {
|
||||
this.options.onTimeout?.(result)
|
||||
this.results[result.relay] = result
|
||||
this._notify()
|
||||
}
|
||||
|
||||
_setAborted = (result: PublishResult) => {
|
||||
this.options.onAborted?.(result)
|
||||
this.results[result.relay] = result
|
||||
this._notify()
|
||||
}
|
||||
|
||||
async _publish(event: SignedEvent) {
|
||||
// Wait if the thunk is to be delayed
|
||||
if (this.options.delay) {
|
||||
await sleep(this.options.delay)
|
||||
}
|
||||
|
||||
// Skip publishing if aborted
|
||||
if (this.controller.signal.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
// Send it off
|
||||
await publish({
|
||||
...this.options,
|
||||
event,
|
||||
onSuccess: (result: PublishResult) => {
|
||||
this.options.onSuccess?.(result)
|
||||
this.results[result.relay] = result
|
||||
this._notify()
|
||||
},
|
||||
onFailure: (result: PublishResult) => {
|
||||
tracker.removeRelay(event.id, result.relay)
|
||||
this.options.onFailure?.(result)
|
||||
this.results[result.relay] = result
|
||||
this._notify()
|
||||
},
|
||||
onPending: this._setPending,
|
||||
onTimeout: (result: PublishResult) => {
|
||||
tracker.removeRelay(event.id, result.relay)
|
||||
this._setTimeout(result)
|
||||
},
|
||||
onAborted: (result: PublishResult) => {
|
||||
tracker.removeRelay(event.id, result.relay)
|
||||
this._setAborted(result)
|
||||
},
|
||||
onComplete: (result: PublishResult) => {
|
||||
this.options.onComplete?.(result)
|
||||
this._subs = []
|
||||
},
|
||||
})
|
||||
|
||||
// Notify the caller that we're done
|
||||
this.complete.resolve()
|
||||
}
|
||||
|
||||
async publish() {
|
||||
// Handle abort immediately if possible
|
||||
if (this.controller.signal.aborted) return
|
||||
|
||||
const {recipient} = this.options
|
||||
|
||||
// If we're sending it privately, wrap the event using nip 59
|
||||
if (recipient) {
|
||||
const wrapper = Nip01Signer.ephemeral()
|
||||
const nip59 = new Nip59(this.signer, wrapper)
|
||||
|
||||
this.wrap = await nip59.wrap(recipient, this.event)
|
||||
|
||||
// If we're calculating pow, update the hash and re-sign
|
||||
if (this.options.pow) {
|
||||
this.wrap = await wrapper.sign(await makePow(this.wrap, this.options.pow).result, {
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
})
|
||||
}
|
||||
|
||||
wrapManager.add({recipient, wrap: this.wrap, rumor: this.event})
|
||||
|
||||
return this._publish(this.wrap)
|
||||
}
|
||||
|
||||
// If the event has been signed, we're good to go
|
||||
if (isSignedEvent(this.event)) {
|
||||
if (this.options.pow) {
|
||||
console.warn("Event is already signed, skipping proof of work calculation")
|
||||
}
|
||||
|
||||
return this._publish(this.event)
|
||||
}
|
||||
|
||||
// Allow for lazily signing/powing events in order to decrease apparent latency in the UI
|
||||
// that results from waiting for remote signers
|
||||
try {
|
||||
if (this.options.pow) {
|
||||
this.event = await makePow(this.event, this.options.pow).result
|
||||
}
|
||||
|
||||
const signedEvent = await this.signer.sign(this.event, {
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
})
|
||||
|
||||
// Update tracker and repository with the signed event since the id will have changed
|
||||
if (this.options.pow) {
|
||||
for (const url of this.options.relays) {
|
||||
tracker.removeRelay(this.event.id, url)
|
||||
tracker.track(signedEvent.id, url)
|
||||
}
|
||||
}
|
||||
|
||||
repository.removeEvent(this.event.id)
|
||||
repository.publish(signedEvent)
|
||||
|
||||
return this._publish(signedEvent)
|
||||
} catch (e: any) {
|
||||
console.error("Failed to sign event", e)
|
||||
return this._fail(String(e || "Failed to sign event"))
|
||||
}
|
||||
}
|
||||
|
||||
enqueue() {
|
||||
thunkQueue.push(this)
|
||||
|
||||
for (const url of this.options.relays) {
|
||||
tracker.track(this.event.id, url)
|
||||
}
|
||||
|
||||
repository.publish(this.event)
|
||||
thunks.update($thunks => append(this, $thunks))
|
||||
|
||||
this.controller.signal.addEventListener("abort", () => {
|
||||
if (this.wrap) {
|
||||
wrapManager.remove(this.wrap.id)
|
||||
} else {
|
||||
repository.removeEvent(this.event.id)
|
||||
}
|
||||
|
||||
thunks.update($thunks => remove(this, $thunks))
|
||||
})
|
||||
}
|
||||
|
||||
subscribe(subscriber: Subscriber<Thunk>) {
|
||||
this._subs.push(subscriber)
|
||||
|
||||
subscriber(this)
|
||||
|
||||
return () => {
|
||||
this._subs = remove(subscriber, this._subs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class MergedThunk {
|
||||
_subs: Subscriber<MergedThunk>[] = []
|
||||
|
||||
results: PublishResultsByRelay = {}
|
||||
|
||||
constructor(readonly thunks: Thunk[]) {
|
||||
const {Aborted, Failure, Timeout, Pending, Sending, Success} = PublishStatus
|
||||
const relays = new Set(thunks.flatMap(thunk => thunk.options.relays))
|
||||
|
||||
for (const thunk of thunks) {
|
||||
thunk.subscribe($thunk => {
|
||||
this.results = {}
|
||||
|
||||
for (const relay of relays) {
|
||||
for (const status of [Aborted, Failure, Timeout, Pending, Sending, Success]) {
|
||||
const thunk = thunks.find(t => t.results[relay]?.status === status)
|
||||
|
||||
if (thunk) {
|
||||
this.results[relay] = thunk.results[relay]!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._notify()
|
||||
|
||||
if (thunks.every(thunkIsComplete)) {
|
||||
this._subs = []
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
_notify() {
|
||||
for (const subscriber of this._subs) {
|
||||
subscriber(this)
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(subscriber: Subscriber<MergedThunk>) {
|
||||
this._subs.push(subscriber)
|
||||
|
||||
subscriber(this)
|
||||
|
||||
return () => {
|
||||
this._subs = remove(subscriber, this._subs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type AbstractThunk = Thunk | MergedThunk
|
||||
|
||||
export const isThunk = (thunk: AbstractThunk): thunk is Thunk => thunk instanceof Thunk
|
||||
|
||||
export const isMergedThunk = (thunk: AbstractThunk): thunk is MergedThunk =>
|
||||
thunk instanceof MergedThunk
|
||||
|
||||
// Thunk status urls
|
||||
|
||||
export const getThunkUrlsWithStatus = (
|
||||
statuses: PublishStatus | PublishStatus[],
|
||||
thunk: AbstractThunk,
|
||||
) => {
|
||||
statuses = ensurePlural(statuses)
|
||||
|
||||
return Object.entries(thunk.results)
|
||||
.filter(([_, {status}]) => statuses.includes(status))
|
||||
.map(nth(0)) as string[]
|
||||
}
|
||||
|
||||
export const getCompleteThunkUrls = (thunk: AbstractThunk) =>
|
||||
getThunkUrlsWithStatus(
|
||||
without([PublishStatus.Sending, PublishStatus.Pending], Object.values(PublishStatus)),
|
||||
thunk,
|
||||
)
|
||||
|
||||
export const getIncompleteThunkUrls = (thunk: AbstractThunk) =>
|
||||
getThunkUrlsWithStatus([PublishStatus.Sending, PublishStatus.Pending], thunk)
|
||||
|
||||
export const getFailedThunkUrls = (thunk: AbstractThunk) =>
|
||||
getThunkUrlsWithStatus([PublishStatus.Failure, PublishStatus.Timeout], thunk)
|
||||
|
||||
// Thunk status checks
|
||||
|
||||
export const thunkHasStatus = (statuses: PublishStatus | PublishStatus[], thunk: AbstractThunk) =>
|
||||
getThunkUrlsWithStatus(statuses, thunk).length > 0
|
||||
|
||||
export const thunkIsComplete = (thunk: AbstractThunk) =>
|
||||
!thunkHasStatus([PublishStatus.Sending, PublishStatus.Pending], thunk)
|
||||
|
||||
// Thunk errors
|
||||
|
||||
export const getThunkError = (thunk: Thunk) => {
|
||||
for (const [_, {status, detail}] of Object.entries(thunk.results)) {
|
||||
if (status === PublishStatus.Failure) {
|
||||
return detail
|
||||
}
|
||||
}
|
||||
|
||||
if (thunkIsComplete(thunk)) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// Thunk utilities that return promises
|
||||
|
||||
export const waitForThunkError = (thunk: Thunk) =>
|
||||
new Promise<string>(resolve => {
|
||||
thunk.subscribe($thunk => {
|
||||
const error = getThunkError($thunk)
|
||||
|
||||
if (error !== undefined) {
|
||||
resolve(error)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
export const waitForThunkCompletion = (thunk: Thunk) =>
|
||||
new Promise<void>(resolve => {
|
||||
thunk.subscribe($thunk => {
|
||||
if (thunkIsComplete($thunk)) {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 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[]) =>
|
||||
new MergedThunk(Array.from(flattenThunks(thunks)))
|
||||
|
||||
export function* flattenThunks(thunks: AbstractThunk[]): Iterable<Thunk> {
|
||||
for (const thunk of thunks) {
|
||||
if (isMergedThunk(thunk)) {
|
||||
yield* flattenThunks(thunk.thunks)
|
||||
} else {
|
||||
yield 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)
|
||||
@@ -1,49 +0,0 @@
|
||||
import {readable} from "svelte/store"
|
||||
import {on, call} from "@welshman/lib"
|
||||
import {deriveItems} from "@welshman/store"
|
||||
import {getTopicTagValues} from "@welshman/util"
|
||||
import {repository} from "./core.js"
|
||||
|
||||
export type Topic = {
|
||||
name: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export const topicsByName = call(() => {
|
||||
const topicsByName = new Map<string, Topic>()
|
||||
|
||||
const addTopic = (name: string) => {
|
||||
const topic = topicsByName.get(name)
|
||||
|
||||
if (topic) {
|
||||
topic.count++
|
||||
} else {
|
||||
topicsByName.set(name, {name, count: 1})
|
||||
}
|
||||
}
|
||||
|
||||
for (const tagString of repository.eventsByTag.keys()) {
|
||||
if (tagString.startsWith("t:")) {
|
||||
addTopic(tagString.slice(2).toLowerCase())
|
||||
}
|
||||
}
|
||||
|
||||
return readable<Map<string, Topic>>(topicsByName, set => {
|
||||
return on(repository, "update", ({added}) => {
|
||||
let dirty = false
|
||||
|
||||
for (const event of added) {
|
||||
for (const name of getTopicTagValues(event.tags)) {
|
||||
addTopic(name)
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
|
||||
if (dirty) {
|
||||
set(topicsByName)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
export const topics = deriveItems(topicsByName)
|
||||
+47
-84
@@ -1,94 +1,57 @@
|
||||
import {derived, Readable} from "svelte/store"
|
||||
import {ItemsByKey, deriveDeduplicated} from "@welshman/store"
|
||||
import {pubkey} from "./session.js"
|
||||
import {profilesByPubkey, forceLoadProfile, loadProfile} from "./profiles.js"
|
||||
import {followListsByPubkey, forceLoadFollowList, loadFollowList} from "./follows.js"
|
||||
import {pinListsByPubkey, forceLoadPinList, loadPinList} from "./pins.js"
|
||||
import {muteListsByPubkey, forceLoadMuteList, loadMuteList} from "./mutes.js"
|
||||
import {
|
||||
blossomServerListsByPubkey,
|
||||
forceLoadBlossomServerList,
|
||||
loadBlossomServerList,
|
||||
} from "./blossom.js"
|
||||
import {relayListsByPubkey, forceLoadRelayList, loadRelayList} from "./relayLists.js"
|
||||
import {
|
||||
messagingRelayListsByPubkey,
|
||||
forceLoadMessagingRelayList,
|
||||
loadMessagingRelayList,
|
||||
} from "./messagingRelayLists.js"
|
||||
import {
|
||||
blockedRelayListsByPubkey,
|
||||
forceLoadBlockedRelayList,
|
||||
loadBlockedRelayList,
|
||||
} from "./blockedRelayLists.js"
|
||||
import {
|
||||
searchRelayListsByPubkey,
|
||||
forceLoadSearchRelayList,
|
||||
loadSearchRelayList,
|
||||
} from "./searchRelayLists.js"
|
||||
import {wotGraph, getWotGraph} from "./wot.js"
|
||||
import type {StampedEvent} from "@welshman/util"
|
||||
import type {ISigner} from "@welshman/signer"
|
||||
import {LoggingSigner} from "./logging.js"
|
||||
import {getSignerFromSession} from "./session.js"
|
||||
import type {Session} from "./session.js"
|
||||
import type {IApp} from "./app.js"
|
||||
|
||||
export const makeUserData = <T>(
|
||||
itemsByKey: Readable<ItemsByKey<T>>,
|
||||
onDerive?: (key: string, ...args: any[]) => void,
|
||||
) =>
|
||||
deriveDeduplicated([itemsByKey, pubkey], ([$itemsByKey, $pubkey]) => {
|
||||
if (!$pubkey) return undefined
|
||||
/**
|
||||
* A single identity: a pubkey plus the signer that proves it. An `App` is
|
||||
* centered on (at most) one `User`, since the data a user can access depends
|
||||
* entirely on who they are.
|
||||
*/
|
||||
export class User {
|
||||
constructor(
|
||||
readonly pubkey: string,
|
||||
readonly signer: ISigner,
|
||||
) {}
|
||||
|
||||
onDerive?.($pubkey)
|
||||
|
||||
return $itemsByKey.get($pubkey)
|
||||
})
|
||||
|
||||
export const makeUserLoader =
|
||||
(loadItem: (key: string, ...args: any[]) => void) =>
|
||||
async (...args: any[]) => {
|
||||
const $pubkey = pubkey.get()
|
||||
|
||||
if ($pubkey) {
|
||||
await loadItem($pubkey, ...args)
|
||||
static async fromSigner(signer: ISigner) {
|
||||
if (!(signer instanceof LoggingSigner)) {
|
||||
signer = new LoggingSigner(signer)
|
||||
}
|
||||
|
||||
const pubkey = await signer.getPubkey()
|
||||
|
||||
return new User(pubkey, signer)
|
||||
}
|
||||
|
||||
export const userProfile = makeUserData(profilesByPubkey, loadProfile)
|
||||
export const forceLoadUserProfile = makeUserLoader(forceLoadProfile)
|
||||
export const loadUserProfile = makeUserLoader(loadProfile)
|
||||
/**
|
||||
* Reconstruct a signing user from a persisted session, using the registered
|
||||
* session handlers to find the one for the session's method. The signer is
|
||||
* wrapped in a `LoggingSigner` (observe it with `makeAppPolicyLogger`) and the
|
||||
* pubkey is derived from it. Returns undefined when no handler is registered
|
||||
* for the session's method.
|
||||
*/
|
||||
static async fromSession(session: Session): Promise<User | undefined> {
|
||||
const signer = await getSignerFromSession(session)
|
||||
|
||||
export const userFollowList = makeUserData(followListsByPubkey, loadFollowList)
|
||||
export const forceLoadUserFollowList = makeUserLoader(forceLoadFollowList)
|
||||
export const loadUserFollowList = makeUserLoader(loadFollowList)
|
||||
return signer ? User.fromSigner(signer) : undefined
|
||||
}
|
||||
|
||||
export const userMuteList = makeUserData(muteListsByPubkey, loadMuteList)
|
||||
export const forceLoadUserMuteList = makeUserLoader(forceLoadMuteList)
|
||||
export const loadUserMuteList = makeUserLoader(loadMuteList)
|
||||
/**
|
||||
* Return the app'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(app: IApp): User {
|
||||
if (!app.user) {
|
||||
throw new Error("This action requires a signed-in user")
|
||||
}
|
||||
|
||||
export const userPinList = makeUserData(pinListsByPubkey, loadPinList)
|
||||
export const forceLoadUserPinList = makeUserLoader(forceLoadPinList)
|
||||
export const loadUserPinList = makeUserLoader(loadPinList)
|
||||
return app.user
|
||||
}
|
||||
|
||||
export const userRelayList = makeUserData(relayListsByPubkey, loadRelayList)
|
||||
export const forceLoadUserRelayList = makeUserLoader(forceLoadRelayList)
|
||||
export const loadUserRelayList = makeUserLoader(loadRelayList)
|
||||
sign = (event: StampedEvent) => this.signer.sign(event)
|
||||
|
||||
export const userMessagingRelayList = makeUserData(
|
||||
messagingRelayListsByPubkey,
|
||||
loadMessagingRelayList,
|
||||
)
|
||||
export const forceLoadUserMessagingRelayList = makeUserLoader(forceLoadMessagingRelayList)
|
||||
export const loadUserMessagingRelayList = makeUserLoader(loadMessagingRelayList)
|
||||
|
||||
export const userSearchRelayList = makeUserData(searchRelayListsByPubkey, loadSearchRelayList)
|
||||
export const forceLoadUserSearchRelayList = makeUserLoader(forceLoadSearchRelayList)
|
||||
export const loadUserSearchRelayList = makeUserLoader(loadSearchRelayList)
|
||||
|
||||
export const userBlockedRelayList = makeUserData(blockedRelayListsByPubkey, loadBlockedRelayList)
|
||||
export const forceLoadUserBlockedRelayList = makeUserLoader(forceLoadBlockedRelayList)
|
||||
export const loadUserBlockedRelayList = makeUserLoader(loadBlockedRelayList)
|
||||
|
||||
export const userBlossomServerList = makeUserData(blossomServerListsByPubkey, loadBlossomServerList)
|
||||
export const forceLoadUserBlossomServerList = makeUserLoader(forceLoadBlossomServerList)
|
||||
export const loadUserBlossomServerList = makeUserLoader(loadBlossomServerList)
|
||||
|
||||
export const getUserWotScore = (tpk: string) => getWotGraph().get(tpk) || 0
|
||||
|
||||
export const deriveUserWotScore = (tpk: string) => derived(wotGraph, $g => $g.get(tpk) || 0)
|
||||
nip44EncryptToSelf = (payload: string) => this.signer.nip44.encrypt(this.pubkey, payload)
|
||||
}
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import {derived, writable} from "svelte/store"
|
||||
import {max, throttle, addToMapKey, inc, dec} from "@welshman/lib"
|
||||
import {getListTags, getPubkeyTagValues} from "@welshman/util"
|
||||
import {throttled, getter} from "@welshman/store"
|
||||
import {pubkey} from "./session.js"
|
||||
import {followLists, getFollowListsByPubkey, getFollowList} from "./follows.js"
|
||||
import {muteLists, getMuteList} from "./mutes.js"
|
||||
|
||||
export const getFollows = (pubkey: string) => getPubkeyTagValues(getListTags(getFollowList(pubkey)))
|
||||
|
||||
export const getMutes = (pubkey: string) => getPubkeyTagValues(getListTags(getMuteList(pubkey)))
|
||||
|
||||
export const getNetwork = (pubkey: string) => {
|
||||
const pubkeys = new Set(getFollows(pubkey))
|
||||
const network = new Set<string>()
|
||||
|
||||
for (const follow of pubkeys) {
|
||||
for (const tpk of getFollows(follow)) {
|
||||
if (!pubkeys.has(tpk)) {
|
||||
network.add(tpk)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(network)
|
||||
}
|
||||
|
||||
export const followersByPubkey = derived(throttled(1000, followLists), lists => {
|
||||
const $followersByPubkey = new Map<string, Set<string>>()
|
||||
|
||||
for (const list of lists) {
|
||||
for (const pubkey of getPubkeyTagValues(getListTags(list))) {
|
||||
addToMapKey($followersByPubkey, pubkey, list.event.pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
return $followersByPubkey
|
||||
})
|
||||
|
||||
export const getFollowersByPubkey = getter(followersByPubkey)
|
||||
|
||||
export const mutersByPubkey = derived(throttled(1000, muteLists), lists => {
|
||||
const $mutersByPubkey = new Map<string, Set<string>>()
|
||||
|
||||
for (const list of lists) {
|
||||
for (const pubkey of getPubkeyTagValues(getListTags(list))) {
|
||||
addToMapKey($mutersByPubkey, pubkey, list.event.pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
return $mutersByPubkey
|
||||
})
|
||||
|
||||
export const getMutersByPubkey = getter(mutersByPubkey)
|
||||
|
||||
export const getFollowers = (pubkey: string) => Array.from(getFollowersByPubkey().get(pubkey) || [])
|
||||
|
||||
export const getMuters = (pubkey: string) => Array.from(getMutersByPubkey().get(pubkey) || [])
|
||||
|
||||
export const getFollowsWhoFollow = (pubkey: string, target: string) =>
|
||||
getFollows(pubkey).filter(other => getFollows(other).includes(target))
|
||||
|
||||
export const getFollowsWhoMute = (pubkey: string, target: string) =>
|
||||
getFollows(pubkey).filter(other => getMutes(other).includes(target))
|
||||
|
||||
export const wotGraph = writable(new Map<string, number>())
|
||||
|
||||
export const getWotGraph = getter(wotGraph)
|
||||
|
||||
export const maxWot = derived(wotGraph, $g => max(Array.from($g.values())))
|
||||
|
||||
export const getMaxWot = getter(maxWot)
|
||||
|
||||
const buildGraph = throttle(1000, () => {
|
||||
const $pubkey = pubkey.get()
|
||||
const $graph = new Map<string, number>()
|
||||
const $follows = $pubkey ? getFollows($pubkey) : getFollowListsByPubkey().keys()
|
||||
|
||||
for (const follow of $follows) {
|
||||
for (const pubkey of getFollows(follow)) {
|
||||
$graph.set(pubkey, inc($graph.get(pubkey)))
|
||||
}
|
||||
|
||||
for (const pubkey of getMutes(follow)) {
|
||||
$graph.set(pubkey, dec($graph.get(pubkey)))
|
||||
}
|
||||
}
|
||||
|
||||
wotGraph.set($graph)
|
||||
})
|
||||
|
||||
pubkey.subscribe(buildGraph)
|
||||
followLists.subscribe(buildGraph)
|
||||
muteLists.subscribe(buildGraph)
|
||||
|
||||
export const getWotScore = (pubkey: string, target: string) => {
|
||||
const follows = pubkey ? getFollowsWhoFollow(pubkey, target) : getFollowers(target)
|
||||
const mutes = pubkey ? getFollowsWhoMute(pubkey, target) : getMuters(target)
|
||||
|
||||
return follows.length - mutes.length
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import {writable, Subscriber} from "svelte/store"
|
||||
import {Zapper, TrustedEvent, Zap, getTagValues, zapFromEvent} from "@welshman/util"
|
||||
import {
|
||||
removeUndefined,
|
||||
fetchJson,
|
||||
bech32ToHex,
|
||||
hexToBech32,
|
||||
tryCatch,
|
||||
batcher,
|
||||
postJson,
|
||||
} from "@welshman/lib"
|
||||
import {
|
||||
getter,
|
||||
deriveItems,
|
||||
deriveDeduplicated,
|
||||
makeForceLoadItem,
|
||||
makeLoadItem,
|
||||
makeDeriveItem,
|
||||
} from "@welshman/store"
|
||||
import {deriveProfile, loadProfile} from "./profiles.js"
|
||||
import {appContext} from "./context.js"
|
||||
|
||||
export const zappersByLnurl = writable(new Map<string, Zapper>())
|
||||
|
||||
export const zappers = deriveItems(zappersByLnurl)
|
||||
|
||||
export const getZappersByLnurl = getter(zappersByLnurl)
|
||||
|
||||
export const getZapper = (lnurl: string) => getZappersByLnurl().get(lnurl)
|
||||
|
||||
export const zapperSubscribers: Subscriber<Zapper>[] = []
|
||||
|
||||
export const notifyZapper = (zapper: Zapper) => zapperSubscribers.forEach(sub => sub(zapper))
|
||||
|
||||
export const onZapper = (sub: (zapper: Zapper) => void) => {
|
||||
zapperSubscribers.push(sub)
|
||||
|
||||
return () => {
|
||||
const i = zapperSubscribers.findIndex(s => s === sub)
|
||||
|
||||
if (i !== -1) zapperSubscribers.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
export const fetchZapper = batcher(800, async (lnurls: string[]) => {
|
||||
const base = appContext.dufflepudUrl
|
||||
const result = new Map<string, Zapper>()
|
||||
const valid = lnurls.filter(lnurl => lnurl.startsWith("lnurl1"))
|
||||
|
||||
const addZapper = (lnurl: string, info: any) => {
|
||||
if (info) {
|
||||
try {
|
||||
result.set(lnurl, {...info, lnurl})
|
||||
} catch (e) {
|
||||
// pass
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use dufflepud if we it's set up to protect user privacy, otherwise fetch directly
|
||||
if (base) {
|
||||
const hexUrls = valid.map(bech32ToHex)
|
||||
const res = await tryCatch(() => postJson(`${base}/zapper/info`, {lnurls: hexUrls}))
|
||||
|
||||
for (const {lnurl, info} of res?.data || []) {
|
||||
addZapper(hexToBech32("lnurl", lnurl), info)
|
||||
}
|
||||
} else {
|
||||
await Promise.all(
|
||||
valid.map(async lnurl => {
|
||||
addZapper(lnurl, await tryCatch(() => fetchJson(bech32ToHex(lnurl))))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
if (result.size > 0) {
|
||||
zappersByLnurl.update($zappersByLnurl => {
|
||||
for (const [lnurl, zapper] of result) {
|
||||
$zappersByLnurl.set(lnurl, zapper)
|
||||
}
|
||||
|
||||
return $zappersByLnurl
|
||||
})
|
||||
|
||||
for (const zapper of result.values()) {
|
||||
notifyZapper(zapper)
|
||||
}
|
||||
}
|
||||
|
||||
return lnurls.map(lnurl => result.get(lnurl))
|
||||
})
|
||||
|
||||
export const forceLoadZapper = makeForceLoadItem(fetchZapper, getZapper)
|
||||
|
||||
export const loadZapper = makeLoadItem(fetchZapper, getZapper)
|
||||
|
||||
export const deriveZapper = makeDeriveItem(zappersByLnurl, loadZapper)
|
||||
|
||||
export const loadZapperForPubkey = async (pubkey: string, relays: string[] = []) => {
|
||||
const $profile = await loadProfile(pubkey, relays)
|
||||
|
||||
return $profile?.lnurl ? loadZapper($profile.lnurl) : undefined
|
||||
}
|
||||
|
||||
export const deriveZapperForPubkey = (pubkey: string, relays: string[] = []) => {
|
||||
loadZapperForPubkey(pubkey, relays)
|
||||
|
||||
return deriveDeduplicated(
|
||||
[zappersByLnurl, deriveProfile(pubkey, relays)],
|
||||
([$zappersByLnurl, $profile]) => {
|
||||
return $profile?.lnurl ? $zappersByLnurl.get($profile.lnurl) : undefined
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export const getLnUrlsForEvent = async (event: TrustedEvent) => {
|
||||
const pubkeys = getTagValues("zap", event.tags)
|
||||
|
||||
if (pubkeys.length > 0) {
|
||||
const profiles = await Promise.all(pubkeys.map(pubkey => loadProfile(pubkey)))
|
||||
const lnurls = removeUndefined(profiles.map(profile => profile?.lnurl))
|
||||
|
||||
if (lnurls.length > 0) {
|
||||
return lnurls
|
||||
}
|
||||
}
|
||||
|
||||
const profile = await loadProfile(event.pubkey)
|
||||
|
||||
return removeUndefined([profile?.lnurl])
|
||||
}
|
||||
|
||||
export const getZapperForZap = async (zap: TrustedEvent, parent: TrustedEvent) => {
|
||||
const lnurls = await getLnUrlsForEvent(parent)
|
||||
|
||||
return lnurls.length > 0 ? loadZapper(lnurls[0]) : undefined
|
||||
}
|
||||
|
||||
export const getValidZap = async (zap: TrustedEvent, parent: TrustedEvent) => {
|
||||
const zapper = await getZapperForZap(zap, parent)
|
||||
|
||||
return zapper ? zapFromEvent(zap, zapper) : undefined
|
||||
}
|
||||
|
||||
export const getValidZaps = async (zaps: TrustedEvent[], parent: TrustedEvent) =>
|
||||
removeUndefined(await Promise.all(zaps.map(zap => getValidZap(zap, parent))))
|
||||
|
||||
export const deriveValidZaps = (zaps: TrustedEvent[], parent: TrustedEvent) => {
|
||||
const store = writable<Zap[]>([])
|
||||
|
||||
getValidZaps(zaps, parent).then(validZaps => {
|
||||
store.set(validZaps)
|
||||
})
|
||||
|
||||
return store
|
||||
}
|
||||
Reference in New Issue
Block a user