Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e2a6ef21cd | |||
| 1bd62d3024 | |||
| bfd91f2d39 | |||
| 5e142e4db4 | |||
| 99f5233e05 | |||
| 925f540640 | |||
| 0a08057786 | |||
| fe5c11b00f | |||
| dfeb7a747b | |||
| eb451d795b | |||
| 393c95e107 | |||
| 772895e3ab | |||
| fafa3b172e | |||
| 72ab746254 | |||
| aae201414d | |||
| f5124a6c4e | |||
| 28219eb64f | |||
| bc728c680e | |||
| abb9f20747 | |||
| 163d2dc355 | |||
| 9094d30b89 | |||
| f8130da2bb | |||
| 2e12010e26 | |||
| 87d8a0832d | |||
| 34065a18cf | |||
| 96b0116c9b | |||
| ea9cc0bf26 | |||
| 28339976b9 | |||
| e0e9ad5834 |
@@ -2,4 +2,6 @@ node_modules
|
|||||||
docs
|
docs
|
||||||
docs/reference
|
docs/reference
|
||||||
docs/.vitepress/cache
|
docs/.vitepress/cache
|
||||||
|
dist
|
||||||
build
|
build
|
||||||
|
.git
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ results
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.vscode
|
.vscode
|
||||||
docs/**/*.html
|
docs/**/*.html
|
||||||
|
.local
|
||||||
|
|||||||
@@ -24,16 +24,15 @@ export default defineConfig({
|
|||||||
text: "@welshman/app",
|
text: "@welshman/app",
|
||||||
link: "/app/",
|
link: "/app/",
|
||||||
items: [
|
items: [
|
||||||
{text: "Session Management", link: "/app/session"},
|
{text: "The App", link: "/app/app"},
|
||||||
{text: "Relay Selection", link: "/app/relay-selection"},
|
{text: "User & Sessions", link: "/app/user"},
|
||||||
{text: "Making Requests", link: "/app/making-requests"},
|
{text: "Plugin Architecture", link: "/app/plugins"},
|
||||||
{text: "Publishing Events", link: "/app/publishing-events"},
|
{text: "Data Plugins", link: "/app/data"},
|
||||||
{text: "Tag utilities", link: "/app/tags"},
|
{text: "Publishing Events", link: "/app/publishing"},
|
||||||
|
{text: "Making Requests", link: "/app/requests"},
|
||||||
|
{text: "Routing & Tags", link: "/app/routing"},
|
||||||
{text: "Web of Trust", link: "/app/wot"},
|
{text: "Web of Trust", link: "/app/wot"},
|
||||||
{text: "Storage", link: "/app/storage"},
|
{text: "Feeds & Search", link: "/app/feeds-and-search"},
|
||||||
{text: "Context", link: "/app/context"},
|
|
||||||
{text: "Commands", link: "/app/commands"},
|
|
||||||
{text: "User", link: "/app/user"},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+173
@@ -0,0 +1,173 @@
|
|||||||
|
# The App
|
||||||
|
|
||||||
|
An `App` is an application instance. It owns every piece of per-identity state and is the entry point to all features. You will usually create one with `createApp` and access everything else through `app.use(...)`.
|
||||||
|
|
||||||
|
## Creating an app
|
||||||
|
|
||||||
|
### `createApp(options?)`
|
||||||
|
|
||||||
|
The batteries-included factory. It returns an `App` wired with the [default policies](#policies) (event ingestion, relay-stats collection, gift-wrap unwrapping, and NIP-42 auth) unless you pass your own `policies`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {createApp} from "@welshman/app"
|
||||||
|
|
||||||
|
const app = createApp({
|
||||||
|
user, // optional signed-in User
|
||||||
|
config: {
|
||||||
|
dufflepudUrl: "https://dufflepud.example",
|
||||||
|
getDefaultRelays: () => ["wss://relay.example"],
|
||||||
|
getIndexerRelays: () => ["wss://purplepag.es"],
|
||||||
|
getSearchRelays: () => ["wss://relay.nostr.band"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### `new App(options?)`
|
||||||
|
|
||||||
|
Use the constructor directly when you want a bare app with **no** side effects (for example in tests, or when you install policies yourself).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {App} from "@welshman/app"
|
||||||
|
|
||||||
|
const app = new App() // no policies installed
|
||||||
|
```
|
||||||
|
|
||||||
|
## `AppOptions`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type AppOptions = {
|
||||||
|
user?: User // the signed-in identity (at most one)
|
||||||
|
config?: AppConfig
|
||||||
|
getAdapter?: AdapterFactory // net-layer adapter factory
|
||||||
|
policies?: AppPolicy[] // side effects to install at construction
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## `AppConfig`
|
||||||
|
|
||||||
|
App-level configuration. All fields are optional; the three relay getters return `string[]` and feed the [Router](./routing).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type AppConfig = {
|
||||||
|
dufflepudUrl?: string // optional dufflepud service (batches NIP-05 / zapper lookups)
|
||||||
|
getDefaultRelays?: () => string[]
|
||||||
|
getIndexerRelays?: () => string[] // relays used to discover relay lists / profiles
|
||||||
|
getSearchRelays?: () => string[] // NIP-50 search relays
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## `IApp`
|
||||||
|
|
||||||
|
Plugins and policies never depend on the concrete `App` class — they take the `IApp` contract:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface IApp {
|
||||||
|
user?: User
|
||||||
|
config: AppConfig
|
||||||
|
use: <T>(Ctor: new (app: IApp) => T) => T
|
||||||
|
netContext: NetContext // {pool, repository, getAdapter} for the net layer
|
||||||
|
pool: Pool // connection pool
|
||||||
|
tracker: Tracker // tracks which relays have seen each event
|
||||||
|
repository: Repository // the local event store / single source of truth
|
||||||
|
wrapManager: WrapManager // NIP-59 gift-wrap bookkeeping
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Every primitive (`pool`, `tracker`, `repository`, `wrapManager`) is constructed fresh per instance, so data never bleeds across identities or sessions.
|
||||||
|
|
||||||
|
## Resolving features: `use`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
use: <T>(Ctor: new (app: IApp) => T) => T
|
||||||
|
```
|
||||||
|
|
||||||
|
`use` is a per-app singleton resolver. The first time you pass a plugin class, the app constructs `new Ctor(this)` and caches it; subsequent calls return the same instance.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const profiles = app.use(Profiles)
|
||||||
|
const sameInstance = app.use(Profiles) // identical reference
|
||||||
|
```
|
||||||
|
|
||||||
|
This is dependency resolution by demand. Plugins reach their own dependencies the same way (`this.app.use(Network)`, `this.app.use(Router)`), which means dependency cycles resolve lazily and there is no constructor wiring to maintain.
|
||||||
|
|
||||||
|
## Teardown: `cleanup`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
app.cleanup()
|
||||||
|
```
|
||||||
|
|
||||||
|
`cleanup()` runs every policy's unsubscribe function, then clears the `pool`, `tracker`, `repository`, and `wrapManager`. Call it when you discard an app (e.g. switching identities) to release connections and free memory.
|
||||||
|
|
||||||
|
## Policies
|
||||||
|
|
||||||
|
A **policy** is the unit of side effects. It runs once at construction and returns an `Unsubscriber` that `cleanup()` will later call. Keeping side effects in policies leaves the data plugins pure and centralizes teardown.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type AppPolicy = (app: IApp) => Unsubscriber
|
||||||
|
```
|
||||||
|
|
||||||
|
### Default policies
|
||||||
|
|
||||||
|
`createApp` installs `defaultAppPolicies`:
|
||||||
|
|
||||||
|
| Policy | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `appPolicyIngest` | Subscribes to the pool; verifies inbound relay events (skipping DVM/ephemeral kinds) and writes them to the `repository` and `tracker`. This is how every repository-backed store gets populated. |
|
||||||
|
| `appPolicyRelayStats` | Pipes socket activity into the [`RelayStats`](./routing#relay-quality) store. |
|
||||||
|
| `appPolicyWraps` | Enqueues existing and newly-arriving gift-wrap events for unwrapping. |
|
||||||
|
| `appPolicyAuthUnlessBlocked` | Answers NIP-42 AUTH challenges, except for relays in the user's blocked-relay list. |
|
||||||
|
|
||||||
|
### Auth policy builders
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
makeAppPolicyAuth(shouldAuth: (socket: Socket, app: IApp) => boolean): AppPolicy
|
||||||
|
|
||||||
|
appPolicyAuthNever // never answer AUTH
|
||||||
|
appPolicyAuthAlways // always answer AUTH
|
||||||
|
appPolicyAuthUnlessBlocked // answer unless the relay is blocked by the user
|
||||||
|
```
|
||||||
|
|
||||||
|
Auth policies are no-ops when there is no signed-in user.
|
||||||
|
|
||||||
|
### Customizing policies
|
||||||
|
|
||||||
|
Pass your own `policies` array to opt out of, or extend, the defaults:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {App, defaultAppPolicies, makeAppPolicyLogger} from "@welshman/app"
|
||||||
|
|
||||||
|
const app = new App({
|
||||||
|
user,
|
||||||
|
policies: [
|
||||||
|
...defaultAppPolicies,
|
||||||
|
makeAppPolicyLogger(msg => console.log(msg)), // see Logging
|
||||||
|
],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
`@welshman/app` can make a user's signer observable. `User.fromSigner`/`User.fromSession` wrap the underlying signer in a `LoggingSigner`, which emits a structured `LogMessage` for every signer operation (pending → success/failure).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type LogMessage =
|
||||||
|
| {type: "signer"; id: string; method: string; status: "pending" | "success" | "failure"; error?: unknown; at: number}
|
||||||
|
| {type: string; at: number; [key: string]: unknown}
|
||||||
|
```
|
||||||
|
|
||||||
|
Forward those messages by installing `makeAppPolicyLogger`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {makeAppPolicyLogger} from "@welshman/app"
|
||||||
|
|
||||||
|
const app = new App({
|
||||||
|
user,
|
||||||
|
policies: [...defaultAppPolicies, makeAppPolicyLogger(msg => {
|
||||||
|
if (msg.type === "signer" && msg.status === "failure") {
|
||||||
|
console.error("signing failed", msg.method, msg.error)
|
||||||
|
}
|
||||||
|
})],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
The logger policy is a no-op unless the user's signer is a `LoggingSigner` (which it is when the user was created via `User.fromSigner`/`User.fromSession`).
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
# Commands
|
|
||||||
|
|
||||||
Commands are functions which pull from app state to publish events on behalf of the user. Most are async and return a thunk
|
|
||||||
|
|
||||||
## Relay Management (NIP 65)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
removeRelay(url: string, mode: RelayMode): Promise<Thunk>
|
|
||||||
addRelay(url: string, mode: RelayMode): Promise<Thunk>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Messaging Relay Management (NIP 17)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
removeMessagingRelay(url: string): Promise<Thunk>
|
|
||||||
addMessagingRelay(url: string): Promise<Thunk>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Profile Management (NIP 01)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
setProfile(profile: Profile): Thunk
|
|
||||||
```
|
|
||||||
|
|
||||||
## Follow Management (NIP 02)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
unfollow(value: string): Promise<Thunk>
|
|
||||||
follow(tag: string[]): Promise<Thunk>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Mute Management
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
unmute(value: string): Promise<Thunk>
|
|
||||||
mutePublicly(tag: string[]): Promise<Thunk>
|
|
||||||
mutePrivately(tag: string[]): Promise<Thunk>
|
|
||||||
setMutes(options: {
|
|
||||||
publicTags?: string[][]
|
|
||||||
privateTags?: string[][]
|
|
||||||
}): Promise<Thunk>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Pin Management
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
unpin(value: string): Promise<Thunk>
|
|
||||||
pin(tag: string[]): Promise<Thunk>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Wrapped Messages (NIP 59)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
type SendWrappedOptions = Omit<ThunkOptions, "event" | "relays"> & {
|
|
||||||
event: EventTemplate
|
|
||||||
recipients: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
sendWrapped(options: SendWrappedOptions): Promise<MergedThunk>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Relay Management (NIP 86)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
manageRelay(url: string, request: ManagementRequest): Promise<Response>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Room Management (NIP 29)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
createRoom(url: string, room: RoomMeta): Thunk
|
|
||||||
deleteRoom(url: string, room: RoomMeta): Thunk
|
|
||||||
editRoom(url: string, room: RoomMeta): Thunk
|
|
||||||
joinRoom(url: string, room: RoomMeta): Thunk
|
|
||||||
leaveRoom(url: string, room: RoomMeta): Thunk
|
|
||||||
```
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# Application Context
|
|
||||||
|
|
||||||
The `@welshman/app` package uses a global context system to configure a few core behaviors.
|
|
||||||
|
|
||||||
## Dufflepud
|
|
||||||
|
|
||||||
[Dufflepud](https://github.com/coracle-social/dufflepud) is a utility server that can retrieve NIP 05 profiles, zappers, relay metadata, link previews, etc. It's not necessary for using welshman, but can improve things by bypassing CORS.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {appContext} from '@welshman/app'
|
|
||||||
|
|
||||||
appContext.dufflepudUrl = 'https://my-dufflepud-instance.com'
|
|
||||||
```
|
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# Data Plugins
|
||||||
|
|
||||||
|
These plugins expose reactive collections of nostr data. They all follow the [plugin patterns](./plugins): read synchronously with `get(key)`, reactively with `one(key)` (which lazily loads), and use the convenience accessors that return a [`Projection`](./plugins#projection-t). Resolve each with `app.use(...)`.
|
||||||
|
|
||||||
|
Most event-backed plugins load via the **outbox model**: they first resolve the author's NIP-65 write relays (from [`RelayLists`](#relay-lists)), then query those relays. This is why nearly every data plugin depends on relay lists.
|
||||||
|
|
||||||
|
## Profiles
|
||||||
|
|
||||||
|
Kind-0 profiles keyed by pubkey.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const profiles = app.use(Profiles)
|
||||||
|
|
||||||
|
profiles.one(pubkey) // Readable<Maybe<Profile>> — lazily loads
|
||||||
|
profiles.get(pubkey) // Maybe<Profile> — sync snapshot, no load
|
||||||
|
await profiles.load(pubkey) // explicit load (cached)
|
||||||
|
profiles.display(pubkey) // Projection<string> — display name (falls back to npub)
|
||||||
|
await profiles.publish(profile) // build & publish a profile event (kind 0)
|
||||||
|
```
|
||||||
|
|
||||||
|
`profiles.display(pubkey).$` is the right thing to bind in a component for a user's name.
|
||||||
|
|
||||||
|
## Follows
|
||||||
|
|
||||||
|
Kind-3 follow lists keyed by pubkey.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const follows = app.use(FollowLists)
|
||||||
|
|
||||||
|
follows.one(pubkey) // Readable<Maybe<List>>
|
||||||
|
await follows.follow(["p", otherPubkey]) // add a tag and publish to outbox
|
||||||
|
await follows.unfollow(otherPubkey) // remove and publish
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mutes
|
||||||
|
|
||||||
|
Kind-10000 mute lists keyed by pubkey. Private entries are NIP-44 encrypted, so decoding is asynchronous.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const mutes = app.use(MuteLists)
|
||||||
|
|
||||||
|
mutes.one(pubkey) // Readable<Maybe<PublishedList>>
|
||||||
|
await mutes.mutePublicly(["p", pubkey]) // public mute
|
||||||
|
await mutes.mutePrivately(["p", pubkey]) // encrypted mute
|
||||||
|
await mutes.unmute(pubkey)
|
||||||
|
await mutes.setMutes({publicTags, privateTags})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pins
|
||||||
|
|
||||||
|
Kind-10001 pin lists keyed by pubkey.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const pins = app.use(PinLists)
|
||||||
|
|
||||||
|
pins.one(pubkey)
|
||||||
|
await pins.pin(["e", eventId])
|
||||||
|
await pins.unpin(eventId)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Relay lists
|
||||||
|
|
||||||
|
The NIP-65 relay list (kind 10002) is the routing substrate the whole outbox model depends on.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const relayLists = app.use(RelayLists)
|
||||||
|
|
||||||
|
relayLists.urls(pubkey) // Projection<string[]> — all relays
|
||||||
|
relayLists.readUrls(pubkey) // Projection<string[]> — read relays
|
||||||
|
relayLists.writeUrls(pubkey) // Projection<string[]> — write relays
|
||||||
|
|
||||||
|
// Mutations for the current user
|
||||||
|
await relayLists.addRelay(url, RelayMode.Write)
|
||||||
|
await relayLists.removeRelay(url, RelayMode.Read) // also notifies the removed relay
|
||||||
|
await relayLists.setReadRelays(urls)
|
||||||
|
await relayLists.setWriteRelays(urls)
|
||||||
|
await relayLists.setRelays(tags)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Specialized relay lists
|
||||||
|
|
||||||
|
Each of these is a separate kind with the same shape (`urls(pubkey)`, `addRelay`, `removeRelay`, `setRelays`):
|
||||||
|
|
||||||
|
| Plugin | Kind | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `BlockedRelayLists` | 10006 | Relays the user refuses to connect to (also gates [auth](./apppolicies) and [relay quality](./routing#relay-quality)) |
|
||||||
|
| `MessagingRelayLists` | 10050 | NIP-17 DM inbox relays (used by [gift-wrapped publishing](./publishing#gift-wrapped-messages)) |
|
||||||
|
| `SearchRelayLists` | 10007 | NIP-50 search relays |
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
app.use(BlockedRelayLists).urls(pubkey) // Projection<string[]>
|
||||||
|
app.use(MessagingRelayLists).urls(pubkey)
|
||||||
|
app.use(SearchRelayLists).urls(pubkey)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Relays (NIP-11)
|
||||||
|
|
||||||
|
Relay metadata fetched over **HTTP**, keyed by relay URL.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const relays = app.use(Relays)
|
||||||
|
|
||||||
|
relays.one(url) // Readable<Maybe<RelayProfile>> — lazily fetches NIP-11
|
||||||
|
relays.display(url) // Projection<string>
|
||||||
|
await relays.hasNip(url, 50) // boolean — does the relay support a NIP?
|
||||||
|
await relays.hasNegentropy(url) // boolean — NIP-77 / negentropy support
|
||||||
|
```
|
||||||
|
|
||||||
|
## Relay management (NIP-86)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await app.use(RelayManagement).post(url, managementRequest)
|
||||||
|
```
|
||||||
|
|
||||||
|
Builds a NIP-98 HTTP-auth event signed by the current user and sends a NIP-86 management request to the relay.
|
||||||
|
|
||||||
|
## Handles (NIP-05)
|
||||||
|
|
||||||
|
NIP-05 identifiers verified over HTTP, keyed by `name@domain`. Lookups are batched (and use `dufflepudUrl` if configured).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const handles = app.use(Handles)
|
||||||
|
|
||||||
|
handles.forPubkey(pubkey) // Projection<Maybe<Handle>> — resolves via the profile's nip05
|
||||||
|
handles.display(nip05) // Projection<string>
|
||||||
|
await handles.loadForPubkey(pubkey)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Zappers (Lightning)
|
||||||
|
|
||||||
|
LNURL zapper info keyed by lnurl, fetched over HTTP.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const zappers = app.use(Zappers)
|
||||||
|
|
||||||
|
zappers.forPubkey(pubkey) // Projection<Maybe<Zapper>>
|
||||||
|
await zappers.validateZapReceipt(zapReceipt, parentEvent) // Promise<Maybe<Zap>>
|
||||||
|
zappers.validZapReceipts(zapReceipts, parentEvent) // Projection<Zap[]>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Blossom servers
|
||||||
|
|
||||||
|
Blossom media-server lists (kind 10063) keyed by pubkey.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const list = await app.use(BlossomServerLists).load(pubkey)
|
||||||
|
app.use(BlossomServerLists).one(pubkey) // Readable<Maybe<List>>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Topics
|
||||||
|
|
||||||
|
Hashtags with usage counts, derived from the repository's tag index.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const topics = app.use(Topics)
|
||||||
|
|
||||||
|
topics.all // Readable<Topic[]> ({name, count})
|
||||||
|
topics.byName // Readable<Map<string, Topic>>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rooms (NIP-29)
|
||||||
|
|
||||||
|
Relay-based group management. Each method builds the relevant room event and publishes it to a single relay as the current user.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const rooms = app.use(Rooms)
|
||||||
|
|
||||||
|
rooms.create(relayUrl, roomMeta)
|
||||||
|
rooms.edit(relayUrl, roomMeta)
|
||||||
|
rooms.delete(relayUrl, roomMeta)
|
||||||
|
rooms.join(relayUrl, roomMeta)
|
||||||
|
rooms.leave(relayUrl, roomMeta)
|
||||||
|
rooms.addMember(relayUrl, roomMeta, pubkey)
|
||||||
|
rooms.removeMember(relayUrl, roomMeta, pubkey)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Plaintext
|
||||||
|
|
||||||
|
A cache of decrypted content, keyed by event id. Only decrypts events authored by the current user (e.g. your own private list entries or DMs).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const text = await app.use(Plaintext).ensure(event) // decrypts & caches
|
||||||
|
const cached = app.use(Plaintext).get(event.id) // sync read of the cache
|
||||||
|
```
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Feeds & Search
|
||||||
|
|
||||||
|
## Feeds
|
||||||
|
|
||||||
|
`app.use(Feeds)` builds `@welshman/feeds` `FeedController`s wired to this app — its router, web-of-trust graph, signer, and net context are all injected for you, so the controller can resolve scopes (`Self`, `Follows`, `Network`, `Followers`) and WoT ranges to real pubkeys and fetch through the app's repository and pool.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {makeIntersectionFeed, makeScopeFeed, makeKindFeed, Scope} from "@welshman/feeds"
|
||||||
|
|
||||||
|
const controller = app.use(Feeds).makeFeedController({
|
||||||
|
feed: makeIntersectionFeed(
|
||||||
|
makeScopeFeed(Scope.Follows),
|
||||||
|
makeKindFeed(1),
|
||||||
|
),
|
||||||
|
onEvent: event => {
|
||||||
|
// render the event
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await controller.load(50) // load a page of 50
|
||||||
|
```
|
||||||
|
|
||||||
|
### `MakeFeedControllerOptions`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type MakeFeedControllerOptions = Partial<Omit<FeedControllerOptions, "feed">> & {feed: Feed}
|
||||||
|
```
|
||||||
|
|
||||||
|
You provide the `feed` (and typically `onEvent`); the app injects `router`, `signer`, `context`, and the scope/WoT-range resolvers. The scope resolvers map `@welshman/feeds` `Scope` values to pubkeys via [`Wot`](./wot):
|
||||||
|
|
||||||
|
- `Scope.Self` → the current user
|
||||||
|
- `Scope.Follows` → `Wot.follows(pubkey)`
|
||||||
|
- `Scope.Network` → `Wot.network(pubkey)`
|
||||||
|
- `Scope.Followers` → `Wot.followers(pubkey)`
|
||||||
|
|
||||||
|
WoT-range feeds resolve to the pubkeys whose trust score falls within a fraction of the maximum score in the graph.
|
||||||
|
|
||||||
|
## Search
|
||||||
|
|
||||||
|
`app.use(Searches)` provides fuzzy ([Fuse.js](https://fusejs.io)) search over profiles, topics, and relays. Profile search additionally triggers a NIP-50 network search and ranks results by web of trust.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {get} from "svelte/store"
|
||||||
|
|
||||||
|
const searches = app.use(Searches)
|
||||||
|
|
||||||
|
// Each of these is a Readable<Search<...>> that stays up to date
|
||||||
|
const profileSearch = get(searches.profileSearch)
|
||||||
|
const topicSearch = get(searches.topicSearch)
|
||||||
|
const relaySearch = get(searches.relaySearch)
|
||||||
|
|
||||||
|
// A Search exposes both option objects and their values
|
||||||
|
profileSearch.searchValues("alice") // string[] — pubkeys; also fires a NIP-50 network search
|
||||||
|
profileSearch.searchOptions("alice") // PublishedProfile[]
|
||||||
|
profileSearch.getOption(pubkey) // PublishedProfile | undefined
|
||||||
|
```
|
||||||
|
|
||||||
|
Profile results are ranked by blending the Fuse score with the WoT score, so well-trusted matches surface first. An empty search term returns all options.
|
||||||
|
|
||||||
|
### Building your own search
|
||||||
|
|
||||||
|
The generic `createSearch` helper underlies the built-in searches and is exported for custom indexes:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {createSearch} from "@welshman/app"
|
||||||
|
|
||||||
|
const search = createSearch(items, {
|
||||||
|
getValue: item => item.id, // map an item to its identifier
|
||||||
|
fuseOptions: {keys: ["name", "about"], threshold: 0.3},
|
||||||
|
onSearch: term => {/* e.g. trigger a network fetch */},
|
||||||
|
sortFn: results => results, // optional custom result ordering
|
||||||
|
})
|
||||||
|
|
||||||
|
search.searchOptions("query") // T[]
|
||||||
|
search.searchValues("query") // V[]
|
||||||
|
search.getOption(value) // T | undefined
|
||||||
|
```
|
||||||
+71
-60
@@ -2,82 +2,93 @@
|
|||||||
|
|
||||||
[](https://npmjs.com/package/@welshman/app)
|
[](https://npmjs.com/package/@welshman/app)
|
||||||
|
|
||||||
A comprehensive framework for building nostr clients, powering production applications like [Coracle](https://coracle.social) and [Flotilla](https://flotilla.social). It provides a complete toolkit for managing events, subscriptions, user data, and relay connections.
|
An instance-based, composable client for building nostr applications. It powers production clients like [Coracle](https://coracle.social) and [Flotilla](https://flotilla.social), and ties together the rest of the welshman packages (`util`, `net`, `store`, `router`, `signer`, `feeds`) into a single, cohesive app layer.
|
||||||
|
|
||||||
## What's Included
|
## The core idea: an app is an `App` instance
|
||||||
|
|
||||||
- **Repository** - Event storage and query capabilities
|
Everything in `@welshman/app` hangs off a single `App` instance. An `App` owns the per-identity primitives — an event `Repository`, a connection `Pool`, a `Tracker`, and a `WrapManager` — plus a `config` and (optionally) a signed-in `User`. Because all state lives on the instance, two apps never share data: you can run multiple identities side-by-side, and tearing one down with `cleanup()` releases everything it allocated.
|
||||||
- **Router** - Intelligent relay selection for optimal network access
|
|
||||||
- **Feed Controller** - Manages feed loading
|
|
||||||
- **Session Management** - User identity and key management
|
|
||||||
- **Event Actions** - High-level operations like reacting, replying, etc.
|
|
||||||
- **Profile Management** - User profile handling and metadata
|
|
||||||
- **Relay Directory** - Discovery and management of relays
|
|
||||||
- **Web of Trust** - Utilities for building webs of trust
|
|
||||||
|
|
||||||
## Quick Example
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import {getNip07} from '@welshman/signer'
|
import {createApp} from "@welshman/app"
|
||||||
import {load, request, RequestEvent, defaultSocketPolicies, makeSocketPolicyAuth, Socket} from '@welshman/net'
|
|
||||||
import {StampedEvent, TrustedEvent, makeEvent, NOTE} from '@welshman/util'
|
|
||||||
import {pubkey, signer, publishThunk} from '@welshman/app'
|
|
||||||
|
|
||||||
// Log in via NIP 07
|
// A batteries-included app (event ingestion, relay stats, gift-wrap
|
||||||
addSession({method: 'nip07', pubkey: await getNip07().getPubkey()})
|
// unwrapping, and NIP-42 auth are all wired up by default policies)
|
||||||
|
const app = createApp()
|
||||||
|
```
|
||||||
|
|
||||||
// Enable automatic authentication to relays
|
Features are exposed as **plugins** — lazily-constructed singletons resolved through `app.use(...)`:
|
||||||
defaultSocketPolicies.push(
|
|
||||||
makeSocketPolicyAuth({
|
|
||||||
sign: (event: StampedEvent) => signer.get()?.sign(event),
|
|
||||||
shouldAuth: (socket: Socket) => true,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
// This will fetch the user's profile automatically, and return a store that updates
|
```typescript
|
||||||
// automatically. Several different stores exist that are ready to go, including handles,
|
import {createApp, Profiles, RelayLists, Thunks} from "@welshman/app"
|
||||||
// zappers, relayLists, relays, follows, mutes.
|
|
||||||
const profile = deriveProfile(pubkey.get())
|
|
||||||
|
|
||||||
// Publish is done using thunks, which optimistically publish to the local database, deferring
|
const app = createApp()
|
||||||
// signing and publishing for instant user feedback. Progress is reported as relays accept/reject the event
|
|
||||||
// Events are automatically signed using the current session
|
// Each plugin is constructed once per app and memoized
|
||||||
const thunk = publishThunk({
|
const profiles = app.use(Profiles)
|
||||||
relays: Router.get().FromUser().getUrls(),
|
const relayLists = app.use(RelayLists)
|
||||||
|
```
|
||||||
|
|
||||||
|
This replaces the previous global-singleton design (`pubkey`, `deriveProfile`, `publishThunk`, `Router.get()`). There are no module-level globals anymore — you create an app and reach everything through it.
|
||||||
|
|
||||||
|
## Architecture at a glance
|
||||||
|
|
||||||
|
| Layer | What it is | Where |
|
||||||
|
|---|---|---|
|
||||||
|
| **`App`** | The app instance; owns repository/pool/tracker/wrapManager and the `use()` registry | [App](./app) |
|
||||||
|
| **`User` & sessions** | The signed-in identity and serializable login descriptors | [User & Sessions](./user) |
|
||||||
|
| **Policies** | Side effects installed at construction (ingest, auth, stats, wraps) | [App](./apppolicies) |
|
||||||
|
| **Plugins** | Lazily-resolved feature modules built on a small set of base classes | [Plugin architecture](./plugins) |
|
||||||
|
| **Data plugins** | Reactive collections of profiles, lists, relays, handles, zappers… | [Data](./data) |
|
||||||
|
| **Publishing** | Optimistic publishing via thunks | [Publishing](./publishing) |
|
||||||
|
| **Requests** | Loading & negentropy sync | [Requests](./requests) |
|
||||||
|
| **Routing** | Outbox-model relay selection and tag builders | [Routing](./routing) |
|
||||||
|
| **Web of Trust** | Follow/mute graph scoring | [Web of Trust](./wot) |
|
||||||
|
| **Feeds & Search** | Feed controllers and fuzzy search | [Feeds & Search](./feeds-and-search) |
|
||||||
|
|
||||||
|
## Quick example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {createApp, User, toSession, nip07, Profiles, Thunks, Router} from "@welshman/app"
|
||||||
|
import {getNip07} from "@welshman/signer"
|
||||||
|
import {makeEvent, NOTE} from "@welshman/util"
|
||||||
|
import {addMinimalFallbacks} from "@welshman/router"
|
||||||
|
|
||||||
|
// 1. Log in. A session is a serializable {method, data} descriptor; User
|
||||||
|
// turns it back into a live, signing identity.
|
||||||
|
const pubkey = await getNip07().getPubkey()
|
||||||
|
const session = toSession(nip07, {})
|
||||||
|
const user = await User.fromSession(session)
|
||||||
|
|
||||||
|
// 2. Create the app around that user.
|
||||||
|
const app = createApp({user})
|
||||||
|
|
||||||
|
// 3. Read data reactively. Stores lazily fetch over the network using the
|
||||||
|
// outbox model and update as events arrive.
|
||||||
|
const profile = app.use(Profiles).one(pubkey) // Readable<Maybe<Profile>>
|
||||||
|
profile.subscribe($profile => console.log($profile?.name))
|
||||||
|
|
||||||
|
// 4. Publish optimistically. The event is written to the local repository
|
||||||
|
// immediately, signed lazily, and progress is reported per-relay.
|
||||||
|
const thunk = app.use(Thunks).publishToOutbox({
|
||||||
event: makeEvent(NOTE, {content: "hi"}),
|
event: makeEvent(NOTE, {content: "hi"}),
|
||||||
delay: 3000,
|
delay: 3000, // soft-undo window
|
||||||
})
|
})
|
||||||
|
|
||||||
// Thunks can be aborted until after `delay`, allowing for soft-undo
|
// Abort before `delay` elapses to undo
|
||||||
thunk.controller.abort()
|
// thunk.abort()
|
||||||
|
await thunk.waitForCompletion()
|
||||||
|
|
||||||
// Some commands are included
|
// 5. Tear it all down
|
||||||
const thunk = follow(['p', '97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322'])
|
app.cleanup()
|
||||||
|
|
||||||
// Load events as a promise
|
|
||||||
const events = await load({
|
|
||||||
relays: Router.get().ForUser().getUrls(),
|
|
||||||
filters: [{kinds: [NOTE],
|
|
||||||
}])
|
|
||||||
|
|
||||||
// Or use `request` for more fine-grained subscription control
|
|
||||||
const abortController = new AbortController()
|
|
||||||
|
|
||||||
request({
|
|
||||||
signal: abortController.signal,
|
|
||||||
relays: Router.get().ForUser().getUrls(),
|
|
||||||
filters: [{kinds: [NOTE],
|
|
||||||
onEvent: (event: TrustedEvent) => {
|
|
||||||
console.log(event)
|
|
||||||
},
|
|
||||||
}])
|
|
||||||
|
|
||||||
// Close the request
|
|
||||||
abortController.abort()
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install @welshman/app
|
npm install @welshman/app
|
||||||
|
# or
|
||||||
|
pnpm add @welshman/app
|
||||||
|
yarn add @welshman/app
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`@welshman/app` has peer dependencies on `svelte` (4 or 5) and the other welshman workspace packages (`@welshman/feeds`, `@welshman/lib`, `@welshman/net`, `@welshman/router`, `@welshman/signer`, `@welshman/store`, `@welshman/util`), plus `@pomade/core` for the optional Pomade signer.
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
# Making Requests
|
|
||||||
|
|
||||||
Welshman extends Nostr's base subscription model with intelligent caching, repository integration, and configurable behaviors.
|
|
||||||
|
|
||||||
## Key Concepts
|
|
||||||
|
|
||||||
- **Local Repository**: Events are automatically cached and tracked
|
|
||||||
- **Cache Intelligence**: Smart decisions about when to use cached data
|
|
||||||
- **Relay Integration**: Works with the router for optimal relay selection
|
|
||||||
- **Configurable Behavior**: Control caching and timeouts
|
|
||||||
|
|
||||||
## Request and Load
|
|
||||||
|
|
||||||
The base functionality for subscription management is implemented in `@welshman/net`. Please refer to [the documentation](/net) for that module for details.
|
|
||||||
|
|
||||||
## Indexed Collections and Loaders
|
|
||||||
|
|
||||||
Create indexed stores with automatic loading using repository derivations and loader utilities:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {deriveItemsByKey, deriveItems, makeDeriveItem, makeLoadItem, getter} from "@welshman/store"
|
|
||||||
|
|
||||||
// Create indexed map from repository
|
|
||||||
const itemsByKey = deriveItemsByKey({
|
|
||||||
repository,
|
|
||||||
filters: [{kinds: [SOME_KIND]}],
|
|
||||||
eventToItem: event => transformEvent(event),
|
|
||||||
getKey: item => item.id
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create array view
|
|
||||||
const items = deriveItems(itemsByKey)
|
|
||||||
|
|
||||||
// Create getter for accessing map
|
|
||||||
const getItemsByKey = getter(itemsByKey)
|
|
||||||
|
|
||||||
// Create loader
|
|
||||||
const loadItem = makeLoadItem(fetchItem, key => getItemsByKey().get(key))
|
|
||||||
|
|
||||||
// Create deriver with automatic loading
|
|
||||||
const deriveItem = makeDeriveItem(itemsByKey, loadItem)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Deriving Events
|
|
||||||
|
|
||||||
Query events from the repository using `deriveEventsById` and `deriveEvents`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {deriveEventsById, deriveEvents} from "@welshman/store"
|
|
||||||
|
|
||||||
const noteEventsById = deriveEventsById({repository, filters: [{kinds: [NOTE]}]})
|
|
||||||
export const notes = deriveEvents(noteEventsById)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Available Collections
|
|
||||||
|
|
||||||
Several common collections are built-in and ready for use:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Profiles
|
|
||||||
profiles → profilesByPubkey → deriveProfile → loadProfile
|
|
||||||
|
|
||||||
// Lists
|
|
||||||
followLists → followListsByPubkey → deriveFollowList → loadFollowList
|
|
||||||
muteLists → muteListsByPubkey → deriveMuteList → loadMuteList
|
|
||||||
pinLists → pinListsByPubkey → derivePinList → loadPinList
|
|
||||||
|
|
||||||
// Relays
|
|
||||||
relays → relaysByUrl → deriveRelay → loadRelay
|
|
||||||
relayLists → relayListsByPubkey → deriveRelayList → loadRelayList
|
|
||||||
messagingRelayLists → messagingRelayListsByPubkey → deriveMessagingRelayList → loadMessagingRelayList
|
|
||||||
|
|
||||||
// Identity
|
|
||||||
handles → handlesByNip05 → deriveHandle → loadHandle
|
|
||||||
zappers → zappersByLnurl → deriveZapper → loadZapper
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example - Loading and Displaying Profiles
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {get} from 'svelte/store'
|
|
||||||
import {displayProfile} from '@welshman/util'
|
|
||||||
import {deriveProfile, deriveProfileDisplay} from '@welshman/app'
|
|
||||||
|
|
||||||
// Subscribe to profile changes - this will automatically load the profile in the background
|
|
||||||
const profile = deriveProfile(pubkey)
|
|
||||||
|
|
||||||
// Display with fallback
|
|
||||||
const name = displayProfile(get(profile), 'unknown')
|
|
||||||
|
|
||||||
// Better: use built-in deriveProfileDisplay utility
|
|
||||||
const name = deriveProfileDisplay(pubkey)
|
|
||||||
```
|
|
||||||
|
|
||||||
### User-Specific Collections
|
|
||||||
|
|
||||||
Several modules provide user-specific derived stores that automatically load data for the currently signed-in user:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { userProfile, userFollowList, userMuteList, userPinList } from '@welshman/app'
|
|
||||||
|
|
||||||
userProfile.subscribe(profile => {
|
|
||||||
// Current user's profile data
|
|
||||||
})
|
|
||||||
|
|
||||||
userFollowList.subscribe(follows => {
|
|
||||||
// Current user's follow list
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Repository Integration
|
|
||||||
|
|
||||||
Events from subscriptions are automatically tracked to their source relay and saved to the repository, unless they are DVM-kind or ephemeral events (which are discarded). WRAP (kind 1059) events are handled separately and only processed when `shouldUnwrap` is set to `true`.
|
|
||||||
|
|
||||||
The repository serves as an intelligent cache layer, making subsequent queries for the same data faster.
|
|
||||||
|
|
||||||
## Feeds
|
|
||||||
|
|
||||||
A high-level feed loader utility is also provided, which combines application state with utilities from `@welshman/net` and `@welshman/feeds`.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {NOTE} from '@welshman/util'
|
|
||||||
import {makeKindFeed} from '@welshman/feeds'
|
|
||||||
import {createFeedController} from '@welshman/app'
|
|
||||||
|
|
||||||
const abortController = new AbortController()
|
|
||||||
|
|
||||||
let done = false
|
|
||||||
|
|
||||||
const ctrl = createFeedController({
|
|
||||||
feed: makeKindFeed(NOTE),
|
|
||||||
useWindowing: true,
|
|
||||||
signal: abortController.signal,
|
|
||||||
onEvent: e => {
|
|
||||||
console.log(e)
|
|
||||||
},
|
|
||||||
onExhausted: () => {
|
|
||||||
done = true
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Load some notes
|
|
||||||
ctrl.load(100)
|
|
||||||
|
|
||||||
// Cancel any pending requests
|
|
||||||
abortController.abort()
|
|
||||||
```
|
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# Plugin Architecture
|
||||||
|
|
||||||
|
Every feature in `@welshman/app` is a **plugin** — a class constructed with a single `IApp` argument and resolved lazily via `app.use(...)`. All the data-bearing plugins are built on a small set of base classes defined in `plugins/base.ts`. Understanding these three bases and the `Projection` type is enough to read (and extend) the entire library.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const profiles = app.use(Profiles) // new Profiles(app), memoized per app
|
||||||
|
```
|
||||||
|
|
||||||
|
## `Projection<T>`
|
||||||
|
|
||||||
|
Almost every accessor in the library returns a `Projection<T>` — a value you can read either synchronously or reactively.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type Projection<T> = {
|
||||||
|
get: () => T // synchronous "hot" snapshot
|
||||||
|
$: Readable<T> // a Svelte readable for subscriptions / $-syntax
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const display = app.use(Profiles).display(pubkey)
|
||||||
|
|
||||||
|
display.get() // string, right now
|
||||||
|
display.$ // Readable<string>, for `$display` in a component
|
||||||
|
```
|
||||||
|
|
||||||
|
Helpers:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Wrap a Readable into a Projection (default getter is hot-path aware)
|
||||||
|
projection<T>($: Readable<T>, get?): Projection<T>
|
||||||
|
|
||||||
|
// Derive one Projection from another, preserving both access modes
|
||||||
|
projectFrom<S, U>(src: Projection<S>, read: ($: S) => U): Projection<U>
|
||||||
|
```
|
||||||
|
|
||||||
|
The default `get` is `getter($)` from `@welshman/store`, which automatically switches between `svelte.get` and a live subscription based on how often it is called — so `.get()` is safe in hot code paths.
|
||||||
|
|
||||||
|
## The three base classes
|
||||||
|
|
||||||
|
| Base class | Source of truth | Loads from network? | Used for |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `MapPlugin<T>` | Its own `Map` | No | Local, non-event data (e.g. relay stats) |
|
||||||
|
| `LoadableMapPlugin<T>` | Its own `Map` | Yes (HTTP) | Data fetched over HTTP (relay NIP-11 info, NIP-05 handles, zappers) |
|
||||||
|
| `DerivedPlugin<T>` | The `repository` | Yes (events) | Anything derived from nostr events (profiles, lists, …) |
|
||||||
|
|
||||||
|
`DerivedPlugin` is the dominant pattern: it is a live view over the app's event repository, so cached events appear immediately and new ones stream in automatically.
|
||||||
|
|
||||||
|
### `MapPlugin<T>`
|
||||||
|
|
||||||
|
A reactive, keyed in-memory collection that owns its own `Map`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class MapPlugin<T> {
|
||||||
|
index: Projection<ItemsByKey<T>> // the whole Map
|
||||||
|
all: Projection<T[]> // values
|
||||||
|
one: (key?: string, ...args: any[]) => Readable<Maybe<T>>
|
||||||
|
|
||||||
|
get(key: string): Maybe<T> // sync read
|
||||||
|
project<U>(key: string, read: (item: Maybe<T>) => U): Projection<U>
|
||||||
|
set(key: string, value: T): void
|
||||||
|
delete(key: string): void
|
||||||
|
clear(): void
|
||||||
|
onItem(subscriber: (key: string, value: Maybe<T>) => void): Unsubscriber
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`set`/`delete`/`clear` fire `onItem` subscribers — handy for persisting the collection to storage.
|
||||||
|
|
||||||
|
### `LoadableMapPlugin<T>`
|
||||||
|
|
||||||
|
A `MapPlugin` that lazily fetches items. Subclasses implement `fetch`; the base adds caching and backoff.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
abstract class LoadableMapPlugin<T> extends MapPlugin<T> {
|
||||||
|
abstract fetch(key: string, ...args: any[]): Promise<unknown>
|
||||||
|
|
||||||
|
load(key: string, ...args: any[]): Promise<Maybe<T>> // cached + deduped + backoff
|
||||||
|
forceLoad(key: string, ...args: any[]): Promise<Maybe<T>> // bypass the cache
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Subscribing to `one(key)` triggers a lazy `load`. Caching, in-flight de-duplication, and exponential backoff come from `makeLoadItem` in `@welshman/store` (default staleness window: one hour).
|
||||||
|
|
||||||
|
### `DerivedPlugin<T>`
|
||||||
|
|
||||||
|
A keyed collection derived from repository events. There is no duplicated map — the repository is the single source of truth.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type DerivedPluginOptions<T> = {
|
||||||
|
filters: Filter[]
|
||||||
|
eventToItem: (event: TrustedEvent) => MaybeAsync<Maybe<T>>
|
||||||
|
getKey: (item: T) => string
|
||||||
|
loadOptions?: MakeLoadItemOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
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>>
|
||||||
|
get(key: string): Maybe<T>
|
||||||
|
project<U>(key: string, read: (item: Maybe<T>) => U): Projection<U>
|
||||||
|
|
||||||
|
abstract fetch(key: string, ...args: any[]): Promise<unknown>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Internally it builds `index` from `app.use(Stores).itemsByKey({filters, eventToItem, getKey})`, a live readable derived over the repository. `eventToItem` may be async — useful when a list has encrypted entries that must be decrypted first.
|
||||||
|
|
||||||
|
## Lifecycle of a `DerivedPlugin` read
|
||||||
|
|
||||||
|
1. **Read (cached):** `get(key)` (sync) or `one(key)` (reactive) returns whatever already matches in the repository — instantly.
|
||||||
|
2. **Lazy load:** subscribing to `one(key)` (or calling `load(key)`) triggers `fetch(key)`. Caching skips recently-loaded keys; in-flight calls for the same key collapse; failures back off exponentially.
|
||||||
|
3. **Decode:** inbound events flow through `eventToItem`. Async decoders resolve and update the index when ready.
|
||||||
|
4. **Derive:** convenience accessors (`display(...)`, `urls(...)`, …) are `project(key, read)` calls returning a `Projection<U>`.
|
||||||
|
|
||||||
|
`forceLoad` bypasses the cache and resolves to the freshly-read item.
|
||||||
|
|
||||||
|
## The `Stores` plugin
|
||||||
|
|
||||||
|
`app.use(Stores)` is the repository/tracker-bound factory that `DerivedPlugin` builds on. It mostly forwards to `@welshman/store`, injecting the app's `repository` and `tracker`:
|
||||||
|
|
||||||
|
- `itemsByKey<T>(opts)` — the live keyed collection used by `DerivedPlugin`
|
||||||
|
- `events(opts)` / `eventsById(opts)` / `makeEvent(opts)` — derived event stores
|
||||||
|
- `eventsByIdByUrl(opts)` / `eventsByIdForUrl(opts)` — relay-scoped views (inject the tracker)
|
||||||
|
- `isDeleted(event)` — reactive deletion status
|
||||||
|
|
||||||
|
You rarely call `Stores` directly — the higher-level data plugins are usually what you want — but it is the seam to use when you need a custom repository-derived store wired to the app.
|
||||||
|
|
||||||
|
## Writing your own plugin
|
||||||
|
|
||||||
|
A plugin is any class with the shape `new (app: IApp) => T`. Extend one of the base classes for a data collection, or write a plain class for behavior:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {DerivedPlugin, Network, type IApp} from "@welshman/app"
|
||||||
|
import {SOME_KIND, readSomething} from "@welshman/util"
|
||||||
|
|
||||||
|
export class Somethings extends DerivedPlugin<ReturnType<typeof readSomething>> {
|
||||||
|
constructor(app: IApp) {
|
||||||
|
super(app, {
|
||||||
|
filters: [{kinds: [SOME_KIND]}],
|
||||||
|
eventToItem: event => readSomething(event),
|
||||||
|
getKey: item => item.event.pubkey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch = (pubkey: string, relayHints: string[] = []) =>
|
||||||
|
this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [SOME_KIND]}, relayHints)
|
||||||
|
}
|
||||||
|
|
||||||
|
// usage
|
||||||
|
const things = app.use(Somethings)
|
||||||
|
const thing$ = things.one(pubkey) // lazily loads via the outbox model
|
||||||
|
```
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
# Thunks
|
|
||||||
|
|
||||||
Thunks provide optimistic updates for event publishing. They immediately update the local repository while handling the actual signing and publishing asynchronously, making the UI feel more responsive.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
A thunk:
|
|
||||||
|
|
||||||
- Updates local state immediately
|
|
||||||
- Handles event signing in the background using the current session
|
|
||||||
- Tracks publish status per relay
|
|
||||||
- Supports soft-undo via abort
|
|
||||||
- Can be delayed/cancelled
|
|
||||||
- Tracks successful publishes
|
|
||||||
|
|
||||||
## Basic Usage
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {publishThunk} from '@welshman/app'
|
|
||||||
import {createEvent, NOTE} from '@welshman/util'
|
|
||||||
|
|
||||||
const publish = async (content: string) => {
|
|
||||||
// Get optimal relays for publishing
|
|
||||||
const relays = ctx.app.router
|
|
||||||
.FromUser()
|
|
||||||
.getUrls()
|
|
||||||
|
|
||||||
// Create and publish thunk
|
|
||||||
const thunk = await publishThunk({
|
|
||||||
event: createEvent(NOTE, {content}),
|
|
||||||
relays,
|
|
||||||
delay: 3000, // 3s window for abort
|
|
||||||
})
|
|
||||||
|
|
||||||
// Track publish results
|
|
||||||
thunk.subscribe($thunk => {
|
|
||||||
for (const [url, result] of Object.entries($thunk.results)) {
|
|
||||||
console.log(`${url}: ${result.status} - ${result.detail}`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Can abort within delay window
|
|
||||||
setTimeout(() => {
|
|
||||||
if (userWantsToCancel) {
|
|
||||||
thunk.controller.abort()
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
|
|
||||||
// Wait for completion
|
|
||||||
await thunk.complete
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Built in commands
|
|
||||||
|
|
||||||
Several thunk factories are provided for common or more complicated scenarios like updating lists:
|
|
||||||
|
|
||||||
- `removeRelay(url: string, mode: RelayMode)`
|
|
||||||
- `addRelay(url: string, mode: RelayMode)`
|
|
||||||
- `removeMessagingRelay(url: string)`
|
|
||||||
- `addMessagingRelay(url: string)`
|
|
||||||
- `setProfile(profile: Profile)`
|
|
||||||
- `unfollow(value: string)`
|
|
||||||
- `follow(tag: string[])`
|
|
||||||
- `unmute(value: string)`
|
|
||||||
- `mutePublicly(tag: string[])`
|
|
||||||
- `mutePrivately(tag: string[])`
|
|
||||||
- `unpin(value: string)`
|
|
||||||
- `pin(tag: string[])`
|
|
||||||
- `sendWrapped({event, recipients, ...options}: SendWrappedOptions)`
|
|
||||||
- `manageRelay(url: string, request: ManagementRequest)`
|
|
||||||
- `createRoom(url: string, room: RoomMeta)`
|
|
||||||
- `deleteRoom(url: string, room: RoomMeta)`
|
|
||||||
- `editRoom(url: string, room: RoomMeta)`
|
|
||||||
- `joinRoom(url: string, room: RoomMeta)`
|
|
||||||
- `leaveRoom(url: string, room: RoomMeta)`
|
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Publishing Events
|
||||||
|
|
||||||
|
Publishing in `@welshman/app` is **optimistic** and built around *thunks*. A thunk writes the event to the local repository immediately (so the UI updates instantly), signs lazily, optionally gift-wraps (NIP-59) and computes proof-of-work (NIP-13), and reports acceptance/rejection per relay. The signing/publishing can be delayed, giving you a soft-undo window.
|
||||||
|
|
||||||
|
Publishing is managed by the `Thunks` plugin: `app.use(Thunks)`.
|
||||||
|
|
||||||
|
## Publishing to specific relays
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {makeEvent, NOTE} from "@welshman/util"
|
||||||
|
|
||||||
|
const thunk = app.use(Thunks).publish({
|
||||||
|
event: makeEvent(NOTE, {content: "hi"}),
|
||||||
|
relays: ["wss://relay.example"],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Publishing to the outbox
|
||||||
|
|
||||||
|
`publishToOutbox` resolves the current user's write relays (via the [Router](./routing)) for you — the usual way to publish your own notes.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const thunk = app.use(Thunks).publishToOutbox({
|
||||||
|
event: makeEvent(NOTE, {content: "hi"}),
|
||||||
|
delay: 3000, // wait 3s before signing/sending — abortable until then
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## `ThunkOptions`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type ThunkOptions = Override<PublishOptions, {
|
||||||
|
app: IApp // injected for you by Thunks.publish
|
||||||
|
event: EventTemplate
|
||||||
|
recipient?: string // present → NIP-59 gift-wrap to this pubkey
|
||||||
|
delay?: number // ms to wait before signing/sending (soft-undo)
|
||||||
|
pow?: number // NIP-13 proof-of-work difficulty
|
||||||
|
}>
|
||||||
|
```
|
||||||
|
|
||||||
|
`publish`/`publishToOutbox` accept these options minus `app` (and minus `relays` for `publishToOutbox`).
|
||||||
|
|
||||||
|
## Working with a thunk
|
||||||
|
|
||||||
|
A thunk is a Svelte store; subscribe to watch per-relay progress.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const thunk = app.use(Thunks).publish({event, relays})
|
||||||
|
|
||||||
|
thunk.subscribe(t => console.log(t.results)) // PublishResultsByRelay
|
||||||
|
|
||||||
|
// Soft-undo: only effective before `delay` elapses
|
||||||
|
thunk.abort()
|
||||||
|
|
||||||
|
// Inspect status
|
||||||
|
thunk.getCompleteUrls()
|
||||||
|
thunk.getIncompleteUrls()
|
||||||
|
thunk.getFailedUrls()
|
||||||
|
thunk.isComplete()
|
||||||
|
thunk.getError() // string | undefined
|
||||||
|
|
||||||
|
// Await outcomes
|
||||||
|
await thunk.waitForCompletion() // resolves when no relay is still pending
|
||||||
|
await thunk.waitForError() // resolves with the first error string
|
||||||
|
```
|
||||||
|
|
||||||
|
## Optimistic-publish history
|
||||||
|
|
||||||
|
The `Thunks` manager keeps a log of all thunks and supports retrying:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const thunks = app.use(Thunks)
|
||||||
|
|
||||||
|
thunks.history // writable<Thunk[]> — the optimistic publish log
|
||||||
|
thunks.retry(thunk) // re-publish a (possibly merged) thunk
|
||||||
|
```
|
||||||
|
|
||||||
|
Each thunk is queued (batched) and its event is written to the repository and tracker the moment it is enqueued, so derived stores reflect it before any relay has responded. If a thunk is aborted before sending, its event and wrap are removed from the repository and its history entry is dropped.
|
||||||
|
|
||||||
|
## Gift-wrapped messages
|
||||||
|
|
||||||
|
There are two ways to publish encrypted, NIP-59 gift-wrapped events.
|
||||||
|
|
||||||
|
### A single thunk with a `recipient`
|
||||||
|
|
||||||
|
Set `recipient` on a normal thunk. The thunk wraps the rumor with an ephemeral key, registers it with the app's `WrapManager`, and publishes the wrap:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
app.use(Thunks).publish({
|
||||||
|
event: rumorTemplate,
|
||||||
|
relays: theirMessagingRelays,
|
||||||
|
recipient: theirPubkey,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Many recipients via `Wraps`
|
||||||
|
|
||||||
|
The `Wraps` plugin publishes one wrap per recipient, resolving each recipient's NIP-17 messaging relays automatically:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const merged = await app.use(Wraps).publish({
|
||||||
|
event: rumorTemplate,
|
||||||
|
recipients: [pubkeyA, pubkeyB],
|
||||||
|
})
|
||||||
|
|
||||||
|
await merged.waitForCompletion()
|
||||||
|
```
|
||||||
|
|
||||||
|
`Wraps.publish` returns a `MergedThunk` aggregating the per-recipient thunks. Incoming wraps addressed to the current user are unwrapped automatically by the [`appPolicyWraps`](./apppolicies) default policy; wraps that fail to unwrap (or are duplicates) are skipped.
|
||||||
|
|
||||||
|
## Proof of work
|
||||||
|
|
||||||
|
Set `pow` to a target difficulty (number of leading zero bits). The thunk mines the PoW before signing; for wrapped events the wrap itself is mined.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
app.use(Thunks).publish({event, relays, pow: 20})
|
||||||
|
```
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
# Router
|
|
||||||
|
|
||||||
The Welshman router can be used to enable the `outbox model` in your Nostr application. It handles relay selection for reading, writing, and discovering events while considering relay quality, user preferences, and network conditions.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The router provides scenarios for common **Nostr** operations:
|
|
||||||
|
|
||||||
- Reading user profiles
|
|
||||||
- Publishing events
|
|
||||||
- Following threads
|
|
||||||
- Handling DMs
|
|
||||||
- Searching content
|
|
||||||
|
|
||||||
Each scenario considers:
|
|
||||||
|
|
||||||
- User's relay preferences (NIP-65)
|
|
||||||
- Event hints in tags
|
|
||||||
- Relay quality scores
|
|
||||||
- Fallback policies
|
|
||||||
- Connection status
|
|
||||||
|
|
||||||
## Basic Usage
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {routerContext, addMaximalFallbacks, Router} from '@welshman/app'
|
|
||||||
|
|
||||||
// Set up global router options
|
|
||||||
routerContext.getDefaultRelays = () => ["wss://relay.damus.io/", "wss://nos.lol/"]
|
|
||||||
|
|
||||||
// Router can be used directly with options, or via a singleton with global options
|
|
||||||
const router = Router.get()
|
|
||||||
|
|
||||||
// Get relays for reading a profile
|
|
||||||
const readRelays = router.ForPubkey(pubkey).getUrls()
|
|
||||||
|
|
||||||
// Get relays for broadcasting events by the current user
|
|
||||||
const writeRelays = router.FromUser().getUrls()
|
|
||||||
|
|
||||||
// Get relays for a quote
|
|
||||||
const quoteRelays = Router.get()
|
|
||||||
.Quote(parentEvent, idOrAddress, relayHints)
|
|
||||||
.policy(addMaximalFallbacks)
|
|
||||||
.getUrls()
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
## Router Features
|
|
||||||
|
|
||||||
- Smart relay selection based on relay monitoring
|
|
||||||
- Quality scoring of relays
|
|
||||||
- Fallback strategies
|
|
||||||
- Handling of special relay types (.onion, local)
|
|
||||||
- NIP-65 support
|
|
||||||
|
|
||||||
The router is central to efficient nostr operations, ensuring events reach their intended audience while minimizing unnecessary network traffic.
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Making Requests
|
||||||
|
|
||||||
|
The `Network` plugin (`app.use(Network)`) wraps the `@welshman/net` request/publish/negentropy functions, injecting the app's net context (its pool and repository) so you don't have to pass it every time. The `Sync` plugin (`app.use(Sync)`) builds on top for negentropy-aware reconciliation.
|
||||||
|
|
||||||
|
## Loading and requesting
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const net = app.use(Network)
|
||||||
|
|
||||||
|
// One-shot load — resolves with matching events
|
||||||
|
const events = await net.load({
|
||||||
|
filters: [{kinds: [1], authors: [pubkey]}],
|
||||||
|
relays: ["wss://relay.example"],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Open a subscription
|
||||||
|
await net.request({
|
||||||
|
filters: [{kinds: [1]}],
|
||||||
|
relays: ["wss://relay.example"],
|
||||||
|
autoClose: true,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`net.load` is a shared, batched loader (created with a 50ms delay / 3s timeout). Use `net.makeLoader(options)` if you need a loader with different batching characteristics.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
publish(options) // publish an event (prefer the Thunks plugin for app publishing)
|
||||||
|
makeLoader(options) // build a custom batched Loader
|
||||||
|
```
|
||||||
|
|
||||||
|
## The outbox model: `loadUsingOutbox`
|
||||||
|
|
||||||
|
`loadUsingOutbox` is the workhorse most data plugins use. Given an author's pubkey, it resolves that author's NIP-65 **write** relays, routes them (with minimal fallbacks, capped at 8), queries them a couple at a time, and resolves with the most recent matching event as soon as any relay responds.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const latestProfile = await net.loadUsingOutbox(pubkey, {kinds: [0]})
|
||||||
|
|
||||||
|
// With relay hints to try first
|
||||||
|
const note = await net.loadUsingOutbox(pubkey, {kinds: [1], limit: 1}, ["wss://hint.example"])
|
||||||
|
```
|
||||||
|
|
||||||
|
The filter is always constrained to `authors: [pubkey]`. This is the mechanism behind the lazy loading you get from `app.use(Profiles).one(pubkey)`, `FollowLists`, `MuteLists`, and friends.
|
||||||
|
|
||||||
|
## Negentropy sync
|
||||||
|
|
||||||
|
`Sync` reconciles the local repository with relays using NIP-77 (negentropy) where available, and falls back to plain request/publish where it isn't (detected via `app.use(Relays).hasNegentropy(url)`).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type AppSyncOpts = {relays: string[]; filters: Filter[]}
|
||||||
|
|
||||||
|
const sync = app.use(Sync)
|
||||||
|
|
||||||
|
// Pull missing events from relays into the local repository
|
||||||
|
await sync.pull({relays, filters: [{kinds: [3], authors: [pubkey]}]})
|
||||||
|
|
||||||
|
// Push local events up to relays
|
||||||
|
await sync.push({relays, filters: [{authors: [pubkey]}]})
|
||||||
|
|
||||||
|
// Query the local repository (sorts unless any filter has a limit)
|
||||||
|
const local = sync.query([{kinds: [1]}])
|
||||||
|
```
|
||||||
|
|
||||||
|
`pull` and `push` operate per relay: if the relay supports negentropy they use efficient set-reconciliation (`net.pull`/`net.push`); otherwise they fall back to a normal request (pull) or publishing each event individually (push). Low-level negentropy primitives are also exposed directly on `Network`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
net.diff(options) // compute a NIP-77 set difference
|
||||||
|
net.pull(options) // negentropy pull
|
||||||
|
net.push(options) // negentropy push
|
||||||
|
```
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Routing & Tags
|
||||||
|
|
||||||
|
## The Router
|
||||||
|
|
||||||
|
`app.use(Router)` is a per-app `Router` (from `@welshman/router`) wired to this app's data. It is the single source for relay selection — there is no global `Router.get()` anymore; one router belongs to each app.
|
||||||
|
|
||||||
|
The app wires it up with:
|
||||||
|
|
||||||
|
- **user pubkey** from `app.user`
|
||||||
|
- **read/write relays** per pubkey from [`RelayLists`](./data#relay-lists)
|
||||||
|
- **relay quality** from [`RelayStats`](#relay-quality)
|
||||||
|
- **default / indexer / search relays** from [`AppConfig`](./appappconfig)
|
||||||
|
|
||||||
|
### Relay-selection scenes
|
||||||
|
|
||||||
|
The router exposes composable "scenes" (inherited from the base router) that resolve to a relay set:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const router = app.use(Router)
|
||||||
|
|
||||||
|
router.FromUser() // the current user's relays
|
||||||
|
router.FromPubkey(pubkey) // another user's relays
|
||||||
|
router.FromRelays(urls) // explicit relays
|
||||||
|
router.Event(event) // relays where an event is likely found
|
||||||
|
router.EventRoots(event) // relays for an event's thread roots
|
||||||
|
router.Search() // search relays
|
||||||
|
```
|
||||||
|
|
||||||
|
Scenes are chainable and terminate in `getUrls()` / `getUrl()`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {addMinimalFallbacks} from "@welshman/router"
|
||||||
|
|
||||||
|
const relays = router.FromUser().policy(addMinimalFallbacks).limit(8).getUrls()
|
||||||
|
const hint = router.Event(event).getUrl()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Relay quality
|
||||||
|
|
||||||
|
`app.use(RelayStats)` collects per-relay connection statistics (open/close/publish/request/event counts, timestamps, recent errors) and exposes a quality score the router uses to rank relays.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const stats = app.use(RelayStats)
|
||||||
|
|
||||||
|
stats.one(url) // Readable<Maybe<RelayStatsItem>>
|
||||||
|
stats.getQuality(url) // number in [0, 1] — 0 for blocked/error-prone relays
|
||||||
|
```
|
||||||
|
|
||||||
|
Stats are populated automatically by the [`appPolicyRelayStats`](./apppolicies) default policy. `getQuality` returns `0` for non-relay URLs, relays in the user's [blocked list](./data#specialized-relay-lists), or error-prone relays, and higher scores for relays that are connected or have been seen before.
|
||||||
|
|
||||||
|
## Tag utilities
|
||||||
|
|
||||||
|
`app.use(Tags)` builds nostr tags using the router for relay hints, `Profiles` for display names, and the current user to avoid self-tagging.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const tags = app.use(Tags)
|
||||||
|
|
||||||
|
tags.tagPubkey(pubkey) // ["p", pubkey, hint, name]
|
||||||
|
tags.tagEvent(event, url?, mark?) // [["e", id, hint, mark, pubkey], ("a", ...)? ]
|
||||||
|
tags.tagEventPubkeys(event) // de-duped p-tags (author + mentions, minus self)
|
||||||
|
tags.tagZapSplit(pubkey, split?) // ["zap", pubkey, hint, split]
|
||||||
|
|
||||||
|
tags.tagEventForReply(event, relay?) // reply tag set (root/reply e/a + p tags)
|
||||||
|
tags.tagEventForComment(event, relay?) // NIP-22 comment tags (K/E/A/I/P + k/p/e)
|
||||||
|
tags.tagEventForQuote(event, relay?) // ["q", id, hint, pubkey]
|
||||||
|
tags.tagEventForReaction(event, relay?) // p, ["k", kind], ["e", id, hint], ("a", ...)?
|
||||||
|
```
|
||||||
|
|
||||||
|
A typical reply:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {makeEvent, NOTE} from "@welshman/util"
|
||||||
|
|
||||||
|
const replyTags = app.use(Tags).tagEventForReply(parentEvent)
|
||||||
|
|
||||||
|
app.use(Thunks).publishToOutbox({
|
||||||
|
event: makeEvent(NOTE, {content: "well said", tags: replyTags}),
|
||||||
|
})
|
||||||
|
```
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
# Session Management
|
|
||||||
|
|
||||||
The session system provides a unified way to handle different authentication methods:
|
|
||||||
|
|
||||||
- NIP-01 via Secret Key
|
|
||||||
- NIP-07 via Browser Extension
|
|
||||||
- NIP-46 via Bunker URL or Nostrconnect
|
|
||||||
- NIP-55 via Android Signer Application
|
|
||||||
- Read-only pubkey login
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Sessions are stored in local storage and can be:
|
|
||||||
|
|
||||||
- Persisted across page reloads
|
|
||||||
- Used with multiple accounts
|
|
||||||
- Switched dynamically
|
|
||||||
- Backed by different signing methods
|
|
||||||
|
|
||||||
## NIP 01 Example
|
|
||||||
|
|
||||||
The simplest type of login is NIP 01, although it's generally a bad idea to be handling user keys. NIP 46, 44, or 07 login are preferable. However, NIP 01 can be useful for supporting signup, local profiles, or ephemeral keys.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {makeSecret} from '@welshman/util'
|
|
||||||
import {loginWithNip01} from '@welshman/app'
|
|
||||||
|
|
||||||
loginWithNip01(makeSecret())
|
|
||||||
```
|
|
||||||
|
|
||||||
## NIP 07 Example
|
|
||||||
|
|
||||||
A simple way to sign in for desktop browser users is using [NIP 07](https://github.com/nostr-protocol/nips/blob/master/07.md). This method is easy to implement, but should be used sparingly, since not all users will be using a browser with a nostr signing extension installed.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {Nip07Signer} from '@welshman/signer'
|
|
||||||
import {loginWithNip07} from '@welshman/app'
|
|
||||||
|
|
||||||
const signer = new Nip07Signer()
|
|
||||||
|
|
||||||
signer.getPubkey().then(pubkey => {
|
|
||||||
if (pubkey) {
|
|
||||||
loginWithNip07(pubkey)
|
|
||||||
} else {
|
|
||||||
// User extension does not exist or did not respond
|
|
||||||
}
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
## NIP-46 Authentication
|
|
||||||
|
|
||||||
The best default signing scheme is [NIP 46](https://github.com/nostr-protocol/nips/blob/master/46.md), AKA "Nostr Connect". This supports multiple handshakes depending on desired UX, and can support advanced use cases like secure enclaves, self-hosted keys, and FROST multisig.
|
|
||||||
|
|
||||||
The simpler `bunker://` handshake is done by asking the user to provide a bunker URL, either by QR code, or by pasting it manually into your application.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {makeSecret} from "@welshman/util"
|
|
||||||
import {Nip46Broker} from "@welshman/signer"
|
|
||||||
import {loginWithNip46, nip46Perms} from "@welshman/app"
|
|
||||||
import {isKeyValid} from "src/util/nostr"
|
|
||||||
|
|
||||||
// Make a client secret - this is distinct from the user's private key, and is used
|
|
||||||
// for communicating securely with the remote signer
|
|
||||||
const clientSecret = makeSecret()
|
|
||||||
|
|
||||||
// Ask the user to input their bunker URL
|
|
||||||
const bunkerUrl = prompt("Please enter your bunker url")
|
|
||||||
|
|
||||||
// Pase the bunker url
|
|
||||||
const {signerPubkey, connectSecret, relays} = Nip46Broker.parseBunkerUrl(bunkerUrl)
|
|
||||||
|
|
||||||
if (!isKeyValid(signerPubkey)) {
|
|
||||||
alert("Sorry, but that's an invalid public key.")
|
|
||||||
} else if (relays.length === 0) {
|
|
||||||
alert("That connection string doesn't have any relays.")
|
|
||||||
} else {
|
|
||||||
// Open up a connection with the signer
|
|
||||||
const broker = Nip46Broker.get({relays, clientSecret, signerPubkey})
|
|
||||||
|
|
||||||
// Send a connect request with the default permissions
|
|
||||||
const result = await broker.connect(connectSecret, nip46Perms)
|
|
||||||
|
|
||||||
// Make sure to check the connect secret to prevent hijacking
|
|
||||||
if (result === connectSecret) {
|
|
||||||
// Get the user's public key
|
|
||||||
const pubkey = await broker.getPublicKey()
|
|
||||||
|
|
||||||
if (!pubkey) {
|
|
||||||
alert("Failed to initialize session")
|
|
||||||
} else {
|
|
||||||
loginWithNip46(pubkey, clientSecret, signerPubkey, relays)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Alternatively, you can provide the user with a `nostrconnect://` URL which they can copy or scan with their signer. This is a better UX for users using a signer on their mobile phone.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {makeSecret} from "@welshman/util"
|
|
||||||
import {Nip46Broker} from "@welshman/signer"
|
|
||||||
import {loginWithNip46, nip46Perms} from "@welshman/app"
|
|
||||||
|
|
||||||
// Create a client secret
|
|
||||||
const clientSecret = makeSecret()
|
|
||||||
|
|
||||||
// Stop listening if the user cancels login
|
|
||||||
const abortController = new AbortController()
|
|
||||||
|
|
||||||
// Customize to use relays the signer can send responses to
|
|
||||||
const relays = ['wss://relay.nsec.app/']
|
|
||||||
|
|
||||||
// Create a broker
|
|
||||||
const broker = Nip46Broker.get({clientSecret, relays})
|
|
||||||
|
|
||||||
// Create a nostrconnect:// url
|
|
||||||
const nostrconnect = await broker.makeNostrconnectUrl({
|
|
||||||
name: "My App",
|
|
||||||
url: window.origin,
|
|
||||||
image: window.origin + '/logo.png',
|
|
||||||
perms: nip46Perms,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Share it with the user. Displaying a QR code is particularly helpful
|
|
||||||
alert("To connect, paste this URL into your signer: " + nostrconnect)
|
|
||||||
|
|
||||||
// Listen for the response
|
|
||||||
let response
|
|
||||||
try {
|
|
||||||
response = await broker.waitForNostrconnect(nostrconnect, abortController.signal)
|
|
||||||
} catch (errorResponse: any) {
|
|
||||||
if (errorResponse?.error) {
|
|
||||||
alert(`Received error from signer: ${errorResponse.error}`)
|
|
||||||
} else if (errorResponse) {
|
|
||||||
console.error(errorResponse)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we got a response, the broker is already connected and we can log in
|
|
||||||
if (response) {
|
|
||||||
const pubkey = await broker.getPublicKey()
|
|
||||||
|
|
||||||
if (!pubkey) {
|
|
||||||
alert("Failed to initialize session")
|
|
||||||
} else {
|
|
||||||
loginWithNip46(pubkey, clientSecret, response.event.pubkey, relays)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## NIP-55 Authentication
|
|
||||||
|
|
||||||
For the best UX on Android, use [NIP 55](https://github.com/nostr-protocol/nips/blob/master/55.md). Note that this only works for web applications that have been compiled to native Android applications using [CapacitorJS](https://capacitorjs.com/) and [nostr-signer-capacitor-plugin](https://github.com/chebizarro/nostr-signer-capacitor-plugin).
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {getNip55, Nip55Signer, loginWithNip55} from "@welshman/signer"
|
|
||||||
|
|
||||||
// Query for installed apps that implement nip 55 signing
|
|
||||||
getNip55().then(signerApps => {
|
|
||||||
// We'll choose the first one and auto-login, but in most cases you'll want to offer a choice
|
|
||||||
if (signerApps.length > 0) {
|
|
||||||
const signer = new Nip55Signer(signerApps[0].packageName)
|
|
||||||
const pubkey = await signer.getPubkey()
|
|
||||||
|
|
||||||
if (pubkey) {
|
|
||||||
loginWithNip55(pubkey, app.packageName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
## Read-only session
|
|
||||||
|
|
||||||
A fun feature of nostr is that you can log in as other people, and see what nostr is like from their perspective (minus encrypted data or course).
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {loginWithPubkey} from "@welshman/app"
|
|
||||||
|
|
||||||
// Log in as hodlbod
|
|
||||||
loginWithPubkey("97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322")
|
|
||||||
```
|
|
||||||
|
|
||||||
## Using the current session
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {signer, session} from '@welshman/app'
|
|
||||||
import {createEvent, NOTE} from '@welshman/util'
|
|
||||||
|
|
||||||
// Print the current session - be aware the private key is stored in memory, be very
|
|
||||||
// careful about how you handle session objects!
|
|
||||||
console.log(session.get())
|
|
||||||
|
|
||||||
// Current session's signer is always ready to use
|
|
||||||
const event = await signer.get().sign(
|
|
||||||
createEvent(NOTE, {content: "Hello Nostr!"})
|
|
||||||
)
|
|
||||||
|
|
||||||
// hodlbod's pubkey
|
|
||||||
const otherPubkey = "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322"
|
|
||||||
|
|
||||||
// Encrypt content for private notes
|
|
||||||
const ciphertext = await signer.get().nip44.encrypt(otherPubkey, "Secret message")
|
|
||||||
|
|
||||||
// Decrypt automatically detects encryption version
|
|
||||||
const plaintext = await decrypt(signer, otherPubkey, ciphertext)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Multiple sessions
|
|
||||||
|
|
||||||
It's possible to support multiple concurrent sessions by simply calling `addSession` multiple times. This will update `sessions`, and set `pubkey` to the most recently added session. You can then switch between sessions by calling `pubkey.set` with a valid session pubkey, and delete sessions using `dropSession(pubkey)`.
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# Tag Utilities
|
|
||||||
|
|
||||||
The tag utilities provide helper functions for creating properly formatted Nostr event tags with correct relay hints and metadata.
|
|
||||||
|
|
||||||
These are especially useful when creating events that reference other events or users.
|
|
||||||
|
|
||||||
## Tag Creators
|
|
||||||
|
|
||||||
### Pubkey Tags
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {tagPubkey} from '@welshman/app'
|
|
||||||
|
|
||||||
// Create a p-tag with relay hint and profile name
|
|
||||||
const tag = tagPubkey(authorPubkey)
|
|
||||||
// => ["p", pubkey, "wss://relay.example.com", "username"]
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
### Event Reference Tags
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {
|
|
||||||
tagEvent, // Basic event reference
|
|
||||||
tagEventForQuote, // For quoting events
|
|
||||||
tagEventForReply, // For reply threads
|
|
||||||
tagEventForComment, // For NIP-22 comments
|
|
||||||
tagEventForReaction // For reactions
|
|
||||||
} from '@welshman/app'
|
|
||||||
|
|
||||||
// Real world example: Creating a reply
|
|
||||||
const createReply = async (parent: TrustedEvent, content: string) => {
|
|
||||||
// Get proper tags for a reply, including:
|
|
||||||
// - All referenced pubkeys
|
|
||||||
// - Root/reply markers
|
|
||||||
// - Inherited mentions
|
|
||||||
// - Relay hints
|
|
||||||
const tags = tagEventForReply(parent)
|
|
||||||
|
|
||||||
return publishThunk({
|
|
||||||
// Use relay hints from tags
|
|
||||||
relays: Router.get().PublishEvent(event).getUrls()
|
|
||||||
event: await signer.get().sign(createEvent(NOTE, {content, tags})),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
+105
-72
@@ -1,100 +1,133 @@
|
|||||||
# User Data Loading
|
# User & Sessions
|
||||||
|
|
||||||
The User Data module provides utilities for loading and managing user-specific data like profiles, follows, mutes, pins, and relay selections. It includes both reactive stores and manual loading functions.
|
An `App` is centered on at most one identity, represented by a `User`. Login state that needs to be persisted is represented separately as a serializable `Session`. The two are connected by session handlers, which know how to turn a serialized session back into a live signer.
|
||||||
|
|
||||||
## User Data Stores
|
## `User`
|
||||||
|
|
||||||
These reactive stores automatically load and cache user data:
|
A `User` is a single identity: a `pubkey` plus the `signer` that proves ownership of it.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// User profile
|
class User {
|
||||||
export const userProfile: Store<Profile | undefined>
|
constructor(readonly pubkey: string, readonly signer: ISigner)
|
||||||
|
|
||||||
// User follows list
|
static fromSigner(signer: ISigner): Promise<User>
|
||||||
export const userFollowList: Store<List | undefined>
|
static fromSession(session: Session): Promise<User | undefined>
|
||||||
|
static require(app: IApp): User
|
||||||
|
|
||||||
// User mutes list
|
sign(event: StampedEvent): Promise<SignedEvent>
|
||||||
export const userMuteList: Store<List | undefined>
|
nip44EncryptToSelf(payload: string): Promise<string>
|
||||||
|
}
|
||||||
// User pins list
|
|
||||||
export const userPinList: Store<List | undefined>
|
|
||||||
|
|
||||||
// User relay selections
|
|
||||||
export const userRelayList: Store<List | undefined>
|
|
||||||
|
|
||||||
// User messaging relay selections
|
|
||||||
export const userMessagingRelayList: Store<List | undefined>
|
|
||||||
|
|
||||||
// User blossom servers
|
|
||||||
export const userBlossomServerList: Store<List | undefined>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Manual Loading Functions
|
### Constructing a user
|
||||||
|
|
||||||
These functions load user data for the currently signed-in user with optional relay hints:
|
- **`User.fromSigner(signer)`** — wraps `signer` in a [`LoggingSigner`](./applogging) (unless it already is one), derives the pubkey via `signer.getPubkey()`, and returns the `User`.
|
||||||
|
- **`User.fromSession(session)`** — resolves a signer from a serialized session (via the [handler registry](#session-handlers)) and returns the `User`, or `undefined` if no handler is registered for the session's method.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Load user profile
|
import {User} from "@welshman/app"
|
||||||
function loadUserProfile(relays?: string[]): Promise<void>
|
import {getNip07} from "@welshman/signer"
|
||||||
|
|
||||||
// Load user follows
|
const user = await User.fromSigner(getNip07())
|
||||||
function loadUserFollowList(relays?: string[]): Promise<void>
|
|
||||||
|
|
||||||
// Load user mutes
|
|
||||||
function loadUserMuteList(relays?: string[]): Promise<void>
|
|
||||||
|
|
||||||
// Load user pins
|
|
||||||
function loadUserPinList(relays?: string[]): Promise<void>
|
|
||||||
|
|
||||||
// Load user relay selections
|
|
||||||
function loadUserRelayList(relays?: string[]): Promise<void>
|
|
||||||
|
|
||||||
// Load user messaging relay selections
|
|
||||||
function loadUserMessagingRelayList(relays?: string[]): Promise<void>
|
|
||||||
|
|
||||||
// Load user blossom servers
|
|
||||||
function loadUserBlossomServerList(relays?: string[]): Promise<void>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Force-reload variants bypass the cache and always fetch fresh data:
|
### Gating user-only actions
|
||||||
|
|
||||||
|
`User.require(app)` returns `app.user`, throwing `"This action requires a signed-in user"` if there is none. Plugins use this internally before signing or encrypting; you can use it the same way.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
function forceLoadUserProfile(relays?: string[]): Promise<void>
|
const user = User.require(app)
|
||||||
function forceLoadUserFollowList(relays?: string[]): Promise<void>
|
const signed = await user.sign(stampedEvent)
|
||||||
function forceLoadUserMuteList(relays?: string[]): Promise<void>
|
|
||||||
function forceLoadUserPinList(relays?: string[]): Promise<void>
|
|
||||||
function forceLoadUserRelayList(relays?: string[]): Promise<void>
|
|
||||||
function forceLoadUserMessagingRelayList(relays?: string[]): Promise<void>
|
|
||||||
function forceLoadUserBlossomServerList(relays?: string[]): Promise<void>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage Examples
|
### Signing & self-encryption
|
||||||
|
|
||||||
|
- **`sign(event)`** delegates to the signer.
|
||||||
|
- **`nip44EncryptToSelf(payload)`** encrypts a payload to your own pubkey via NIP-44 — used for private list entries (mutes, follows) that only you should read.
|
||||||
|
|
||||||
|
## `Session`
|
||||||
|
|
||||||
|
A `Session` is a serializable login descriptor. It contains only data — never a live signer object — so it can be stored in `localStorage`, IndexedDB, or anywhere else, and rehydrated later.
|
||||||
|
|
||||||
### Using Reactive Stores
|
|
||||||
```typescript
|
```typescript
|
||||||
import { userProfile, userFollowList } from '@welshman/app'
|
type Session<M extends string = string, D = unknown> = {method: M; data: D}
|
||||||
|
```
|
||||||
|
|
||||||
// Subscribe to user profile changes
|
### Building sessions
|
||||||
userProfile.subscribe(profile => {
|
|
||||||
if (profile) {
|
Build a typed session from a handler with `toSession`:
|
||||||
console.log('User profile:', profile)
|
|
||||||
}
|
```typescript
|
||||||
|
toSession<M, D>(handler: SessionHandler<M, D>, data: D): Session<M, D>
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {toSession, nip01, nip07, nip46} from "@welshman/app"
|
||||||
|
|
||||||
|
const a = toSession(nip01, {secret: "<hex secret>"})
|
||||||
|
const b = toSession(nip07, {})
|
||||||
|
const c = toSession(nip46, {clientSecret, signerPubkey, relays})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Session handlers
|
||||||
|
|
||||||
|
A `SessionHandler` maps a session's `data` back to an `ISigner`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type SessionHandler<M extends string, D> = {
|
||||||
|
method: M
|
||||||
|
getSigner: (data: D) => MaybeAsync<ISigner>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Built-in handlers
|
||||||
|
|
||||||
|
These are registered automatically when the package loads:
|
||||||
|
|
||||||
|
| Handler | `method` | `data` shape | Signer |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `nip01` | `"nip01"` | `{secret: string}` | `Nip01Signer` |
|
||||||
|
| `nip07` | `"nip07"` | `{}` | `Nip07Signer` (browser extension) |
|
||||||
|
| `nip46` | `"nip46"` | `{clientSecret, signerPubkey, relays}` | `Nip46Signer` (remote signer / bunker) |
|
||||||
|
| `nip55` | `"nip55"` | `{pubkey, signer}` | `Nip55Signer` (Android signer app) |
|
||||||
|
| `pomade` | `"pomade"` | `{clientOptions, email}` | `PomadeSigner` |
|
||||||
|
|
||||||
|
### Registering custom handlers
|
||||||
|
|
||||||
|
Define a handler with `defineSessionHandler` (it infers `M`/`D` so `getSigner` is type-checked against the data shape), then register it:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {defineSessionHandler, registerSessionHandler, unregisterSessionHandler} from "@welshman/app"
|
||||||
|
|
||||||
|
const myHandler = defineSessionHandler({
|
||||||
|
method: "my-method",
|
||||||
|
getSigner: (data: {token: string}) => new MyCustomSigner(data.token),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Get current follows list
|
registerSessionHandler(myHandler)
|
||||||
const follows = userFollowList.get()
|
// later: unregisterSessionHandler(myHandler)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Manual Loading
|
### Resolving signers directly
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { loadUserMuteList, forceLoadUserRelayList } from '@welshman/app'
|
getSignerFromSession(session: Session): MaybeAsync<ISigner> | undefined
|
||||||
|
```
|
||||||
// Load user mutes from specific relays
|
|
||||||
await loadUserMuteList(['wss://relay1.com', 'wss://relay2.com'])
|
Returns the signer for a session, or `undefined` if no handler is registered for its method. `User.fromSession` is a thin wrapper over this.
|
||||||
|
|
||||||
// Force refresh user relay selections
|
## A complete login flow
|
||||||
await forceLoadUserRelayList([])
|
|
||||||
|
```typescript
|
||||||
// Load from default relays
|
import {createApp, User, toSession, nip07} from "@welshman/app"
|
||||||
await loadUserProfile()
|
import {getNip07} from "@welshman/signer"
|
||||||
|
|
||||||
|
// On login: build a serializable session and persist it
|
||||||
|
const session = toSession(nip07, {})
|
||||||
|
localStorage.setItem("session", JSON.stringify(session))
|
||||||
|
|
||||||
|
// On startup: rehydrate the user and create the app
|
||||||
|
const stored = JSON.parse(localStorage.getItem("session"))
|
||||||
|
const user = await User.fromSession(stored) // User | undefined
|
||||||
|
const app = createApp({user})
|
||||||
```
|
```
|
||||||
|
|||||||
+35
-43
@@ -1,63 +1,55 @@
|
|||||||
# Web of Trust (WOT)
|
# Web of Trust
|
||||||
|
|
||||||
Welshman provides utilities for implementing a Web of Trust system within Nostr applications. This system analyzes social connections (follows and mutes) to build a reputation graph that can be used for content filtering, user scoring, and discovery.
|
`app.use(Wot)` computes a web-of-trust graph from follow ([`FollowLists`](./data#follows)) and mute ([`MuteLists`](./data#mutes)) lists, rooted at the current user. When there is no signed-in user, the graph is built from the union of all known follow lists. All computations are throttled (1s) to stay cheap under churn.
|
||||||
|
|
||||||
## Core Concepts
|
The score for a pubkey is the number of roots that follow it minus the number that mute it.
|
||||||
|
|
||||||
- **Follow Trust**: Users gain positive reputation when followed by those in your network
|
## Aggregate projections
|
||||||
- **Mute Distrust**: Users lose reputation when muted by those in your network
|
|
||||||
- **WOT Graph**: A reactive weighted directed graph representing trust relationships
|
|
||||||
- **Contextual Scoring**: Reputation scores that adapt based on user's social graph
|
|
||||||
|
|
||||||
## API Reference
|
Each returns a [`Projection`](./plugins#projection-t) (`.get()` / `.$`):
|
||||||
|
|
||||||
### Social Graph Navigation
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Get users followed by a specific pubkey
|
const wot = app.use(Wot)
|
||||||
getFollows(pubkey: string): string[]
|
|
||||||
|
|
||||||
// Get users who have muted a specific pubkey
|
wot.graph // Projection<Map<string, number>> — score per pubkey
|
||||||
getMutes(pubkey: string): string[]
|
wot.max // Projection<number | undefined> — highest score in the graph
|
||||||
|
wot.followersByPubkey // Projection<Map<string, Set<string>>>
|
||||||
// Get followers of a specific pubkey
|
wot.mutersByPubkey // Projection<Map<string, Set<string>>>
|
||||||
getFollowers(pubkey: string): string[]
|
|
||||||
|
|
||||||
// Get users who have muted a specific pubkey
|
|
||||||
getMuters(pubkey: string): string[]
|
|
||||||
|
|
||||||
// Get the extended network (follows-of-follows) for a pubkey
|
|
||||||
getNetwork(pubkey: string): string[]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Trust Analysis
|
## Per-pubkey queries
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Get follows of a user who also follow a target
|
wot.follows(pubkey) // Projection<string[]> — who pubkey follows
|
||||||
getFollowsWhoFollow(pubkey: string, target: string): string[]
|
wot.mutes(pubkey) // Projection<string[]> — who pubkey mutes
|
||||||
|
wot.followers(pubkey) // Projection<string[]> — who follows pubkey
|
||||||
|
wot.muters(pubkey) // Projection<string[]> — who mutes pubkey
|
||||||
|
wot.network(pubkey) // Projection<string[]> — follows-of-follows (minus direct follows)
|
||||||
|
|
||||||
// Get follows of a user who have muted a target
|
wot.followsWhoFollow(pubkey, target) // Projection<string[]>
|
||||||
getFollowsWhoMute(pubkey: string, target: string): string[]
|
wot.followsWhoMute(pubkey, target) // Projection<string[]>
|
||||||
|
wot.wotScore(pubkey, target) // Projection<number>
|
||||||
// Calculate trust score between users
|
|
||||||
getWotScore(pubkey: string, target: string): number
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Reactive Stores
|
`wotScore(pubkey, target)`:
|
||||||
|
|
||||||
|
- With a `pubkey`: `(pubkey's follows who follow target) − (pubkey's follows who mute target)`.
|
||||||
|
- Without a `pubkey`: `followers(target).length − muters(target).length`.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Map of follower lists by pubkey
|
const wot = app.use(Wot)
|
||||||
followersByPubkey: Readable<Map<string, Set<string>>>
|
|
||||||
|
|
||||||
// Map of muter lists by pubkey
|
// Sort a list of pubkeys by trust, descending
|
||||||
mutersByPubkey: Readable<Map<string, Set<string>>>
|
const graph = wot.graph.get()
|
||||||
|
const sorted = [...pubkeys].sort((a, b) => (graph.get(b) ?? 0) - (graph.get(a) ?? 0))
|
||||||
|
|
||||||
// The full WOT graph with scores (pubkey → score)
|
// Reactive trust score between me and someone else
|
||||||
wotGraph: Writable<Map<string, number>>
|
const score$ = wot.wotScore(myPubkey, theirPubkey).$
|
||||||
|
|
||||||
// The maximum WOT score in the graph
|
// Discover the extended network for a "follows of follows" feed
|
||||||
maxWot: Readable<number>
|
const network = wot.network(myPubkey).get()
|
||||||
|
|
||||||
// Derive the WOT score for a specific user
|
|
||||||
deriveUserWotScore(targetPubkey: string): Readable<number>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The WoT graph also feeds [profile search](./feeds-and-search#search) ranking and the `Scope`/WoT-range pubkey resolution used by [feeds](./feeds-and-search#feeds).
|
||||||
|
|||||||
@@ -1,271 +0,0 @@
|
|||||||
import {COMMENT, getAddress, MUTES, NOTE} from "@welshman/util"
|
|
||||||
import {beforeEach, describe, expect, it, vi} from "vitest"
|
|
||||||
import {
|
|
||||||
tagEvent,
|
|
||||||
tagEventForComment,
|
|
||||||
tagEventForQuote,
|
|
||||||
tagEventForReaction,
|
|
||||||
tagEventForReply,
|
|
||||||
tagEventPubkeys,
|
|
||||||
tagPubkey,
|
|
||||||
tagZapSplit,
|
|
||||||
} from "../src/tags"
|
|
||||||
|
|
||||||
describe("tags", () => {
|
|
||||||
const id = "00".repeat(32)
|
|
||||||
const id1 = "11".repeat(32)
|
|
||||||
const id2 = "22".repeat(32)
|
|
||||||
|
|
||||||
const pubkey = "aa".repeat(32)
|
|
||||||
const pubkey1 = "bb".repeat(32)
|
|
||||||
const pubkey2 = "cc".repeat(32)
|
|
||||||
|
|
||||||
const mockEvent: any = {
|
|
||||||
id,
|
|
||||||
pubkey,
|
|
||||||
kind: 1,
|
|
||||||
tags: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("tagZapSplit", () => {
|
|
||||||
it("should create zap split tag with default split", () => {
|
|
||||||
const result = tagZapSplit(pubkey1)
|
|
||||||
expect(result).toEqual(["zap", pubkey1, expect.any(String), "1"])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should create zap split tag with custom split", () => {
|
|
||||||
const result = tagZapSplit(pubkey1, 0.5)
|
|
||||||
expect(result).toEqual(["zap", pubkey1, expect.any(String), "0.5"])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("tagPubkey", () => {
|
|
||||||
it("should create pubkey tag with relay hint and display name", () => {
|
|
||||||
const result = tagPubkey(pubkey1)
|
|
||||||
expect(result).toEqual(["p", pubkey1, expect.any(String), expect.any(String)])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("tagEvent", () => {
|
|
||||||
it("should create basic event tag", () => {
|
|
||||||
const result = tagEvent(mockEvent)
|
|
||||||
expect(result).toHaveLength(1)
|
|
||||||
expect(result[0]).toEqual(["e", mockEvent.id, expect.any(String), "", mockEvent.pubkey])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should include address tag for replaceable events", () => {
|
|
||||||
const replaceableEvent = {...mockEvent, kind: MUTES}
|
|
||||||
const result = tagEvent(replaceableEvent)
|
|
||||||
expect(result).toHaveLength(2)
|
|
||||||
expect(result[1][0]).toBe("a")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("tagEventPubkeys", () => {
|
|
||||||
it("should extract and tag unique pubkeys from event", () => {
|
|
||||||
const event = {
|
|
||||||
...mockEvent,
|
|
||||||
tags: [
|
|
||||||
["p", pubkey1],
|
|
||||||
["p", pubkey2],
|
|
||||||
],
|
|
||||||
}
|
|
||||||
const result = tagEventPubkeys(event)
|
|
||||||
expect(result).toHaveLength(3) // event.pubkey + 2 tagged pubkeys
|
|
||||||
expect(result.every(tag => tag[0] === "p")).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("tagEventForQuote", () => {
|
|
||||||
it("should create quote tag", () => {
|
|
||||||
const result = tagEventForQuote(mockEvent)
|
|
||||||
expect(result).toEqual(["q", mockEvent.id, expect.any(String), mockEvent.pubkey])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("tagEventForReply", () => {
|
|
||||||
it("should handle reply to event with no existing tags", () => {
|
|
||||||
const result = tagEventForReply(mockEvent)
|
|
||||||
expect(result.some(tag => tag[0] === "e")).toBe(true)
|
|
||||||
expect(result.some(tag => tag[3] === "root")).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should handle reply to event with root", () => {
|
|
||||||
const eventWithRoot = {
|
|
||||||
...mockEvent,
|
|
||||||
tags: [
|
|
||||||
["e", id1, "", "root"],
|
|
||||||
["p", pubkey1],
|
|
||||||
],
|
|
||||||
}
|
|
||||||
const result = tagEventForReply(eventWithRoot)
|
|
||||||
const p = result.filter(tag => tag[0] === "p")
|
|
||||||
const e = result.filter(tag => tag[0] === "e")
|
|
||||||
// p[0] should be the author of the event
|
|
||||||
expect(p[0][1]).toBe(pubkey)
|
|
||||||
// p[1] should be the pubkey mentioned in the event
|
|
||||||
expect(p[1][1]).toBe(pubkey1)
|
|
||||||
// e[0] the "e" root tag should have been propagated
|
|
||||||
expect(e[0][1]).toBe(id1)
|
|
||||||
// e[1] should be the event id
|
|
||||||
expect(e[1][1]).toBe(id)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should handle replaceable events", () => {
|
|
||||||
const replaceableEvent = {
|
|
||||||
...mockEvent,
|
|
||||||
kind: MUTES,
|
|
||||||
tags: [
|
|
||||||
["e", id1, "relay-url", "root"],
|
|
||||||
["e", id2, "relay-url", "mention"],
|
|
||||||
],
|
|
||||||
}
|
|
||||||
const result = tagEventForReply(replaceableEvent)
|
|
||||||
|
|
||||||
const p = result.filter(tag => tag[0] === "p")
|
|
||||||
const e = result.filter(tag => tag[0] === "e")
|
|
||||||
const a = result.filter(tag => tag[0] === "a")
|
|
||||||
|
|
||||||
// p[0] should be the author of the event
|
|
||||||
expect(p[0][1]).toBe(pubkey)
|
|
||||||
// e[0] should be the root propagated
|
|
||||||
expect(e[0][1]).toBe(id1)
|
|
||||||
expect(e[0][3]).toBe("root")
|
|
||||||
// e[1] should be the event id and marked as a reply
|
|
||||||
expect(e[1][1]).toBe(id)
|
|
||||||
expect(e[1][3]).toBe("reply")
|
|
||||||
|
|
||||||
// a[0] should be the address of the replaceable event
|
|
||||||
expect(a[0][1]).toBe(getAddress(replaceableEvent))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("tagEventForComment", () => {
|
|
||||||
it("should create comment tags for basic event", () => {
|
|
||||||
const result = tagEventForComment(mockEvent)
|
|
||||||
expect(result.some(tag => tag[0] === "K")).toBe(true)
|
|
||||||
expect(result.some(tag => tag[0] === "P")).toBe(true)
|
|
||||||
expect(result.some(tag => tag[0] === "E")).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should handle replaceable events", () => {
|
|
||||||
const replaceableEvent = {...mockEvent, kind: MUTES}
|
|
||||||
const result = tagEventForComment(replaceableEvent)
|
|
||||||
expect(result.some(tag => tag[0] === "A")).toBe(true)
|
|
||||||
expect(result.some(tag => tag[0] === "a")).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should preserve root tags and point to the direct parent", () => {
|
|
||||||
const eventWithTags = {
|
|
||||||
...mockEvent,
|
|
||||||
kind: COMMENT,
|
|
||||||
tags: [
|
|
||||||
["e", id2, "relay-url", "root"],
|
|
||||||
["p", pubkey2, "relay-url"],
|
|
||||||
["k", NOTE.toString()],
|
|
||||||
["E", id1, "relay-url", "root"],
|
|
||||||
["P", pubkey1, "relay-url"],
|
|
||||||
["K", NOTE.toString()],
|
|
||||||
],
|
|
||||||
}
|
|
||||||
const result = tagEventForComment(eventWithTags)
|
|
||||||
|
|
||||||
// Should preserve uppercase variants of existing tags
|
|
||||||
// expect(result.some(tag => tag[0] === "E" && tag[1] === id1)).toBe(true)
|
|
||||||
// expect(result.some(tag => tag[0] === "P" && tag[1] === pubkey1)).toBe(true)
|
|
||||||
// expect(result.some(tag => tag[0] === "K" && tag[1] === NOTE.toString())).toBe(true)
|
|
||||||
|
|
||||||
// Should also add lowercase variants
|
|
||||||
expect(result.some(tag => tag[0] === "e" && tag[1] === eventWithTags.id)).toBe(true)
|
|
||||||
expect(result.some(tag => tag[0] === "p" && tag[1] === eventWithTags.pubkey)).toBe(true)
|
|
||||||
expect(result.some(tag => tag[0] === "k" && tag[1] === COMMENT.toString())).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should handle events with multiple root tags", () => {
|
|
||||||
const eventWithMultipleRoots = {
|
|
||||||
...mockEvent,
|
|
||||||
tags: [
|
|
||||||
["e", id1, "relay-url", "root"],
|
|
||||||
["e", id2, "relay-url", "root"],
|
|
||||||
],
|
|
||||||
}
|
|
||||||
const result = tagEventForComment(eventWithMultipleRoots)
|
|
||||||
|
|
||||||
expect(result).toEqual([
|
|
||||||
["K", String(NOTE)],
|
|
||||||
["P", pubkey, expect.any(String)],
|
|
||||||
["E", id, expect.any(String), pubkey],
|
|
||||||
["k", String(NOTE)],
|
|
||||||
["p", pubkey, expect.any(String)],
|
|
||||||
["e", id, expect.any(String), pubkey],
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should handle events with mixed tag types", () => {
|
|
||||||
const eventWithMixedTags = {
|
|
||||||
...mockEvent,
|
|
||||||
kind: MUTES,
|
|
||||||
tags: [
|
|
||||||
["e", id, "relay-url", "root"],
|
|
||||||
["p", pubkey1, "relay-url"],
|
|
||||||
["i", id1],
|
|
||||||
["a", "some-address", "relay-url"],
|
|
||||||
["custom", "value"],
|
|
||||||
],
|
|
||||||
}
|
|
||||||
const result = tagEventForComment(eventWithMixedTags)
|
|
||||||
|
|
||||||
expect(result).toEqual([
|
|
||||||
["K", String(MUTES)],
|
|
||||||
["P", pubkey, expect.any(String)],
|
|
||||||
["E", id, expect.any(String), pubkey],
|
|
||||||
["A", getAddress(eventWithMixedTags), expect.any(String), pubkey],
|
|
||||||
["k", String(MUTES)],
|
|
||||||
["p", pubkey, expect.any(String)],
|
|
||||||
["e", id, expect.any(String), pubkey],
|
|
||||||
["a", getAddress(eventWithMixedTags), expect.any(String), pubkey],
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should add event metadata tags when no root tags exist", () => {
|
|
||||||
const eventWithoutRoots = {
|
|
||||||
...mockEvent,
|
|
||||||
tags: [["custom", "value"]],
|
|
||||||
}
|
|
||||||
const result = tagEventForComment(eventWithoutRoots)
|
|
||||||
|
|
||||||
// Should add uppercase metadata tags (roots)
|
|
||||||
expect(result.some(tag => tag[0] === "K" && tag[1] === String(mockEvent.kind))).toBe(true)
|
|
||||||
expect(result.some(tag => tag[0] === "P" && tag[1] === mockEvent.pubkey)).toBe(true)
|
|
||||||
expect(result.some(tag => tag[0] === "E" && tag[1] === mockEvent.id)).toBe(true)
|
|
||||||
|
|
||||||
// Should add lowercase variants (parents)
|
|
||||||
expect(result.some(tag => tag[0] === "k" && tag[1] === String(mockEvent.kind))).toBe(true)
|
|
||||||
expect(result.some(tag => tag[0] === "p" && tag[1] === mockEvent.pubkey)).toBe(true)
|
|
||||||
expect(result.some(tag => tag[0] === "e" && tag[1] === mockEvent.id)).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("tagEventForReaction", () => {
|
|
||||||
it("should create reaction tags", () => {
|
|
||||||
const result = tagEventForReaction(mockEvent)
|
|
||||||
expect(result.some(tag => tag[0] === "k")).toBe(true)
|
|
||||||
expect(result.some(tag => tag[0] === "e")).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should include author tag if different from current user", () => {
|
|
||||||
const result = tagEventForReaction(mockEvent)
|
|
||||||
expect(result.some(tag => tag[0] === "p")).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should handle replaceable events", () => {
|
|
||||||
const replaceableEvent = {...mockEvent, kind: MUTES}
|
|
||||||
const result = tagEventForReaction(replaceableEvent)
|
|
||||||
expect(result.some(tag => tag[0] === "a")).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import {PublishStatus, LOCAL_RELAY_URL} from "@welshman/net"
|
|
||||||
import {NOTE, DIRECT_MESSAGE, WRAP, makeEvent, getPubkey, makeSecret, prep} from "@welshman/util"
|
|
||||||
import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"
|
|
||||||
import {repository, tracker} from "../src/core"
|
|
||||||
import {addSession, dropSession, makeNip01Session} from "../src/session"
|
|
||||||
import {abortThunk, MergedThunk, publishThunk, thunkQueue, flattenThunks} from "../src/thunk"
|
|
||||||
|
|
||||||
const secret = makeSecret()
|
|
||||||
|
|
||||||
const pubkey = getPubkey(secret)
|
|
||||||
|
|
||||||
const mockRequest = {
|
|
||||||
event: prep({...makeEvent(NOTE), pubkey}),
|
|
||||||
relays: [LOCAL_RELAY_URL],
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("thunk", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers()
|
|
||||||
addSession(makeNip01Session(secret))
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
thunkQueue.stop()
|
|
||||||
thunkQueue.clear()
|
|
||||||
await vi.runAllTimersAsync()
|
|
||||||
vi.useRealTimers()
|
|
||||||
vi.clearAllMocks()
|
|
||||||
thunkQueue.start()
|
|
||||||
dropSession(pubkey)
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("MergedThunk", () => {
|
|
||||||
it("should abort all thunks when merged controller aborts", () => {
|
|
||||||
const thunk1 = publishThunk(mockRequest)
|
|
||||||
const thunk2 = publishThunk(mockRequest)
|
|
||||||
const merged = new MergedThunk([thunk1, thunk2])
|
|
||||||
|
|
||||||
abortThunk(merged)
|
|
||||||
|
|
||||||
expect(thunk1.controller.signal.aborted).toBe(true)
|
|
||||||
expect(thunk2.controller.signal.aborted).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("flattenThunks", () => {
|
|
||||||
it("should iterate through nested thunks", () => {
|
|
||||||
const thunk1 = publishThunk(mockRequest)
|
|
||||||
const thunk2 = publishThunk(mockRequest)
|
|
||||||
const merged = new MergedThunk([thunk1, thunk2])
|
|
||||||
const thunks = Array.from(flattenThunks([merged, thunk1]))
|
|
||||||
|
|
||||||
expect(thunks).toHaveLength(3)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("publishThunk", () => {
|
|
||||||
it("should create and publish a thunk", async () => {
|
|
||||||
const publishSpy = vi.spyOn(repository, "publish")
|
|
||||||
const result = publishThunk(mockRequest)
|
|
||||||
|
|
||||||
expect(publishSpy).toHaveBeenCalled()
|
|
||||||
expect(result).toHaveProperty("event")
|
|
||||||
expect(result).toHaveProperty("options")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should handle abort", () => {
|
|
||||||
const removeEventSpy = vi.spyOn(repository, "removeEvent")
|
|
||||||
const thunk = publishThunk(mockRequest)
|
|
||||||
|
|
||||||
abortThunk(thunk)
|
|
||||||
|
|
||||||
expect(removeEventSpy).toHaveBeenCalledWith(thunk.event.id)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("abortThunk", () => {
|
|
||||||
it("should abort a thunk and clean up", () => {
|
|
||||||
const removeEventSpy = vi.spyOn(repository, "removeEvent")
|
|
||||||
const thunk = publishThunk(mockRequest)
|
|
||||||
|
|
||||||
abortThunk(thunk)
|
|
||||||
|
|
||||||
expect(removeEventSpy).toHaveBeenCalledWith(thunk.event.id)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should update status during publishing", async () => {
|
|
||||||
const track = vi.spyOn(tracker, "track")
|
|
||||||
const thunk = publishThunk(mockRequest)
|
|
||||||
|
|
||||||
// Wait for initial async operations
|
|
||||||
await vi.runAllTimersAsync()
|
|
||||||
|
|
||||||
expect(thunk.results[LOCAL_RELAY_URL].status).toEqual(PublishStatus.Success)
|
|
||||||
|
|
||||||
// Verify tracker was called on success
|
|
||||||
expect(track).toHaveBeenCalledWith(thunk.event.id, LOCAL_RELAY_URL)
|
|
||||||
|
|
||||||
await vi.runAllTimersAsync()
|
|
||||||
await thunk.complete
|
|
||||||
|
|
||||||
expect(thunk.results[LOCAL_RELAY_URL].status).toEqual(PublishStatus.Success)
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("wrapped events", () => {
|
|
||||||
it("if recipient is included, the event should be wrapped", async () => {
|
|
||||||
const recipient = getPubkey(makeSecret())
|
|
||||||
const event = prep({...makeEvent(DIRECT_MESSAGE), pubkey})
|
|
||||||
const thunk = publishThunk({event, relays: [], recipient})
|
|
||||||
const publishSpy = vi.spyOn(thunk, "_publish")
|
|
||||||
|
|
||||||
await vi.runAllTimersAsync()
|
|
||||||
|
|
||||||
expect(publishSpy.mock.calls[0][0].kind).toBe(WRAP)
|
|
||||||
expect(publishSpy.mock.calls[0][0].id).not.toBe(thunk.event.id)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "@welshman/app",
|
"name": "@welshman/app",
|
||||||
"version": "0.8.16",
|
"version": "0.8.13",
|
||||||
"author": "hodlbod",
|
"author": "hodlbod",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"description": "A collection of svelte stores for use in building nostr client applications.",
|
"description": "An instance-based, composable client for building nostr applications",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@pomade/core": "^0.2.1",
|
"@pomade/core": "^0.2.1",
|
||||||
|
"@welshman/domain": "workspace:*",
|
||||||
"@welshman/feeds": "workspace:*",
|
"@welshman/feeds": "workspace:*",
|
||||||
"@welshman/lib": "workspace:*",
|
"@welshman/lib": "workspace:*",
|
||||||
"@welshman/net": "workspace:*",
|
"@welshman/net": "workspace:*",
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
"typescript": "~5.8.0",
|
"typescript": "~5.8.0",
|
||||||
"@pomade/core": "^0.2.1",
|
"@pomade/core": "^0.2.1",
|
||||||
"@types/throttle-debounce": "^5.0.2",
|
"@types/throttle-debounce": "^5.0.2",
|
||||||
|
"@welshman/domain": "workspace:*",
|
||||||
"@welshman/feeds": "workspace:*",
|
"@welshman/feeds": "workspace:*",
|
||||||
"@welshman/lib": "workspace:*",
|
"@welshman/lib": "workspace:*",
|
||||||
"@welshman/net": "workspace:*",
|
"@welshman/net": "workspace:*",
|
||||||
|
|||||||
@@ -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 "./app.js"
|
||||||
export * from "./context.js"
|
export * from "./policy.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 "./user.js"
|
export * from "./user.js"
|
||||||
export * from "./wot.js"
|
export * from "./session.js"
|
||||||
export * from "./zappers.js"
|
export * from "./logging.js"
|
||||||
|
export * from "./createApp.js"
|
||||||
import {derived} from "svelte/store"
|
export * from "./plugins/base.js"
|
||||||
import {sortBy, throttleWithValue} from "@welshman/lib"
|
export * from "./plugins/network.js"
|
||||||
import {
|
export * from "./plugins/stores.js"
|
||||||
isEphemeralKind,
|
export * from "./plugins/router.js"
|
||||||
isDVMKind,
|
export * from "./plugins/relays.js"
|
||||||
WRAP,
|
export * from "./plugins/relayStats.js"
|
||||||
RelayMode,
|
export * from "./plugins/relayLists.js"
|
||||||
RelayProfile,
|
export * from "./plugins/blockedRelayLists.js"
|
||||||
getRelaysFromList,
|
export * from "./plugins/plaintext.js"
|
||||||
} from "@welshman/util"
|
export * from "./plugins/profiles.js"
|
||||||
import {routerContext} from "@welshman/router"
|
export * from "./plugins/follows.js"
|
||||||
import {Pool, SocketEvent, isRelayEvent, netContext} from "@welshman/net"
|
export * from "./plugins/mutes.js"
|
||||||
import {pubkey, unwrapAndStore} from "./session.js"
|
export * from "./plugins/pins.js"
|
||||||
import {repository, tracker} from "./core.js"
|
export * from "./plugins/blossom.js"
|
||||||
import {getRelays, loadRelay} from "./relays.js"
|
export * from "./plugins/messagingRelayLists.js"
|
||||||
import {trackRelayStats, getRelayQuality} from "./relayStats.js"
|
export * from "./plugins/searchRelayLists.js"
|
||||||
import {deriveRelayList, getRelayList} from "./relayLists.js"
|
export * from "./plugins/handles.js"
|
||||||
import {deriveSearchRelayList, getSearchRelayList} from "./searchRelayLists.js"
|
export * from "./plugins/zappers.js"
|
||||||
import {deriveBlockedRelayList, getBlockedRelayList} from "./blockedRelayLists.js"
|
export * from "./plugins/topics.js"
|
||||||
import {deriveMessagingRelayList, getMessagingRelayList} from "./messagingRelayLists.js"
|
export * from "./plugins/tags.js"
|
||||||
|
export * from "./plugins/wot.js"
|
||||||
// Sync relays with our database
|
export * from "./plugins/feeds.js"
|
||||||
|
export * from "./plugins/search.js"
|
||||||
Pool.get().subscribe(socket => {
|
export * from "./plugins/sync.js"
|
||||||
loadRelay(socket.url)
|
export * from "./plugins/wraps.js"
|
||||||
trackRelayStats(socket)
|
export * from "./plugins/rooms.js"
|
||||||
|
export * from "./plugins/relayManagement.js"
|
||||||
socket.on(SocketEvent.Receive, message => {
|
export * from "./plugins/thunk.js"
|
||||||
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"))
|
|
||||||
|
|||||||
@@ -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,47 @@
|
|||||||
|
import {BLOCKED_RELAYS} from "@welshman/util"
|
||||||
|
import {BlockedRelayList, BlockedRelayListBuilder} from "@welshman/domain"
|
||||||
|
import {DerivedPlugin} from "./base.js"
|
||||||
|
import type {Projection} from "./base.js"
|
||||||
|
import {Network} from "./network.js"
|
||||||
|
import {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<BlockedRelayList> {
|
||||||
|
constructor(app: IApp) {
|
||||||
|
super(app, {
|
||||||
|
filters: [{kinds: [BLOCKED_RELAYS]}],
|
||||||
|
eventToItem: BlockedRelayList.factory(app.user?.signer),
|
||||||
|
getKey: list => list.author(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 => list?.urls() ?? [])
|
||||||
|
|
||||||
|
update = async (fn: (builder: BlockedRelayListBuilder) => void) => {
|
||||||
|
const user = User.require(this.app)
|
||||||
|
const builder = new BlockedRelayListBuilder(await this.forceLoad(user.pubkey))
|
||||||
|
|
||||||
|
fn(builder)
|
||||||
|
|
||||||
|
const event = await builder.toTemplate(user.signer)
|
||||||
|
|
||||||
|
return this.app.use(Thunks).publishToOutbox({event})
|
||||||
|
}
|
||||||
|
|
||||||
|
addUrl = (url: string) => this.update(builder => builder.addUrl(url))
|
||||||
|
|
||||||
|
removeUrl = (url: string) => this.update(builder => builder.removeUrl(url))
|
||||||
|
|
||||||
|
setUrls = (urls: string[]) => this.update(builder => builder.setUrls(urls))
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import {BLOSSOM_SERVERS} from "@welshman/util"
|
||||||
|
import {BlossomServerList} from "@welshman/domain"
|
||||||
|
import {DerivedPlugin} from "./base.js"
|
||||||
|
import {Network} from "./network.js"
|
||||||
|
import type {IApp} from "../app.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<BlossomServerList> {
|
||||||
|
constructor(app: IApp) {
|
||||||
|
super(app, {
|
||||||
|
filters: [{kinds: [BLOSSOM_SERVERS]}],
|
||||||
|
eventToItem: BlossomServerList.factory(app.user?.signer),
|
||||||
|
getKey: list => list.author(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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,40 @@
|
|||||||
|
import {FOLLOWS} from "@welshman/util"
|
||||||
|
import {FollowList, FollowListBuilder} from "@welshman/domain"
|
||||||
|
import {DerivedPlugin} from "./base.js"
|
||||||
|
import {Network} from "./network.js"
|
||||||
|
import {Thunks} from "./thunk.js"
|
||||||
|
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<FollowList> {
|
||||||
|
constructor(app: IApp) {
|
||||||
|
super(app, {
|
||||||
|
filters: [{kinds: [FOLLOWS]}],
|
||||||
|
eventToItem: FollowList.factory(app.user?.signer),
|
||||||
|
getKey: followList => followList.author(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(pubkey: string, relayHints: string[] = []) {
|
||||||
|
return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [FOLLOWS]}, relayHints)
|
||||||
|
}
|
||||||
|
|
||||||
|
update = async (fn: (builder: FollowListBuilder) => void) => {
|
||||||
|
const user = User.require(this.app)
|
||||||
|
const builder = new FollowListBuilder(await this.forceLoad(user.pubkey))
|
||||||
|
|
||||||
|
fn(builder)
|
||||||
|
|
||||||
|
const event = await builder.toTemplate(user.signer)
|
||||||
|
|
||||||
|
return this.app.use(Thunks).publishToOutbox({event})
|
||||||
|
}
|
||||||
|
|
||||||
|
follow = (tag: string[]) => this.update(builder => builder.addPublic(tag))
|
||||||
|
|
||||||
|
unfollow = (value: string) => this.update(builder => builder.removeFollow(value))
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import {tryCatch, batcher, postJson} from "@welshman/lib"
|
||||||
|
import type {Maybe} from "@welshman/lib"
|
||||||
|
import {queryProfile, displayNip05} from "@welshman/util"
|
||||||
|
import type {Handle} from "@welshman/util"
|
||||||
|
import type {Profile} from "@welshman/domain"
|
||||||
|
import {deriveDeduplicated} from "@welshman/store"
|
||||||
|
import {LoadableMapPlugin, projection} from "./base.js"
|
||||||
|
import type {Projection} from "./base.js"
|
||||||
|
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)
|
||||||
|
|
||||||
|
const nip05 = $profile?.nip05()
|
||||||
|
|
||||||
|
return nip05 ? this.load(nip05) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
forPubkey = (pubkey: string, relays: string[] = []): Projection<Maybe<Handle>> => {
|
||||||
|
this.loadForPubkey(pubkey, relays)
|
||||||
|
|
||||||
|
const read = ([$handlesByNip05, $profile]: [ReadonlyMap<string, Handle>, Maybe<Profile>]) => {
|
||||||
|
const nip05 = $profile?.nip05()
|
||||||
|
|
||||||
|
if (!nip05) return undefined
|
||||||
|
|
||||||
|
const handle = $handlesByNip05.get(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,47 @@
|
|||||||
|
import {MESSAGING_RELAYS} from "@welshman/util"
|
||||||
|
import {MessagingRelayList, MessagingRelayListBuilder} from "@welshman/domain"
|
||||||
|
import {DerivedPlugin} from "./base.js"
|
||||||
|
import type {Projection} from "./base.js"
|
||||||
|
import {Network} from "./network.js"
|
||||||
|
import {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<MessagingRelayList> {
|
||||||
|
constructor(app: IApp) {
|
||||||
|
super(app, {
|
||||||
|
filters: [{kinds: [MESSAGING_RELAYS]}],
|
||||||
|
eventToItem: MessagingRelayList.factory(app.user?.signer),
|
||||||
|
getKey: list => list.author(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 => list?.urls() ?? [])
|
||||||
|
|
||||||
|
update = async (fn: (builder: MessagingRelayListBuilder) => void) => {
|
||||||
|
const user = User.require(this.app)
|
||||||
|
const builder = new MessagingRelayListBuilder(await this.forceLoad(user.pubkey))
|
||||||
|
|
||||||
|
fn(builder)
|
||||||
|
|
||||||
|
const event = await builder.toTemplate(user.signer)
|
||||||
|
|
||||||
|
return this.app.use(Thunks).publishToOutbox({event})
|
||||||
|
}
|
||||||
|
|
||||||
|
addUrl = (url: string) => this.update(builder => builder.addUrl(url))
|
||||||
|
|
||||||
|
removeUrl = (url: string) => this.update(builder => builder.removeUrl(url))
|
||||||
|
|
||||||
|
setUrls = (urls: string[]) => this.update(builder => builder.setUrls(urls))
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import {nthEq} from "@welshman/lib"
|
||||||
|
import {MUTES} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import type {ISigner} from "@welshman/signer"
|
||||||
|
import {MuteList, MuteListBuilder} from "@welshman/domain"
|
||||||
|
import {DerivedPlugin} from "./base.js"
|
||||||
|
import type {IApp} from "../app.js"
|
||||||
|
import {Network} from "./network.js"
|
||||||
|
import {Thunks} from "./thunk.js"
|
||||||
|
import {Plaintext} from "./plaintext.js"
|
||||||
|
import {User} from "../user.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A signer that decrypts via the app's plaintext cache (keyed by event), falling
|
||||||
|
* back to the real signer. Lets `MuteList.fromEvent(event, signer)` reuse cached
|
||||||
|
* decryptions instead of re-decrypting. Returns undefined when there's no user,
|
||||||
|
* so the reader falls back to public-only.
|
||||||
|
*/
|
||||||
|
const makeCachedSigner = (app: IApp, event: TrustedEvent): ISigner | undefined => {
|
||||||
|
const user = app.user
|
||||||
|
|
||||||
|
if (!user) return undefined
|
||||||
|
|
||||||
|
const {signer} = user
|
||||||
|
const decryptVia =
|
||||||
|
(fallback: (pubkey: string, message: string) => Promise<string>) =>
|
||||||
|
async (pubkey: string, message: string) =>
|
||||||
|
(await app.use(Plaintext).ensure(event)) ?? fallback(pubkey, message)
|
||||||
|
|
||||||
|
return {
|
||||||
|
sign: (event, options) => signer.sign(event, options),
|
||||||
|
getPubkey: () => signer.getPubkey(),
|
||||||
|
nip04: {
|
||||||
|
encrypt: (pubkey, message) => signer.nip04.encrypt(pubkey, message),
|
||||||
|
decrypt: decryptVia((pubkey, message) => signer.nip04.decrypt(pubkey, message)),
|
||||||
|
},
|
||||||
|
nip44: {
|
||||||
|
encrypt: (pubkey, message) => signer.nip44.encrypt(pubkey, message),
|
||||||
|
decrypt: decryptVia((pubkey, message) => signer.nip44.decrypt(pubkey, message)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kind-10000 mute lists, keyed by pubkey. Mute lists carry private entries in
|
||||||
|
* encrypted content, decoded through the plaintext cache (via a cache-backed
|
||||||
|
* signer passed to the reader).
|
||||||
|
*/
|
||||||
|
export class MuteLists extends DerivedPlugin<MuteList> {
|
||||||
|
constructor(app: IApp) {
|
||||||
|
super(app, {
|
||||||
|
filters: [{kinds: [MUTES]}],
|
||||||
|
eventToItem: event => MuteList.fromEvent(event, makeCachedSigner(app, event)),
|
||||||
|
getKey: mute => mute.author(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(pubkey: string, relayHints: string[] = []) {
|
||||||
|
return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [MUTES]}, relayHints)
|
||||||
|
}
|
||||||
|
|
||||||
|
update = async (fn: (builder: MuteListBuilder) => void) => {
|
||||||
|
const user = User.require(this.app)
|
||||||
|
const builder = new MuteListBuilder(await this.forceLoad(user.pubkey))
|
||||||
|
|
||||||
|
fn(builder)
|
||||||
|
|
||||||
|
const event = await builder.toTemplate(user.signer)
|
||||||
|
|
||||||
|
return this.app.use(Thunks).publishToOutbox({event})
|
||||||
|
}
|
||||||
|
|
||||||
|
mutePublicly = (tag: string[]) => this.update(builder => builder.addPublic(tag))
|
||||||
|
|
||||||
|
mutePrivately = (tag: string[]) => this.update(builder => builder.addPrivate(tag))
|
||||||
|
|
||||||
|
unmute = (value: string) => this.update(builder => builder.drop(nthEq(1, value)))
|
||||||
|
|
||||||
|
setMutes = (updates: {publicTags?: string[][]; privateTags?: string[][]}) =>
|
||||||
|
this.update(builder => {
|
||||||
|
if (updates.publicTags) builder.clearPublic().addPublic(...updates.publicTags)
|
||||||
|
if (updates.privateTags) builder.clearPrivate().addPrivate(...updates.privateTags)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import {chunk, first} from "@welshman/lib"
|
||||||
|
import {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 = (await this.app.use(RelayLists).load(pubkey))?.writeUrls() ?? []
|
||||||
|
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,40 @@
|
|||||||
|
import {PINS} from "@welshman/util"
|
||||||
|
import {PinList, PinListBuilder} from "@welshman/domain"
|
||||||
|
import {DerivedPlugin} from "./base.js"
|
||||||
|
import {Network} from "./network.js"
|
||||||
|
import {Thunks} from "./thunk.js"
|
||||||
|
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<PinList> {
|
||||||
|
constructor(app: IApp) {
|
||||||
|
super(app, {
|
||||||
|
filters: [{kinds: [PINS]}],
|
||||||
|
eventToItem: PinList.factory(app.user?.signer),
|
||||||
|
getKey: pins => pins.author(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(pubkey: string, relayHints: string[] = []) {
|
||||||
|
return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [PINS]}, relayHints)
|
||||||
|
}
|
||||||
|
|
||||||
|
update = async (fn: (builder: PinListBuilder) => void) => {
|
||||||
|
const user = User.require(this.app)
|
||||||
|
const builder = new PinListBuilder(await this.forceLoad(user.pubkey))
|
||||||
|
|
||||||
|
fn(builder)
|
||||||
|
|
||||||
|
const event = await builder.toTemplate(user.signer)
|
||||||
|
|
||||||
|
return this.app.use(Thunks).publishToOutbox({event})
|
||||||
|
}
|
||||||
|
|
||||||
|
pin = (tag: string[]) => this.update(builder => builder.pinPublicly(tag))
|
||||||
|
|
||||||
|
unpin = (value: string) => this.update(builder => builder.unpin(value))
|
||||||
|
}
|
||||||
@@ -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,51 @@
|
|||||||
|
import {derived, readable} from "svelte/store"
|
||||||
|
import {PROFILE} from "@welshman/util"
|
||||||
|
import type {Maybe} from "@welshman/lib"
|
||||||
|
import {Profile, ProfileBuilder, displayPubkey} from "@welshman/domain"
|
||||||
|
import {DerivedPlugin, projection} from "./base.js"
|
||||||
|
import type {Projection} from "./base.js"
|
||||||
|
import {Network} from "./network.js"
|
||||||
|
import {Router} from "./router.js"
|
||||||
|
import {Thunks} from "./thunk.js"
|
||||||
|
import {User} from "../user.js"
|
||||||
|
import type {IApp} from "../app.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kind-0 profiles, keyed by pubkey. Loaded via the outbox model (the author's
|
||||||
|
* write relays), resolved through the relay-list collection at fetch time.
|
||||||
|
*/
|
||||||
|
export class Profiles extends DerivedPlugin<Profile> {
|
||||||
|
constructor(app: IApp) {
|
||||||
|
super(app, {
|
||||||
|
filters: [{kinds: [PROFILE]}],
|
||||||
|
eventToItem: Profile.factory(app.user?.signer),
|
||||||
|
getKey: profile => profile.author(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(pubkey: string, relayHints: string[] = []) {
|
||||||
|
return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [PROFILE]}, relayHints)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish the app user's kind-0, merging `values` over their current profile
|
||||||
|
// (preserving any unknown metadata fields and source tags).
|
||||||
|
publish = async (values: Record<string, any>) => {
|
||||||
|
const user = User.require(this.app)
|
||||||
|
const router = this.app.use(Router)
|
||||||
|
const relays = router.merge([router.Index(), router.FromUser()]).getUrls()
|
||||||
|
const builder = new ProfileBuilder(this.get(user.pubkey)).update(values)
|
||||||
|
const event = await builder.toTemplate()
|
||||||
|
|
||||||
|
return this.app.use(Thunks).publish({event, relays})
|
||||||
|
}
|
||||||
|
|
||||||
|
display = (pubkey: string | undefined, ...args: any[]): Projection<string> => {
|
||||||
|
const read = ($profile: Maybe<Profile>) =>
|
||||||
|
pubkey ? ($profile?.display() ?? displayPubkey(pubkey)) : ""
|
||||||
|
|
||||||
|
return projection(
|
||||||
|
pubkey ? derived(this.one(pubkey, ...args), read) : readable(""),
|
||||||
|
() => read(pubkey ? this.get(pubkey) : undefined),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import {RELAYS, RelayMode, getRelayTagValues} from "@welshman/util"
|
||||||
|
import {RelayList, RelayListBuilder} from "@welshman/domain"
|
||||||
|
import {DerivedPlugin} from "./base.js"
|
||||||
|
import type {Projection} from "./base.js"
|
||||||
|
import {addMinimalFallbacks} from "@welshman/router"
|
||||||
|
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<RelayList> {
|
||||||
|
constructor(app: IApp) {
|
||||||
|
super(app, {
|
||||||
|
filters: [{kinds: [RELAYS]}],
|
||||||
|
eventToItem: RelayList.factory(app.user?.signer),
|
||||||
|
getKey: (list: RelayList) => list.author(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 => list?.urls() ?? [])
|
||||||
|
|
||||||
|
readUrls = (pubkey: string): Projection<string[]> =>
|
||||||
|
this.project(pubkey, list => list?.readUrls() ?? [])
|
||||||
|
|
||||||
|
writeUrls = (pubkey: string): Projection<string[]> =>
|
||||||
|
this.project(pubkey, list => list?.writeUrls() ?? [])
|
||||||
|
|
||||||
|
// NIP-65 relay-list mutations for the app's user
|
||||||
|
|
||||||
|
update = async (fn: (builder: RelayListBuilder) => void) => {
|
||||||
|
const user = User.require(this.app)
|
||||||
|
const builder = new RelayListBuilder(await this.forceLoad(user.pubkey))
|
||||||
|
|
||||||
|
fn(builder)
|
||||||
|
|
||||||
|
const event = await builder.toTemplate(user.signer)
|
||||||
|
|
||||||
|
return this.app.use(Thunks).publishToOutbox({event})
|
||||||
|
}
|
||||||
|
|
||||||
|
addRelay = (url: string, mode: RelayMode) => this.update(builder => builder.addUrl(url, mode))
|
||||||
|
|
||||||
|
setReadRelays = (urls: string[]) => this.update(builder => builder.setReadUrls(urls))
|
||||||
|
|
||||||
|
setWriteRelays = (urls: string[]) => this.update(builder => builder.setWriteUrls(urls))
|
||||||
|
|
||||||
|
removeRelay = async (url: string, mode: RelayMode) => {
|
||||||
|
const user = User.require(this.app)
|
||||||
|
const builder = new RelayListBuilder(await this.forceLoad(user.pubkey))
|
||||||
|
const event = await builder.removeUrl(url, mode).toTemplate(user.signer)
|
||||||
|
|
||||||
|
// publishToOutbox is outbox-only, so build relays here to also notify the
|
||||||
|
// removed relay of its removal
|
||||||
|
const relays = [url, ...this.app.use(Router).FromUser().policy(addMinimalFallbacks).getUrls()]
|
||||||
|
|
||||||
|
return this.app.use(Thunks).publish({event, relays})
|
||||||
|
}
|
||||||
|
|
||||||
|
setRelays = async (tags: string[][]) => {
|
||||||
|
const user = User.require(this.app)
|
||||||
|
const router = this.app.use(Router)
|
||||||
|
const builder = new RelayListBuilder(await this.forceLoad(user.pubkey))
|
||||||
|
const event = await builder.setTags(tags).toTemplate(user.signer)
|
||||||
|
const relays = router
|
||||||
|
.merge([router.Index(), router.FromRelays(getRelayTagValues(tags))])
|
||||||
|
.getUrls()
|
||||||
|
|
||||||
|
return this.app.use(Thunks).publish({event, relays})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,75 @@
|
|||||||
|
import {
|
||||||
|
RoomCreateBuilder,
|
||||||
|
RoomDeleteBuilder,
|
||||||
|
RoomEditBuilder,
|
||||||
|
RoomJoinBuilder,
|
||||||
|
RoomLeaveBuilder,
|
||||||
|
RoomAddMemberBuilder,
|
||||||
|
RoomRemoveMemberBuilder,
|
||||||
|
} from "@welshman/domain"
|
||||||
|
import {Thunks} from "./thunk.js"
|
||||||
|
import type {ThunkOptions} from "./thunk.js"
|
||||||
|
import type {IApp} from "../app.js"
|
||||||
|
|
||||||
|
// Room metadata used when publishing NIP-29 room events. `h` is the group id.
|
||||||
|
export type RoomMeta = {
|
||||||
|
h: string
|
||||||
|
name?: string
|
||||||
|
about?: string
|
||||||
|
picture?: string
|
||||||
|
pictureMeta?: string[]
|
||||||
|
isClosed?: boolean
|
||||||
|
isHidden?: boolean
|
||||||
|
isPrivate?: boolean
|
||||||
|
isRestricted?: boolean
|
||||||
|
livekit?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-29 relay-based group (room) management. Each method publishes the relevant
|
||||||
|
* room event to the given relay as the app's user.
|
||||||
|
*/
|
||||||
|
export class Rooms {
|
||||||
|
constructor(readonly app: IApp) {}
|
||||||
|
|
||||||
|
private publish = (url: string, event: ThunkOptions["event"]) =>
|
||||||
|
this.app.use(Thunks).publish({event, relays: [url]})
|
||||||
|
|
||||||
|
create = async (url: string, room: RoomMeta) =>
|
||||||
|
this.publish(url, await new RoomCreateBuilder().setGroup(room.h).toTemplate())
|
||||||
|
|
||||||
|
delete = async (url: string, room: RoomMeta) =>
|
||||||
|
this.publish(url, await new RoomDeleteBuilder().setGroup(room.h).toTemplate())
|
||||||
|
|
||||||
|
edit = async (url: string, room: RoomMeta) => {
|
||||||
|
const builder = new RoomEditBuilder().setGroup(room.h)
|
||||||
|
|
||||||
|
if (room.name) builder.setName(room.name)
|
||||||
|
if (room.about) builder.setAbout(room.about)
|
||||||
|
if (room.picture) builder.setPicture(room.picture, room.pictureMeta)
|
||||||
|
|
||||||
|
builder
|
||||||
|
.setClosed(Boolean(room.isClosed))
|
||||||
|
.setHidden(Boolean(room.isHidden))
|
||||||
|
.setPrivate(Boolean(room.isPrivate))
|
||||||
|
.setRestricted(Boolean(room.isRestricted))
|
||||||
|
.setLivekit(Boolean(room.livekit))
|
||||||
|
|
||||||
|
return this.publish(url, await builder.toTemplate())
|
||||||
|
}
|
||||||
|
|
||||||
|
join = async (url: string, room: RoomMeta) =>
|
||||||
|
this.publish(url, await new RoomJoinBuilder().setGroup(room.h).toTemplate())
|
||||||
|
|
||||||
|
leave = async (url: string, room: RoomMeta) =>
|
||||||
|
this.publish(url, await new RoomLeaveBuilder().setGroup(room.h).toTemplate())
|
||||||
|
|
||||||
|
addMember = async (url: string, room: RoomMeta, pubkey: string) =>
|
||||||
|
this.publish(url, await new RoomAddMemberBuilder().setGroup(room.h).addPubkey(pubkey).toTemplate())
|
||||||
|
|
||||||
|
removeMember = async (url: string, room: RoomMeta, pubkey: string) =>
|
||||||
|
this.publish(
|
||||||
|
url,
|
||||||
|
await new RoomRemoveMemberBuilder().setGroup(room.h).addPubkey(pubkey).toTemplate(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,136 @@
|
|||||||
|
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 {RelayProfile} from "@welshman/util"
|
||||||
|
import type {Profile} from "@welshman/domain"
|
||||||
|
import {throttled} from "@welshman/store"
|
||||||
|
import type {IApp} from "../app.js"
|
||||||
|
import {Network} from "./network.js"
|
||||||
|
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, Profile>>
|
||||||
|
topicSearch: Readable<Search<string, Topic>>
|
||||||
|
relaySearch: Readable<Search<string, RelayProfile>>
|
||||||
|
|
||||||
|
constructor(readonly app: IApp) {
|
||||||
|
this.profileSearch = derived(
|
||||||
|
[throttled(800, this.app.use(Profiles).all.$), throttled(800, this.app.use(Handles).index.$)],
|
||||||
|
([$profiles, $handlesByNip05]) =>
|
||||||
|
createSearch($profiles, {
|
||||||
|
onSearch: this.searchProfiles,
|
||||||
|
getValue: (profile: Profile) => profile.author(),
|
||||||
|
sortFn: ({score = 1, item}) => {
|
||||||
|
const wotScore = this.app.use(Wot).graph.get().get(item.author()) || 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,
|
||||||
|
// Read fields off the domain reader's parsed `values`; only expose a
|
||||||
|
// nip05 that's verified against the loaded handle (anti-spoofing).
|
||||||
|
getFn: (profile: Profile, path) => {
|
||||||
|
const key = Array.isArray(path) ? path[0] : path
|
||||||
|
|
||||||
|
if (key === "nip05") {
|
||||||
|
const nip05 = profile.nip05()
|
||||||
|
|
||||||
|
return nip05 && $handlesByNip05.get(nip05)?.pubkey === profile.author()
|
||||||
|
? nip05
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return profile.values[key] ?? ""
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
this.topicSearch = derived(this.app.use(Topics).all, $topics =>
|
||||||
|
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,47 @@
|
|||||||
|
import {SEARCH_RELAYS} from "@welshman/util"
|
||||||
|
import {SearchRelayList, SearchRelayListBuilder} from "@welshman/domain"
|
||||||
|
import {DerivedPlugin} from "./base.js"
|
||||||
|
import type {Projection} from "./base.js"
|
||||||
|
import {Network} from "./network.js"
|
||||||
|
import {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<SearchRelayList> {
|
||||||
|
constructor(app: IApp) {
|
||||||
|
super(app, {
|
||||||
|
filters: [{kinds: [SEARCH_RELAYS]}],
|
||||||
|
eventToItem: SearchRelayList.factory(app.user?.signer),
|
||||||
|
getKey: list => list.author(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 => list?.urls() ?? [])
|
||||||
|
|
||||||
|
update = async (fn: (builder: SearchRelayListBuilder) => void) => {
|
||||||
|
const user = User.require(this.app)
|
||||||
|
const builder = new SearchRelayListBuilder(await this.forceLoad(user.pubkey))
|
||||||
|
|
||||||
|
fn(builder)
|
||||||
|
|
||||||
|
const event = await builder.toTemplate(user.signer)
|
||||||
|
|
||||||
|
return this.app.use(Thunks).publishToOutbox({event})
|
||||||
|
}
|
||||||
|
|
||||||
|
addUrl = (url: string) => this.update(builder => builder.addUrl(url))
|
||||||
|
|
||||||
|
removeUrl = (url: string) => this.update(builder => builder.removeUrl(url))
|
||||||
|
|
||||||
|
setUrls = (urls: string[]) => this.update(builder => builder.setUrls(urls))
|
||||||
|
}
|
||||||
@@ -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,192 @@
|
|||||||
|
import {readable, derived} from "svelte/store"
|
||||||
|
import {max, throttle, addToMapKey, inc, dec} from "@welshman/lib"
|
||||||
|
import type {FollowList, MuteList} from "@welshman/domain"
|
||||||
|
import type {IApp} from "../app.js"
|
||||||
|
import {projection, projectFrom} from "./base.js"
|
||||||
|
import type {Projection} from "./base.js"
|
||||||
|
import {FollowLists} from "./follows.js"
|
||||||
|
import {MuteLists} from "./mutes.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 list?.pubkeys() ?? []) {
|
||||||
|
addToMapKey($followersByPubkey, pubkey, list.author())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 list?.pubkeys() ?? []) {
|
||||||
|
addToMapKey($mutersByPubkey, pubkey, list.author())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
? ($followLists.get($pubkey)?.pubkeys() ?? [])
|
||||||
|
: Array.from($followLists.keys())
|
||||||
|
|
||||||
|
for (const follow of roots) {
|
||||||
|
for (const pubkey of $followLists.get(follow)?.pubkeys() ?? []) {
|
||||||
|
$graph.set(pubkey, inc($graph.get(pubkey)))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pubkey of $muteLists.get(follow)?.pubkeys() ?? []) {
|
||||||
|
$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 => $lists.get(pubkey)?.pubkeys() ?? [])
|
||||||
|
|
||||||
|
mutes = (pubkey: string): Projection<string[]> =>
|
||||||
|
projectFrom(this.app.use(MuteLists).index, $lists => $lists.get(pubkey)?.pubkeys() ?? [])
|
||||||
|
|
||||||
|
network = (pubkey: string): Projection<string[]> =>
|
||||||
|
projectFrom(this.app.use(FollowLists).index, $lists => {
|
||||||
|
const pubkeys = new Set($lists.get(pubkey)?.pubkeys() ?? [])
|
||||||
|
const network = new Set<string>()
|
||||||
|
|
||||||
|
for (const follow of pubkeys) {
|
||||||
|
for (const tpk of $lists.get(follow)?.pubkeys() ?? []) {
|
||||||
|
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 =>
|
||||||
|
($lists.get(pubkey)?.pubkeys() ?? []).filter(other =>
|
||||||
|
($lists.get(other)?.pubkeys() ?? []).includes(target),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
followsWhoMute = (pubkey: string, target: string): Projection<string[]> => {
|
||||||
|
const read = (
|
||||||
|
$follows: ReadonlyMap<string, FollowList>,
|
||||||
|
$mutes: ReadonlyMap<string, MuteList>,
|
||||||
|
) =>
|
||||||
|
($follows.get(pubkey)?.pubkeys() ?? []).filter(other =>
|
||||||
|
($mutes.get(other)?.pubkeys() ?? []).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, FollowList>,
|
||||||
|
$mutes: ReadonlyMap<string, MuteList>,
|
||||||
|
$followers: ReadonlyMap<string, Set<string>>,
|
||||||
|
$muters: ReadonlyMap<string, Set<string>>,
|
||||||
|
) => {
|
||||||
|
let follows: string[]
|
||||||
|
let mutes: string[]
|
||||||
|
|
||||||
|
if (pubkey) {
|
||||||
|
const theirFollows = $follows.get(pubkey)?.pubkeys() ?? []
|
||||||
|
|
||||||
|
follows = theirFollows.filter(other => ($follows.get(other)?.pubkeys() ?? []).includes(target))
|
||||||
|
mutes = theirFollows.filter(other => ($mutes.get(other)?.pubkeys() ?? []).includes(target))
|
||||||
|
} else {
|
||||||
|
follows = Array.from($followers.get(target) || [])
|
||||||
|
mutes = Array.from($muters.get(target) || [])
|
||||||
|
}
|
||||||
|
|
||||||
|
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, 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 = (await this.app.use(MessagingRelayLists).load(recipient))?.urls() ?? []
|
||||||
|
|
||||||
|
return this.app.use(Thunks).publish({event: stableEvent, relays, recipient, ...options})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
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 type {Profile} from "@welshman/domain"
|
||||||
|
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)
|
||||||
|
|
||||||
|
const lnurl = $profile?.lnurl()
|
||||||
|
|
||||||
|
return lnurl ? this.load(lnurl) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
forPubkey = (pubkey: string, relays: string[] = []): Projection<Maybe<Zapper>> => {
|
||||||
|
this.loadForPubkey(pubkey, relays)
|
||||||
|
|
||||||
|
const read = ([$zappersByLnurl, $profile]: [ReadonlyMap<string, Zapper>, Maybe<Profile>]) => {
|
||||||
|
const lnurl = $profile?.lnurl()
|
||||||
|
|
||||||
|
return lnurl ? $zappersByLnurl.get(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<Profile | 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 {Client as PomadeClient, PomadeSigner} from "@pomade/core"
|
||||||
import {derived, writable} from "svelte/store"
|
import type {ClientOptions as PomadeClientOptions} from "@pomade/core"
|
||||||
import {cached, randomId, append, omit, equals, assoc, TaskQueue} from "@welshman/lib"
|
import type {MaybeAsync} from "@welshman/lib"
|
||||||
import {withGetter} from "@welshman/store"
|
import {Nip46Broker, Nip46Signer, Nip07Signer, Nip01Signer, Nip55Signer} from "@welshman/signer"
|
||||||
import {
|
import type {ISigner} from "@welshman/signer"
|
||||||
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"
|
|
||||||
|
|
||||||
export enum SessionMethod {
|
// ── Sessions: serializable {method, data} descriptors ──
|
||||||
Nip01 = "nip01",
|
|
||||||
Nip07 = "nip07",
|
export type Session<M extends string = string, D = unknown> = {
|
||||||
Nip46 = "nip46",
|
method: M
|
||||||
Nip55 = "nip55",
|
data: D
|
||||||
Pomade = "pomade",
|
|
||||||
Pubkey = "pubkey",
|
|
||||||
Anonymous = "anonymous",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionNip01 = {
|
// ── Session handlers: a method string, its data shape, and how to build a signer ──
|
||||||
method: SessionMethod.Nip01
|
|
||||||
pubkey: string
|
export type SessionHandler<M extends string, D> = {
|
||||||
secret: string
|
method: M
|
||||||
|
getSigner: (data: D) => MaybeAsync<ISigner>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionNip07 = {
|
/**
|
||||||
method: SessionMethod.Nip07
|
* Define a session handler. `M` and `D` are inferred from the arguments, so
|
||||||
pubkey: string
|
* `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 = {
|
/** Build a typed, serializable session from a handler and its data. */
|
||||||
method: SessionMethod.Nip46
|
export const toSession = <M extends string, D>(
|
||||||
pubkey: string
|
handler: SessionHandler<M, D>,
|
||||||
secret: string
|
data: D,
|
||||||
handler: {
|
): Session<M, D> => ({method: handler.method, data})
|
||||||
pubkey: string
|
|
||||||
relays: string[]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SessionNip55 = {
|
// ── Built-in handlers ──
|
||||||
method: SessionMethod.Nip55
|
|
||||||
pubkey: string
|
|
||||||
signer: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SessionPomade = {
|
export const nip01 = defineSessionHandler({
|
||||||
method: SessionMethod.Pomade
|
method: "nip01",
|
||||||
pubkey: string
|
getSigner: (data: {secret: string}) => new Nip01Signer(data.secret),
|
||||||
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 makeNip07Session = (pubkey: string): SessionNip07 => ({
|
export const nip07 = defineSessionHandler({
|
||||||
method: SessionMethod.Nip07,
|
method: "nip07",
|
||||||
pubkey,
|
getSigner: (_data: Record<string, never>) => new Nip07Signer(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const makeNip46Session = (
|
export const nip46 = defineSessionHandler({
|
||||||
pubkey: string,
|
method: "nip46",
|
||||||
clientSecret: string,
|
getSigner: (data: {clientSecret: string; signerPubkey: string; relays: string[]}) =>
|
||||||
signerPubkey: string,
|
new Nip46Signer(new Nip46Broker(data)),
|
||||||
relays: string[],
|
|
||||||
): SessionNip46 => ({
|
|
||||||
method: SessionMethod.Nip46,
|
|
||||||
pubkey,
|
|
||||||
secret: clientSecret,
|
|
||||||
handler: {pubkey: signerPubkey, relays},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const makeNip55Session = (pubkey: string, signer: string): SessionNip55 => ({
|
export const nip55 = defineSessionHandler({
|
||||||
method: SessionMethod.Nip55,
|
method: "nip55",
|
||||||
pubkey,
|
getSigner: (data: {pubkey: string; signer: string}) => new Nip55Signer(data.signer, data.pubkey),
|
||||||
signer,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const makePomadeSession = (
|
export const pomade = defineSessionHandler({
|
||||||
pubkey: string,
|
method: "pomade",
|
||||||
email: string,
|
getSigner: (data: {clientOptions: PomadeClientOptions; email: string}) =>
|
||||||
clientOptions: ClientOptions,
|
new PomadeSigner(new PomadeClient(data.clientOptions)),
|
||||||
): SessionPomade => ({
|
|
||||||
method: SessionMethod.Pomade,
|
|
||||||
pubkey,
|
|
||||||
clientOptions,
|
|
||||||
email,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const makePubkeySession = (pubkey: string): SessionPubkey => ({
|
// ── Registry: deserialize a stored session back into a signer ──
|
||||||
method: SessionMethod.Pubkey,
|
|
||||||
pubkey,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Type guards
|
export const sessionHandlers = new Map<string, SessionHandler<string, any>>()
|
||||||
|
|
||||||
export const isNip01Session = (session?: Session): session is SessionNip01 =>
|
export const registerSessionHandler = (handler: SessionHandler<string, any>) => {
|
||||||
session?.method === SessionMethod.Nip01
|
sessionHandlers.set(handler.method, handler)
|
||||||
|
|
||||||
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 signerLog = withGetter(writable<SignerLogEntry[]>([]))
|
export const unregisterSessionHandler = (handler: SessionHandler<string, any>) => {
|
||||||
|
sessionHandlers.delete(handler.method)
|
||||||
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 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) => {
|
for (const sessionHandler of [nip01, nip07, nip46, nip55, pomade]) {
|
||||||
const $pubkey = pubkey.get()
|
registerSessionHandler(sessionHandler)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
+46
-83
@@ -1,94 +1,57 @@
|
|||||||
import {derived, Readable} from "svelte/store"
|
import type {StampedEvent} from "@welshman/util"
|
||||||
import {ItemsByKey, deriveDeduplicated} from "@welshman/store"
|
import type {ISigner} from "@welshman/signer"
|
||||||
import {pubkey} from "./session.js"
|
import {LoggingSigner} from "./logging.js"
|
||||||
import {profilesByPubkey, forceLoadProfile, loadProfile} from "./profiles.js"
|
import {getSignerFromSession} from "./session.js"
|
||||||
import {followListsByPubkey, forceLoadFollowList, loadFollowList} from "./follows.js"
|
import type {Session} from "./session.js"
|
||||||
import {pinListsByPubkey, forceLoadPinList, loadPinList} from "./pins.js"
|
import type {IApp} from "./app.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"
|
|
||||||
|
|
||||||
export const makeUserData = <T>(
|
/**
|
||||||
itemsByKey: Readable<ItemsByKey<T>>,
|
* A single identity: a pubkey plus the signer that proves it. An `App` is
|
||||||
onDerive?: (key: string, ...args: any[]) => void,
|
* centered on (at most) one `User`, since the data a user can access depends
|
||||||
) =>
|
* entirely on who they are.
|
||||||
deriveDeduplicated([itemsByKey, pubkey], ([$itemsByKey, $pubkey]) => {
|
*/
|
||||||
if (!$pubkey) return undefined
|
export class User {
|
||||||
|
constructor(
|
||||||
|
readonly pubkey: string,
|
||||||
|
readonly signer: ISigner,
|
||||||
|
) {}
|
||||||
|
|
||||||
onDerive?.($pubkey)
|
static async fromSigner(signer: ISigner) {
|
||||||
|
if (!(signer instanceof LoggingSigner)) {
|
||||||
return $itemsByKey.get($pubkey)
|
signer = new LoggingSigner(signer)
|
||||||
})
|
|
||||||
|
|
||||||
export const makeUserLoader =
|
|
||||||
(loadItem: (key: string, ...args: any[]) => void) =>
|
|
||||||
async (...args: any[]) => {
|
|
||||||
const $pubkey = pubkey.get()
|
|
||||||
|
|
||||||
if ($pubkey) {
|
|
||||||
await loadItem($pubkey, ...args)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const userProfile = makeUserData(profilesByPubkey, loadProfile)
|
const pubkey = await signer.getPubkey()
|
||||||
export const forceLoadUserProfile = makeUserLoader(forceLoadProfile)
|
|
||||||
export const loadUserProfile = makeUserLoader(loadProfile)
|
|
||||||
|
|
||||||
export const userFollowList = makeUserData(followListsByPubkey, loadFollowList)
|
return new User(pubkey, signer)
|
||||||
export const forceLoadUserFollowList = makeUserLoader(forceLoadFollowList)
|
}
|
||||||
export const loadUserFollowList = makeUserLoader(loadFollowList)
|
|
||||||
|
|
||||||
export const userMuteList = makeUserData(muteListsByPubkey, loadMuteList)
|
/**
|
||||||
export const forceLoadUserMuteList = makeUserLoader(forceLoadMuteList)
|
* Reconstruct a signing user from a persisted session, using the registered
|
||||||
export const loadUserMuteList = makeUserLoader(loadMuteList)
|
* 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 userPinList = makeUserData(pinListsByPubkey, loadPinList)
|
return signer ? User.fromSigner(signer) : undefined
|
||||||
export const forceLoadUserPinList = makeUserLoader(forceLoadPinList)
|
}
|
||||||
export const loadUserPinList = makeUserLoader(loadPinList)
|
|
||||||
|
|
||||||
export const userRelayList = makeUserData(relayListsByPubkey, loadRelayList)
|
/**
|
||||||
export const forceLoadUserRelayList = makeUserLoader(forceLoadRelayList)
|
* Return the app's signed-in user, throwing if there isn't one — the entry
|
||||||
export const loadUserRelayList = makeUserLoader(loadRelayList)
|
* 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 userMessagingRelayList = makeUserData(
|
return app.user
|
||||||
messagingRelayListsByPubkey,
|
}
|
||||||
loadMessagingRelayList,
|
|
||||||
)
|
|
||||||
export const forceLoadUserMessagingRelayList = makeUserLoader(forceLoadMessagingRelayList)
|
|
||||||
export const loadUserMessagingRelayList = makeUserLoader(loadMessagingRelayList)
|
|
||||||
|
|
||||||
export const userSearchRelayList = makeUserData(searchRelayListsByPubkey, loadSearchRelayList)
|
sign = (event: StampedEvent) => this.signer.sign(event)
|
||||||
export const forceLoadUserSearchRelayList = makeUserLoader(forceLoadSearchRelayList)
|
|
||||||
export const loadUserSearchRelayList = makeUserLoader(loadSearchRelayList)
|
|
||||||
|
|
||||||
export const userBlockedRelayList = makeUserData(blockedRelayListsByPubkey, loadBlockedRelayList)
|
nip44EncryptToSelf = (payload: string) => this.signer.nip44.encrypt(this.pubkey, payload)
|
||||||
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)
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -4,9 +4,11 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
"@welshman/domain": ["../domain/src/index.js"],
|
||||||
"@welshman/feeds": ["../feeds/src/index.js"],
|
"@welshman/feeds": ["../feeds/src/index.js"],
|
||||||
"@welshman/lib": ["../lib/src/index.js"],
|
"@welshman/lib": ["../lib/src/index.js"],
|
||||||
"@welshman/net": ["../net/src/index.js"],
|
"@welshman/net": ["../net/src/index.js"],
|
||||||
|
"@welshman/router": ["../router/src/index.js"],
|
||||||
"@welshman/signer": ["../signer/src/index.js"],
|
"@welshman/signer": ["../signer/src/index.js"],
|
||||||
"@welshman/store": ["../store/src/index.js"],
|
"@welshman/store": ["../store/src/index.js"],
|
||||||
"@welshman/util": ["../util/src/index.js"]
|
"@welshman/util": ["../util/src/index.js"]
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
build
|
||||||
|
__tests__
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import {describe, it, expect} from "vitest"
|
||||||
|
import {makeSecret, BLOCKED_RELAYS, NOTE, getTagValues, normalizeRelayUrl} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {Nip01Signer} from "@welshman/signer"
|
||||||
|
import {BlockedRelayList, BlockedRelayListBuilder} from "../src/kinds/BlockedRelayList"
|
||||||
|
|
||||||
|
const signer = new Nip01Signer(makeSecret())
|
||||||
|
const pubkey = "ee".repeat(32)
|
||||||
|
|
||||||
|
const r1 = "wss://relay.one.example/"
|
||||||
|
const r2 = "wss://relay.two.example/"
|
||||||
|
const r3 = "wss://relay.three.example/"
|
||||||
|
|
||||||
|
const makeEvent = (o: Partial<TrustedEvent> = {}): TrustedEvent =>
|
||||||
|
({
|
||||||
|
id: "ff".repeat(32),
|
||||||
|
pubkey,
|
||||||
|
created_at: 0,
|
||||||
|
kind: BLOCKED_RELAYS,
|
||||||
|
tags: [],
|
||||||
|
content: "",
|
||||||
|
sig: "00".repeat(64),
|
||||||
|
...o,
|
||||||
|
}) as TrustedEvent
|
||||||
|
|
||||||
|
describe("BlockedRelayList", () => {
|
||||||
|
it("reads relay urls from relay tags", async () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
tags: [
|
||||||
|
["relay", r1],
|
||||||
|
["relay", r2],
|
||||||
|
["alt", "x"],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = await BlockedRelayList.fromEvent(event)
|
||||||
|
|
||||||
|
expect(list.urls().sort()).toEqual([r1, r2].sort())
|
||||||
|
expect(list.includes(r1)).toBe(true)
|
||||||
|
expect(list.includes(r3)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("round-trips without duplicating tags and preserves passthrough", async () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
tags: [
|
||||||
|
["relay", r1],
|
||||||
|
["relay", r2],
|
||||||
|
["alt", "x"],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = await BlockedRelayList.fromEvent(event)
|
||||||
|
const tmpl = await list.builder().toTemplate(signer)
|
||||||
|
|
||||||
|
expect(tmpl.kind).toBe(BLOCKED_RELAYS)
|
||||||
|
expect(tmpl.tags.filter(t => t[0] === "relay").length).toBe(2)
|
||||||
|
expect(tmpl.tags).toContainEqual(["alt", "x"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("builds from a fresh builder and normalizes urls", async () => {
|
||||||
|
const tmpl = await new BlockedRelayListBuilder()
|
||||||
|
.addUrl("wss://relay.one.example")
|
||||||
|
.toTemplate(signer)
|
||||||
|
|
||||||
|
expect(getTagValues("relay", tmpl.tags)).toEqual([normalizeRelayUrl("wss://relay.one.example")])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("setRelays replaces existing relays", async () => {
|
||||||
|
const event = makeEvent({tags: [["relay", r1]]})
|
||||||
|
const list = await BlockedRelayList.fromEvent(event)
|
||||||
|
|
||||||
|
const tmpl = await list.builder().setUrls([r2, r3]).toTemplate(signer)
|
||||||
|
|
||||||
|
expect(getTagValues("relay", tmpl.tags).sort()).toEqual([r2, r3].sort())
|
||||||
|
})
|
||||||
|
|
||||||
|
it("round-trips public and private entries through encryption", async () => {
|
||||||
|
const event = await new BlockedRelayListBuilder()
|
||||||
|
.addUrl(r1)
|
||||||
|
.addPrivate(["relay", r2])
|
||||||
|
.toEvent(signer)
|
||||||
|
|
||||||
|
expect(event.kind).toBe(BLOCKED_RELAYS)
|
||||||
|
expect(getTagValues("relay", event.tags)).toEqual([r1])
|
||||||
|
expect(event.content).not.toBe("")
|
||||||
|
|
||||||
|
const decrypted = await BlockedRelayList.fromEvent(event, signer)
|
||||||
|
|
||||||
|
expect(decrypted.decrypted).toBe(true)
|
||||||
|
expect(decrypted.urls().sort()).toEqual([r1, r2].sort())
|
||||||
|
|
||||||
|
const publicOnly = await BlockedRelayList.fromEvent(event)
|
||||||
|
|
||||||
|
expect(publicOnly.decrypted).toBe(false)
|
||||||
|
expect(publicOnly.urls()).toEqual([r1])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves undecrypted ciphertext on pass-through", async () => {
|
||||||
|
const event = await new BlockedRelayListBuilder().addPrivate(["relay", r2]).toEvent(signer)
|
||||||
|
const undecrypted = await BlockedRelayList.fromEvent(event)
|
||||||
|
|
||||||
|
const tmpl = await undecrypted.builder().toTemplate(signer)
|
||||||
|
|
||||||
|
expect(tmpl.content).toBe(event.content)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws on the wrong kind", async () => {
|
||||||
|
await expect(BlockedRelayList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import {describe, it, expect} from "vitest"
|
||||||
|
import {makeSecret, BLOSSOM_SERVERS, NOTE, getTagValues, normalizeRelayUrl} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {Nip01Signer} from "@welshman/signer"
|
||||||
|
import {BlossomServerList, BlossomServerListBuilder} from "../src/kinds/BlossomServerList"
|
||||||
|
|
||||||
|
const signer = new Nip01Signer(makeSecret())
|
||||||
|
const pubkey = "ee".repeat(32)
|
||||||
|
|
||||||
|
const s1 = "https://blossom.one.example/"
|
||||||
|
const s2 = "https://blossom.two.example/"
|
||||||
|
const s3 = "https://blossom.three.example/"
|
||||||
|
|
||||||
|
const norm = (url: string) => normalizeRelayUrl(url)
|
||||||
|
|
||||||
|
const makeEvent = (o: Partial<TrustedEvent> = {}): TrustedEvent =>
|
||||||
|
({
|
||||||
|
id: "ff".repeat(32),
|
||||||
|
pubkey,
|
||||||
|
created_at: 0,
|
||||||
|
kind: BLOSSOM_SERVERS,
|
||||||
|
tags: [],
|
||||||
|
content: "",
|
||||||
|
sig: "00".repeat(64),
|
||||||
|
...o,
|
||||||
|
}) as TrustedEvent
|
||||||
|
|
||||||
|
describe("BlossomServerList", () => {
|
||||||
|
it("reads server urls from server tags", async () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
tags: [
|
||||||
|
["server", s1],
|
||||||
|
["server", s2],
|
||||||
|
["alt", "x"],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = await BlossomServerList.fromEvent(event)
|
||||||
|
|
||||||
|
expect(list.urls().sort()).toEqual([norm(s1), norm(s2)].sort())
|
||||||
|
expect(list.includes(s1)).toBe(true)
|
||||||
|
expect(list.includes(s3)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("round-trips without duplicating tags and preserves passthrough", async () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
tags: [
|
||||||
|
["server", s1],
|
||||||
|
["server", s2],
|
||||||
|
["alt", "x"],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = await BlossomServerList.fromEvent(event)
|
||||||
|
const tmpl = await list.builder().toTemplate(signer)
|
||||||
|
|
||||||
|
expect(tmpl.kind).toBe(BLOSSOM_SERVERS)
|
||||||
|
expect(tmpl.tags.filter(t => t[0] === "server").length).toBe(2)
|
||||||
|
expect(tmpl.tags).toContainEqual(["alt", "x"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("builds from a fresh builder and normalizes urls", async () => {
|
||||||
|
const tmpl = await new BlossomServerListBuilder().addUrl(s1).toTemplate(signer)
|
||||||
|
|
||||||
|
expect(getTagValues("server", tmpl.tags)).toEqual([norm(s1)])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("setServers replaces existing servers", async () => {
|
||||||
|
const event = makeEvent({tags: [["server", s1]]})
|
||||||
|
const list = await BlossomServerList.fromEvent(event)
|
||||||
|
|
||||||
|
const tmpl = await list.builder().setUrls([s2, s3]).toTemplate(signer)
|
||||||
|
|
||||||
|
expect(getTagValues("server", tmpl.tags).sort()).toEqual([norm(s2), norm(s3)].sort())
|
||||||
|
})
|
||||||
|
|
||||||
|
it("round-trips public and private entries through encryption", async () => {
|
||||||
|
const event = await new BlossomServerListBuilder()
|
||||||
|
.addUrl(s1)
|
||||||
|
.addPrivate(["server", norm(s2)])
|
||||||
|
.toEvent(signer)
|
||||||
|
|
||||||
|
expect(getTagValues("server", event.tags)).toEqual([norm(s1)])
|
||||||
|
expect(event.content).not.toBe("")
|
||||||
|
|
||||||
|
const decrypted = await BlossomServerList.fromEvent(event, signer)
|
||||||
|
|
||||||
|
expect(decrypted.decrypted).toBe(true)
|
||||||
|
expect(decrypted.urls().sort()).toEqual([norm(s1), norm(s2)].sort())
|
||||||
|
|
||||||
|
const publicOnly = await BlossomServerList.fromEvent(event)
|
||||||
|
|
||||||
|
expect(publicOnly.decrypted).toBe(false)
|
||||||
|
expect(publicOnly.urls()).toEqual([norm(s1)])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws on the wrong kind", async () => {
|
||||||
|
await expect(BlossomServerList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import {describe, it, expect} from "vitest"
|
||||||
|
import {
|
||||||
|
makeSecret,
|
||||||
|
BOOKMARKS,
|
||||||
|
NOTE,
|
||||||
|
getEventTagValues,
|
||||||
|
getTopicTagValues,
|
||||||
|
} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {Nip01Signer} from "@welshman/signer"
|
||||||
|
import {BookmarkList, BookmarkListBuilder} from "../src/kinds/BookmarkList"
|
||||||
|
|
||||||
|
const signer = new Nip01Signer(makeSecret())
|
||||||
|
const pubkey = "ee".repeat(32)
|
||||||
|
|
||||||
|
const noteId = "11".repeat(32)
|
||||||
|
const noteId2 = "22".repeat(32)
|
||||||
|
const address = `30023:${"aa".repeat(32)}:article-1`
|
||||||
|
const url = "https://example.com/post"
|
||||||
|
|
||||||
|
const makeEvent = (o: Partial<TrustedEvent> = {}): TrustedEvent =>
|
||||||
|
({
|
||||||
|
id: "ff".repeat(32),
|
||||||
|
pubkey,
|
||||||
|
created_at: 0,
|
||||||
|
kind: BOOKMARKS,
|
||||||
|
tags: [],
|
||||||
|
content: "",
|
||||||
|
sig: "00".repeat(64),
|
||||||
|
...o,
|
||||||
|
}) as TrustedEvent
|
||||||
|
|
||||||
|
describe("BookmarkList", () => {
|
||||||
|
it("reads mixed bookmark entries", async () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
tags: [
|
||||||
|
["e", noteId],
|
||||||
|
["a", address],
|
||||||
|
["t", "nostr"],
|
||||||
|
["r", url],
|
||||||
|
["alt", "x"],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = await BookmarkList.fromEvent(event)
|
||||||
|
|
||||||
|
expect(list.ids()).toEqual([noteId])
|
||||||
|
expect(list.addresses()).toEqual([address])
|
||||||
|
expect(list.topics()).toEqual(["nostr"])
|
||||||
|
expect(list.urls()).toEqual([url])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("round-trips without duplicating tags and preserves passthrough", async () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
tags: [
|
||||||
|
["e", noteId],
|
||||||
|
["a", address],
|
||||||
|
["t", "nostr"],
|
||||||
|
["r", url],
|
||||||
|
["alt", "x"],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = await BookmarkList.fromEvent(event)
|
||||||
|
const tmpl = await list.builder().toTemplate(signer)
|
||||||
|
|
||||||
|
expect(tmpl.kind).toBe(BOOKMARKS)
|
||||||
|
expect(tmpl.tags.filter(t => t[0] === "e").length).toBe(1)
|
||||||
|
expect(tmpl.tags.filter(t => t[0] === "a").length).toBe(1)
|
||||||
|
expect(tmpl.tags.filter(t => t[0] === "t").length).toBe(1)
|
||||||
|
expect(tmpl.tags.filter(t => t[0] === "r").length).toBe(1)
|
||||||
|
expect(tmpl.tags).toContainEqual(["alt", "x"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("builds from a fresh builder", async () => {
|
||||||
|
const tmpl = await new BookmarkListBuilder()
|
||||||
|
.bookmarkPublicly(["e", noteId])
|
||||||
|
.bookmarkPublicly(["t", "nostr"])
|
||||||
|
.toTemplate(signer)
|
||||||
|
|
||||||
|
expect(getEventTagValues(tmpl.tags)).toEqual([noteId])
|
||||||
|
expect(getTopicTagValues(tmpl.tags)).toEqual(["nostr"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("removeBookmark removes by value", async () => {
|
||||||
|
const event = makeEvent({tags: [["e", noteId], ["e", noteId2]]})
|
||||||
|
const list = await BookmarkList.fromEvent(event)
|
||||||
|
|
||||||
|
const tmpl = await list.builder().removeBookmark(noteId).toTemplate(signer)
|
||||||
|
|
||||||
|
expect(getEventTagValues(tmpl.tags)).toEqual([noteId2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("round-trips public and private bookmarks through encryption", async () => {
|
||||||
|
const event = await new BookmarkListBuilder()
|
||||||
|
.bookmarkPublicly(["e", noteId])
|
||||||
|
.bookmarkPrivately(["e", noteId2])
|
||||||
|
.toEvent(signer)
|
||||||
|
|
||||||
|
expect(getEventTagValues(event.tags)).toEqual([noteId])
|
||||||
|
expect(event.content).not.toBe("")
|
||||||
|
|
||||||
|
const decrypted = await BookmarkList.fromEvent(event, signer)
|
||||||
|
|
||||||
|
expect(decrypted.decrypted).toBe(true)
|
||||||
|
expect(decrypted.ids().sort()).toEqual([noteId, noteId2].sort())
|
||||||
|
|
||||||
|
const publicOnly = await BookmarkList.fromEvent(event)
|
||||||
|
|
||||||
|
expect(publicOnly.decrypted).toBe(false)
|
||||||
|
expect(publicOnly.ids()).toEqual([noteId])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves undecrypted ciphertext on pass-through", async () => {
|
||||||
|
const event = await new BookmarkListBuilder().bookmarkPrivately(["e", noteId2]).toEvent(signer)
|
||||||
|
const undecrypted = await BookmarkList.fromEvent(event)
|
||||||
|
|
||||||
|
const tmpl = await undecrypted.builder().toTemplate(signer)
|
||||||
|
|
||||||
|
expect(tmpl.content).toBe(event.content)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("refuses private mutation when undecrypted", async () => {
|
||||||
|
const event = await new BookmarkListBuilder().bookmarkPrivately(["e", noteId2]).toEvent(signer)
|
||||||
|
const undecrypted = await BookmarkList.fromEvent(event)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
undecrypted.builder().bookmarkPrivately(["e", noteId]).toEvent(signer),
|
||||||
|
).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws on the wrong kind", async () => {
|
||||||
|
await expect(BookmarkList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import {describe, it, expect} from "vitest"
|
||||||
|
import {makeSecret, CLASSIFIED, NOTE} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {Nip01Signer} from "@welshman/signer"
|
||||||
|
import {Classified, ClassifiedBuilder} from "../src/kinds/Classified"
|
||||||
|
|
||||||
|
const signer = new Nip01Signer(makeSecret())
|
||||||
|
const pubkey = "ee".repeat(32)
|
||||||
|
|
||||||
|
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
|
||||||
|
({
|
||||||
|
id: "ff".repeat(32),
|
||||||
|
pubkey,
|
||||||
|
created_at: 0,
|
||||||
|
kind: CLASSIFIED,
|
||||||
|
tags: [],
|
||||||
|
content: "",
|
||||||
|
sig: "00".repeat(64),
|
||||||
|
...overrides,
|
||||||
|
}) as TrustedEvent
|
||||||
|
|
||||||
|
describe("Classified", () => {
|
||||||
|
it("reads represented tags and content", async () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
content: "for sale",
|
||||||
|
tags: [
|
||||||
|
["d", "abc"],
|
||||||
|
["title", "Bike"],
|
||||||
|
["summary", "A good bike"],
|
||||||
|
["price", "100", "USD"],
|
||||||
|
["status", "active"],
|
||||||
|
["image", "https://example.com/a.jpg"],
|
||||||
|
["image", "https://example.com/b.jpg"],
|
||||||
|
["t", "cycling"],
|
||||||
|
["alt", "x"],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const c = await Classified.fromEvent(event)
|
||||||
|
|
||||||
|
expect(c.identifier()).toBe("abc")
|
||||||
|
expect(c.title()).toBe("Bike")
|
||||||
|
expect(c.summary()).toBe("A good bike")
|
||||||
|
expect(c.price()).toEqual({amount: 100, currency: "USD", frequency: ""})
|
||||||
|
expect(c.status()).toBe("active")
|
||||||
|
expect(c.images()).toEqual(["https://example.com/a.jpg", "https://example.com/b.jpg"])
|
||||||
|
expect(c.topics()).toEqual(["cycling"])
|
||||||
|
expect(c.content()).toBe("for sale")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("defaults the price currency to SAT", async () => {
|
||||||
|
const c = await Classified.fromEvent(makeEvent({tags: [["d", "x"], ["price", "50"]]}))
|
||||||
|
|
||||||
|
expect(c.price()).toEqual({amount: 50, currency: "SAT", frequency: ""})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("round-trips with no duplicate represented tags", async () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
content: "for sale",
|
||||||
|
tags: [
|
||||||
|
["d", "abc"],
|
||||||
|
["title", "Bike"],
|
||||||
|
["summary", "A good bike"],
|
||||||
|
["price", "100", "USD"],
|
||||||
|
["status", "active"],
|
||||||
|
["image", "https://example.com/a.jpg"],
|
||||||
|
["image", "https://example.com/b.jpg"],
|
||||||
|
["t", "cycling"],
|
||||||
|
["alt", "x"],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const tmpl = await (await Classified.fromEvent(event)).builder().toTemplate(signer)
|
||||||
|
|
||||||
|
for (const key of ["d", "title", "summary", "price", "status"]) {
|
||||||
|
expect(tmpl.tags.filter(t => t[0] === key).length).toBe(1)
|
||||||
|
}
|
||||||
|
expect(tmpl.tags.filter(t => t[0] === "image").length).toBe(2)
|
||||||
|
expect(tmpl.tags.filter(t => t[0] === "t").length).toBe(1)
|
||||||
|
expect(tmpl.tags).toContainEqual(["d", "abc"])
|
||||||
|
expect(tmpl.tags).toContainEqual(["price", "100", "USD"])
|
||||||
|
// Unknown passthrough tag survives.
|
||||||
|
expect(tmpl.tags).toContainEqual(["alt", "x"])
|
||||||
|
expect(tmpl.content).toBe("for sale")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("builds from a fresh builder", async () => {
|
||||||
|
const tmpl = await new ClassifiedBuilder()
|
||||||
|
.setIdentifier("listing1")
|
||||||
|
.setTitle("Fresh")
|
||||||
|
.setContent("desc")
|
||||||
|
.setPrice(25)
|
||||||
|
.setImages(["https://example.com/c.jpg"])
|
||||||
|
.setTopics(["misc"])
|
||||||
|
.toTemplate(signer)
|
||||||
|
|
||||||
|
expect(tmpl.kind).toBe(CLASSIFIED)
|
||||||
|
expect(tmpl.tags).toContainEqual(["d", "listing1"])
|
||||||
|
expect(tmpl.tags).toContainEqual(["title", "Fresh"])
|
||||||
|
expect(tmpl.tags).toContainEqual(["price", "25", "SAT"])
|
||||||
|
expect(tmpl.tags).toContainEqual(["image", "https://example.com/c.jpg"])
|
||||||
|
expect(tmpl.tags).toContainEqual(["t", "misc"])
|
||||||
|
expect(tmpl.content).toBe("desc")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws on the wrong kind", async () => {
|
||||||
|
await expect(Classified.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import {describe, it, expect} from "vitest"
|
||||||
|
import {makeSecret, COMMENT, NOTE} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {Nip01Signer} from "@welshman/signer"
|
||||||
|
import {Comment, CommentBuilder} from "../src/kinds/Comment"
|
||||||
|
|
||||||
|
const signer = new Nip01Signer(makeSecret())
|
||||||
|
const pubkey = "ee".repeat(32)
|
||||||
|
const rootId = "aa".repeat(32)
|
||||||
|
const rootPubkey = "bb".repeat(32)
|
||||||
|
const parentId = "cc".repeat(32)
|
||||||
|
const parentPubkey = "dd".repeat(32)
|
||||||
|
|
||||||
|
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
|
||||||
|
({
|
||||||
|
id: "ff".repeat(32),
|
||||||
|
pubkey,
|
||||||
|
created_at: 0,
|
||||||
|
kind: COMMENT,
|
||||||
|
tags: [],
|
||||||
|
content: "",
|
||||||
|
sig: "00".repeat(64),
|
||||||
|
...overrides,
|
||||||
|
}) as TrustedEvent
|
||||||
|
|
||||||
|
describe("Comment", () => {
|
||||||
|
it("reads root and parent references", async () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
content: "nice thread",
|
||||||
|
tags: [
|
||||||
|
["E", rootId],
|
||||||
|
["K", "11"],
|
||||||
|
["P", rootPubkey],
|
||||||
|
["e", parentId],
|
||||||
|
["k", "1111"],
|
||||||
|
["p", parentPubkey],
|
||||||
|
["alt", "x"],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const comment = await Comment.fromEvent(event)
|
||||||
|
|
||||||
|
expect(comment.content()).toBe("nice thread")
|
||||||
|
expect(comment.root()).toEqual({id: rootId, address: undefined, kind: "11", pubkey: rootPubkey})
|
||||||
|
expect(comment.parent()).toEqual({
|
||||||
|
id: parentId,
|
||||||
|
address: undefined,
|
||||||
|
kind: "1111",
|
||||||
|
pubkey: parentPubkey,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("round-trips with no duplicate reference tags", async () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
content: "nice thread",
|
||||||
|
tags: [
|
||||||
|
["E", rootId],
|
||||||
|
["K", "11"],
|
||||||
|
["P", rootPubkey],
|
||||||
|
["e", parentId],
|
||||||
|
["k", "1111"],
|
||||||
|
["p", parentPubkey],
|
||||||
|
["alt", "x"],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const tmpl = await (await Comment.fromEvent(event)).builder().toTemplate(signer)
|
||||||
|
|
||||||
|
// Each represented reference key emits exactly once.
|
||||||
|
for (const key of ["E", "K", "P", "e", "k", "p"]) {
|
||||||
|
expect(tmpl.tags.filter(t => t[0] === key).length).toBe(1)
|
||||||
|
}
|
||||||
|
expect(tmpl.tags).toContainEqual(["E", rootId])
|
||||||
|
expect(tmpl.tags).toContainEqual(["e", parentId])
|
||||||
|
// Unknown passthrough tag survives.
|
||||||
|
expect(tmpl.tags).toContainEqual(["alt", "x"])
|
||||||
|
expect(tmpl.content).toBe("nice thread")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("builds references from full events", async () => {
|
||||||
|
const root = makeEvent({id: rootId, pubkey: rootPubkey, kind: 11})
|
||||||
|
const parent = makeEvent({id: parentId, pubkey: parentPubkey, kind: 1111})
|
||||||
|
|
||||||
|
const tmpl = await new CommentBuilder()
|
||||||
|
.setContent("reply")
|
||||||
|
.setRootFromEvent(root)
|
||||||
|
.setParentFromEvent(parent)
|
||||||
|
.toTemplate(signer)
|
||||||
|
|
||||||
|
expect(tmpl.kind).toBe(COMMENT)
|
||||||
|
expect(tmpl.tags).toContainEqual(["E", rootId])
|
||||||
|
expect(tmpl.tags).toContainEqual(["K", "11"])
|
||||||
|
expect(tmpl.tags).toContainEqual(["P", rootPubkey])
|
||||||
|
expect(tmpl.tags).toContainEqual(["e", parentId])
|
||||||
|
expect(tmpl.tags).toContainEqual(["k", "1111"])
|
||||||
|
expect(tmpl.tags).toContainEqual(["p", parentPubkey])
|
||||||
|
expect(tmpl.content).toBe("reply")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws on the wrong kind", async () => {
|
||||||
|
await expect(Comment.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import {describe, it, expect} from "vitest"
|
||||||
|
import {makeSecret, EMOJIS, NOTE, getAddressTagValues} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {Nip01Signer} from "@welshman/signer"
|
||||||
|
import {EmojiList, EmojiListBuilder} from "../src/kinds/EmojiList"
|
||||||
|
|
||||||
|
const signer = new Nip01Signer(makeSecret())
|
||||||
|
const pubkey = "ee".repeat(32)
|
||||||
|
|
||||||
|
const setAddress = `30030:${"aa".repeat(32)}:my-emojis`
|
||||||
|
const setAddress2 = `30030:${"bb".repeat(32)}:more-emojis`
|
||||||
|
const emojiTag = ["emoji", "soapbox", "https://example.com/soapbox.png"]
|
||||||
|
const emojiTag2 = ["emoji", "ostrich", "https://example.com/ostrich.png"]
|
||||||
|
|
||||||
|
const makeEvent = (o: Partial<TrustedEvent> = {}): TrustedEvent =>
|
||||||
|
({
|
||||||
|
id: "ff".repeat(32),
|
||||||
|
pubkey,
|
||||||
|
created_at: 0,
|
||||||
|
kind: EMOJIS,
|
||||||
|
tags: [],
|
||||||
|
content: "",
|
||||||
|
sig: "00".repeat(64),
|
||||||
|
...o,
|
||||||
|
}) as TrustedEvent
|
||||||
|
|
||||||
|
describe("EmojiList", () => {
|
||||||
|
it("reads emoji-set addresses and inline emoji tags", async () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
tags: [["a", setAddress], emojiTag, ["alt", "x"]],
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = await EmojiList.fromEvent(event)
|
||||||
|
|
||||||
|
expect(list.emojiSets()).toEqual([setAddress])
|
||||||
|
expect(list.emojis()).toEqual([emojiTag])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("round-trips without duplicating tags and preserves passthrough", async () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
tags: [["a", setAddress], emojiTag, ["alt", "x"]],
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = await EmojiList.fromEvent(event)
|
||||||
|
const tmpl = await list.builder().toTemplate(signer)
|
||||||
|
|
||||||
|
expect(tmpl.kind).toBe(EMOJIS)
|
||||||
|
expect(tmpl.tags.filter(t => t[0] === "a").length).toBe(1)
|
||||||
|
expect(tmpl.tags.filter(t => t[0] === "emoji").length).toBe(1)
|
||||||
|
expect(tmpl.tags).toContainEqual(["alt", "x"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("builds from a fresh builder", async () => {
|
||||||
|
const tmpl = await new EmojiListBuilder()
|
||||||
|
.addEmojiSet(setAddress)
|
||||||
|
.addEmoji("soapbox", "https://example.com/soapbox.png")
|
||||||
|
.toTemplate(signer)
|
||||||
|
|
||||||
|
expect(getAddressTagValues(tmpl.tags)).toEqual([setAddress])
|
||||||
|
expect(tmpl.tags).toContainEqual(emojiTag)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("removeEmoji removes by value", async () => {
|
||||||
|
const event = makeEvent({tags: [emojiTag, emojiTag2]})
|
||||||
|
const list = await EmojiList.fromEvent(event)
|
||||||
|
|
||||||
|
const tmpl = await list.builder().removeEmoji("soapbox").toTemplate(signer)
|
||||||
|
|
||||||
|
expect(tmpl.tags.filter(t => t[0] === "emoji")).toEqual([emojiTag2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("round-trips public and private entries through encryption", async () => {
|
||||||
|
const event = await new EmojiListBuilder()
|
||||||
|
.addEmojiSet(setAddress)
|
||||||
|
.addPrivate(["a", setAddress2])
|
||||||
|
.toEvent(signer)
|
||||||
|
|
||||||
|
expect(getAddressTagValues(event.tags)).toEqual([setAddress])
|
||||||
|
expect(event.content).not.toBe("")
|
||||||
|
|
||||||
|
const decrypted = await EmojiList.fromEvent(event, signer)
|
||||||
|
|
||||||
|
expect(decrypted.decrypted).toBe(true)
|
||||||
|
expect(decrypted.emojiSets().sort()).toEqual([setAddress, setAddress2].sort())
|
||||||
|
|
||||||
|
const publicOnly = await EmojiList.fromEvent(event)
|
||||||
|
|
||||||
|
expect(publicOnly.decrypted).toBe(false)
|
||||||
|
expect(publicOnly.emojiSets()).toEqual([setAddress])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves undecrypted ciphertext on pass-through", async () => {
|
||||||
|
const event = await new EmojiListBuilder().addPrivate(["a", setAddress2]).toEvent(signer)
|
||||||
|
const undecrypted = await EmojiList.fromEvent(event)
|
||||||
|
|
||||||
|
const tmpl = await undecrypted.builder().toTemplate(signer)
|
||||||
|
|
||||||
|
expect(tmpl.content).toBe(event.content)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws on the wrong kind", async () => {
|
||||||
|
await expect(EmojiList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import {describe, it, expect} from "vitest"
|
||||||
|
import {makeSecret, FEED, NOTE} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {Nip01Signer} from "@welshman/signer"
|
||||||
|
import {Feed, FeedBuilder} from "../src/kinds/Feed"
|
||||||
|
|
||||||
|
const signer = new Nip01Signer(makeSecret())
|
||||||
|
const pubkey = "ee".repeat(32)
|
||||||
|
|
||||||
|
const definition = ["union", ["search", "nostr"]]
|
||||||
|
|
||||||
|
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
|
||||||
|
({
|
||||||
|
id: "ff".repeat(32),
|
||||||
|
pubkey,
|
||||||
|
created_at: 0,
|
||||||
|
kind: FEED,
|
||||||
|
tags: [],
|
||||||
|
content: "",
|
||||||
|
sig: "00".repeat(64),
|
||||||
|
...overrides,
|
||||||
|
}) as TrustedEvent
|
||||||
|
|
||||||
|
describe("Feed", () => {
|
||||||
|
it("reads represented tags", async () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
tags: [
|
||||||
|
["d", "abc"],
|
||||||
|
["title", "My Feed"],
|
||||||
|
["description", "all the things"],
|
||||||
|
["feed", JSON.stringify(definition)],
|
||||||
|
["alt", "My Feed"],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const feed = await Feed.fromEvent(event)
|
||||||
|
|
||||||
|
expect(feed.identifier()).toBe("abc")
|
||||||
|
expect(feed.title()).toBe("My Feed")
|
||||||
|
expect(feed.description()).toBe("all the things")
|
||||||
|
expect(feed.definition()).toEqual(definition)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("round-trips with no duplicate represented tags", async () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
tags: [
|
||||||
|
["d", "abc"],
|
||||||
|
["title", "My Feed"],
|
||||||
|
["description", "all the things"],
|
||||||
|
["feed", JSON.stringify(definition)],
|
||||||
|
// "alt" is consumed but not re-emitted, so it shouldn't survive.
|
||||||
|
["alt", "My Feed"],
|
||||||
|
["zzz", "x"],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const tmpl = await (await Feed.fromEvent(event)).builder().toTemplate(signer)
|
||||||
|
|
||||||
|
for (const key of ["d", "title", "description", "feed"]) {
|
||||||
|
expect(tmpl.tags.filter(t => t[0] === key).length).toBe(1)
|
||||||
|
}
|
||||||
|
expect(tmpl.tags).toContainEqual(["d", "abc"])
|
||||||
|
expect(tmpl.tags).toContainEqual(["title", "My Feed"])
|
||||||
|
expect(tmpl.tags).toContainEqual(["feed", JSON.stringify(definition)])
|
||||||
|
// "alt" is consumed but not re-emitted.
|
||||||
|
expect(tmpl.tags.filter(t => t[0] === "alt")).toHaveLength(0)
|
||||||
|
// Unknown passthrough tag survives.
|
||||||
|
expect(tmpl.tags).toContainEqual(["zzz", "x"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("builds from a fresh builder", async () => {
|
||||||
|
const tmpl = await new FeedBuilder()
|
||||||
|
.setIdentifier("feed1")
|
||||||
|
.setTitle("Fresh")
|
||||||
|
.setDescription("desc")
|
||||||
|
.setDefinition(definition)
|
||||||
|
.toTemplate(signer)
|
||||||
|
|
||||||
|
expect(tmpl.kind).toBe(FEED)
|
||||||
|
expect(tmpl.tags).toContainEqual(["d", "feed1"])
|
||||||
|
expect(tmpl.tags).toContainEqual(["title", "Fresh"])
|
||||||
|
expect(tmpl.tags).toContainEqual(["description", "desc"])
|
||||||
|
expect(tmpl.tags).toContainEqual(["feed", JSON.stringify(definition)])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws on the wrong kind", async () => {
|
||||||
|
await expect(Feed.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import {describe, it, expect} from "vitest"
|
||||||
|
import {makeSecret, FEEDS, NOTE} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {Nip01Signer} from "@welshman/signer"
|
||||||
|
import {FeedList, FeedListBuilder} from "../src/kinds/FeedList"
|
||||||
|
|
||||||
|
const signer = new Nip01Signer(makeSecret())
|
||||||
|
const pubkey = "ee".repeat(32)
|
||||||
|
|
||||||
|
const addressA = `31890:${"22".repeat(32)}:feeda`
|
||||||
|
const addressB = `31890:${"33".repeat(32)}:feedb`
|
||||||
|
|
||||||
|
const makeEvent = (o: Partial<TrustedEvent> = {}): TrustedEvent =>
|
||||||
|
({
|
||||||
|
id: "ff".repeat(32),
|
||||||
|
pubkey,
|
||||||
|
created_at: 0,
|
||||||
|
kind: FEEDS,
|
||||||
|
tags: [],
|
||||||
|
content: "",
|
||||||
|
sig: "00".repeat(64),
|
||||||
|
...o,
|
||||||
|
}) as TrustedEvent
|
||||||
|
|
||||||
|
describe("FeedList", () => {
|
||||||
|
it("reads saved feed addresses", async () => {
|
||||||
|
const reader = await FeedList.fromEvent(
|
||||||
|
makeEvent({tags: [["a", addressA], ["alt", "x"]]}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(reader.addresses()).toEqual([addressA])
|
||||||
|
expect(reader.includes(addressA)).toBe(true)
|
||||||
|
expect(reader.includes(addressB)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("round-trips without duplicating represented tags", async () => {
|
||||||
|
const reader = await FeedList.fromEvent(
|
||||||
|
makeEvent({tags: [["a", addressA], ["alt", "x"]]}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tmpl = await reader.builder().toTemplate(signer)
|
||||||
|
|
||||||
|
expect(tmpl.tags.filter(t => t[0] === "a").length).toBe(1)
|
||||||
|
expect(tmpl.tags).toContainEqual(["alt", "x"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("adds and removes feeds via a fresh builder", async () => {
|
||||||
|
const tmpl = await new FeedListBuilder()
|
||||||
|
.addFeed(addressA, "wss://relay.example.com/")
|
||||||
|
.addFeed(addressB)
|
||||||
|
.removeFeed(addressA)
|
||||||
|
.toTemplate(signer)
|
||||||
|
|
||||||
|
expect(tmpl.kind).toBe(FEEDS)
|
||||||
|
expect(tmpl.tags).toContainEqual(["a", addressB, ""])
|
||||||
|
expect(tmpl.tags.some(t => t[1] === addressA)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("round-trips public and private feeds through encryption", async () => {
|
||||||
|
const event = await new FeedListBuilder()
|
||||||
|
.addFeed(addressA)
|
||||||
|
.addFeedPrivately(addressB)
|
||||||
|
.toEvent(signer)
|
||||||
|
|
||||||
|
const decrypted = await FeedList.fromEvent(event, signer)
|
||||||
|
|
||||||
|
expect(decrypted.decrypted).toBe(true)
|
||||||
|
expect(decrypted.addresses().sort()).toEqual([addressA, addressB].sort())
|
||||||
|
|
||||||
|
const publicOnly = await FeedList.fromEvent(event)
|
||||||
|
|
||||||
|
expect(publicOnly.decrypted).toBe(false)
|
||||||
|
expect(publicOnly.addresses()).toEqual([addressA])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws on the wrong kind", async () => {
|
||||||
|
await expect(FeedList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import {describe, it, expect} from "vitest"
|
||||||
|
import {makeSecret, FOLLOWS, NOTE, getPubkeyTagValues} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {Nip01Signer} from "@welshman/signer"
|
||||||
|
import {FollowList, FollowListBuilder} from "../src/kinds/FollowList"
|
||||||
|
|
||||||
|
const signer = new Nip01Signer(makeSecret())
|
||||||
|
const pubkey = "ee".repeat(32)
|
||||||
|
|
||||||
|
const a = "aa".repeat(32)
|
||||||
|
const b = "bb".repeat(32)
|
||||||
|
const c = "cc".repeat(32)
|
||||||
|
|
||||||
|
const makeEvent = (o: Partial<TrustedEvent> = {}): TrustedEvent =>
|
||||||
|
({
|
||||||
|
id: "ff".repeat(32),
|
||||||
|
pubkey,
|
||||||
|
created_at: 0,
|
||||||
|
kind: FOLLOWS,
|
||||||
|
tags: [],
|
||||||
|
content: "",
|
||||||
|
sig: "00".repeat(64),
|
||||||
|
...o,
|
||||||
|
}) as TrustedEvent
|
||||||
|
|
||||||
|
describe("FollowList", () => {
|
||||||
|
it("reads followed pubkeys", async () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
tags: [
|
||||||
|
["p", a],
|
||||||
|
["p", b],
|
||||||
|
["t", "nostr"],
|
||||||
|
["alt", "x"],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = await FollowList.fromEvent(event)
|
||||||
|
|
||||||
|
expect(list.pubkeys().sort()).toEqual([a, b].sort())
|
||||||
|
expect(list.includes(a)).toBe(true)
|
||||||
|
expect(list.includes(c)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("round-trips without duplicating tags and preserves passthrough", async () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
tags: [
|
||||||
|
["p", a],
|
||||||
|
["p", b],
|
||||||
|
["alt", "x"],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = await FollowList.fromEvent(event)
|
||||||
|
const tmpl = await list.builder().toTemplate(signer)
|
||||||
|
|
||||||
|
expect(tmpl.kind).toBe(FOLLOWS)
|
||||||
|
expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(2)
|
||||||
|
expect(tmpl.tags).toContainEqual(["alt", "x"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("builds from a fresh builder via addFollow", async () => {
|
||||||
|
const tmpl = await new FollowListBuilder()
|
||||||
|
.addFollow(["p", a])
|
||||||
|
.addFollow(["t", "nostr"])
|
||||||
|
.toTemplate(signer)
|
||||||
|
|
||||||
|
expect(getPubkeyTagValues(tmpl.tags)).toEqual([a])
|
||||||
|
expect(tmpl.tags).toContainEqual(["t", "nostr"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("removeFollow removes by value", async () => {
|
||||||
|
const event = makeEvent({tags: [["p", a], ["p", b]]})
|
||||||
|
const list = await FollowList.fromEvent(event)
|
||||||
|
|
||||||
|
const tmpl = await list.builder().removeFollow(a).toTemplate(signer)
|
||||||
|
|
||||||
|
expect(getPubkeyTagValues(tmpl.tags)).toEqual([b])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("round-trips public and private follows through encryption", async () => {
|
||||||
|
const event = await new FollowListBuilder()
|
||||||
|
.addFollow(["p", a])
|
||||||
|
.addPrivate(["p", b])
|
||||||
|
.toEvent(signer)
|
||||||
|
|
||||||
|
expect(getPubkeyTagValues(event.tags)).toEqual([a])
|
||||||
|
expect(event.content).not.toBe("")
|
||||||
|
|
||||||
|
const decrypted = await FollowList.fromEvent(event, signer)
|
||||||
|
|
||||||
|
expect(decrypted.decrypted).toBe(true)
|
||||||
|
expect(decrypted.pubkeys().sort()).toEqual([a, b].sort())
|
||||||
|
|
||||||
|
const publicOnly = await FollowList.fromEvent(event)
|
||||||
|
|
||||||
|
expect(publicOnly.decrypted).toBe(false)
|
||||||
|
expect(publicOnly.pubkeys()).toEqual([a])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws on the wrong kind", async () => {
|
||||||
|
await expect(FollowList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import {describe, it, expect} from "vitest"
|
||||||
|
import {makeSecret, COMMUNITIES, NOTE, getAddressTagValues} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {Nip01Signer} from "@welshman/signer"
|
||||||
|
import {GroupList, GroupListBuilder} from "../src/kinds/GroupList"
|
||||||
|
|
||||||
|
const signer = new Nip01Signer(makeSecret())
|
||||||
|
const pubkey = "ee".repeat(32)
|
||||||
|
|
||||||
|
const g1 = `34550:${"aa".repeat(32)}:dev`
|
||||||
|
const g2 = `34550:${"bb".repeat(32)}:art`
|
||||||
|
const g3 = `34550:${"cc".repeat(32)}:music`
|
||||||
|
|
||||||
|
const makeEvent = (o: Partial<TrustedEvent> = {}): TrustedEvent =>
|
||||||
|
({
|
||||||
|
id: "ff".repeat(32),
|
||||||
|
pubkey,
|
||||||
|
created_at: 0,
|
||||||
|
kind: COMMUNITIES,
|
||||||
|
tags: [],
|
||||||
|
content: "",
|
||||||
|
sig: "00".repeat(64),
|
||||||
|
...o,
|
||||||
|
}) as TrustedEvent
|
||||||
|
|
||||||
|
describe("GroupList", () => {
|
||||||
|
it("reads community addresses", async () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
tags: [
|
||||||
|
["a", g1, "wss://relay.example/"],
|
||||||
|
["a", g2],
|
||||||
|
["alt", "x"],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = await GroupList.fromEvent(event)
|
||||||
|
|
||||||
|
expect(list.addresses().sort()).toEqual([g1, g2].sort())
|
||||||
|
})
|
||||||
|
|
||||||
|
it("round-trips without duplicating tags and preserves passthrough", async () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
tags: [
|
||||||
|
["a", g1, "wss://relay.example/"],
|
||||||
|
["a", g2],
|
||||||
|
["alt", "x"],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = await GroupList.fromEvent(event)
|
||||||
|
const tmpl = await list.builder().toTemplate(signer)
|
||||||
|
|
||||||
|
expect(tmpl.kind).toBe(COMMUNITIES)
|
||||||
|
expect(tmpl.tags.filter(t => t[0] === "a").length).toBe(2)
|
||||||
|
expect(tmpl.tags).toContainEqual(["alt", "x"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("builds from a fresh builder with relay hint", async () => {
|
||||||
|
const tmpl = await new GroupListBuilder()
|
||||||
|
.addGroup(g1, "wss://relay.example/")
|
||||||
|
.addGroup(g2)
|
||||||
|
.toTemplate(signer)
|
||||||
|
|
||||||
|
expect(getAddressTagValues(tmpl.tags).sort()).toEqual([g1, g2].sort())
|
||||||
|
expect(tmpl.tags).toContainEqual(["a", g1, "wss://relay.example/"])
|
||||||
|
expect(tmpl.tags).toContainEqual(["a", g2, ""])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("removeGroup removes by address", async () => {
|
||||||
|
const event = makeEvent({tags: [["a", g1], ["a", g2]]})
|
||||||
|
const list = await GroupList.fromEvent(event)
|
||||||
|
|
||||||
|
const tmpl = await list.builder().removeGroup(g1).toTemplate(signer)
|
||||||
|
|
||||||
|
expect(getAddressTagValues(tmpl.tags)).toEqual([g2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("round-trips public and private entries through encryption", async () => {
|
||||||
|
const event = await new GroupListBuilder()
|
||||||
|
.addGroup(g1)
|
||||||
|
.addPrivate(["a", g2, ""])
|
||||||
|
.toEvent(signer)
|
||||||
|
|
||||||
|
expect(getAddressTagValues(event.tags)).toEqual([g1])
|
||||||
|
expect(event.content).not.toBe("")
|
||||||
|
|
||||||
|
const decrypted = await GroupList.fromEvent(event, signer)
|
||||||
|
|
||||||
|
expect(decrypted.decrypted).toBe(true)
|
||||||
|
expect(decrypted.addresses().sort()).toEqual([g1, g2].sort())
|
||||||
|
|
||||||
|
const publicOnly = await GroupList.fromEvent(event)
|
||||||
|
|
||||||
|
expect(publicOnly.decrypted).toBe(false)
|
||||||
|
expect(publicOnly.addresses()).toEqual([g1])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves undecrypted ciphertext on pass-through", async () => {
|
||||||
|
const event = await new GroupListBuilder().addPrivate(["a", g2, ""]).toEvent(signer)
|
||||||
|
const undecrypted = await GroupList.fromEvent(event)
|
||||||
|
|
||||||
|
const tmpl = await undecrypted.builder().toTemplate(signer)
|
||||||
|
|
||||||
|
expect(tmpl.content).toBe(event.content)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws on the wrong kind", async () => {
|
||||||
|
await expect(GroupList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import {describe, it, expect} from "vitest"
|
||||||
|
import {makeSecret, HANDLER_INFORMATION, NOTE} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {Nip01Signer} from "@welshman/signer"
|
||||||
|
import {Handler, HandlerBuilder} from "../src/kinds/Handler"
|
||||||
|
|
||||||
|
const signer = new Nip01Signer(makeSecret())
|
||||||
|
const pubkey = "ee".repeat(32)
|
||||||
|
|
||||||
|
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
|
||||||
|
({
|
||||||
|
id: "ff".repeat(32),
|
||||||
|
pubkey,
|
||||||
|
created_at: 0,
|
||||||
|
kind: HANDLER_INFORMATION,
|
||||||
|
tags: [],
|
||||||
|
content: "",
|
||||||
|
sig: "00".repeat(64),
|
||||||
|
...overrides,
|
||||||
|
}) as TrustedEvent
|
||||||
|
|
||||||
|
describe("Handler", () => {
|
||||||
|
it("parses JSON metadata content and k tags", async () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
content: JSON.stringify({
|
||||||
|
name: "Coracle",
|
||||||
|
about: "a client",
|
||||||
|
picture: "https://example.com/i.png",
|
||||||
|
website: "https://example.com",
|
||||||
|
lud16: "a@example.com",
|
||||||
|
nip05: "a@example.com",
|
||||||
|
}),
|
||||||
|
tags: [
|
||||||
|
["d", "myhandler"],
|
||||||
|
["k", "1"],
|
||||||
|
["k", "30023"],
|
||||||
|
["alt", "x"],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const handler = await Handler.fromEvent(event)
|
||||||
|
|
||||||
|
expect(handler.values.name).toBe("Coracle")
|
||||||
|
expect(handler.name()).toBe("Coracle")
|
||||||
|
expect(handler.about()).toBe("a client")
|
||||||
|
expect(handler.picture()).toBe("https://example.com/i.png")
|
||||||
|
expect(handler.website()).toBe("https://example.com")
|
||||||
|
expect(handler.lud16()).toBe("a@example.com")
|
||||||
|
expect(handler.nip05()).toBe("a@example.com")
|
||||||
|
expect(handler.kinds()).toEqual([1, 30023])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves unknown content metadata on round-trip", async () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
content: JSON.stringify({name: "Coracle", custom_field: "keep me"}),
|
||||||
|
tags: [["d", "myhandler"]],
|
||||||
|
})
|
||||||
|
|
||||||
|
const tmpl = await (await Handler.fromEvent(event)).builder().toTemplate(signer)
|
||||||
|
const parsed = JSON.parse(tmpl.content)
|
||||||
|
|
||||||
|
expect(parsed.name).toBe("Coracle")
|
||||||
|
expect(parsed.custom_field).toBe("keep me")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("ignores non-spec aliases like display_name", async () => {
|
||||||
|
const handler = await Handler.fromEvent(
|
||||||
|
makeEvent({
|
||||||
|
content: JSON.stringify({display_name: "Alias", picture: "https://example.com/p.png"}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// NIP-89 metadata follows NIP-01: only the canonical `name`/`picture` fields are read.
|
||||||
|
expect(handler.name()).toBeUndefined()
|
||||||
|
expect(handler.picture()).toBe("https://example.com/p.png")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("round-trips with no duplication", async () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
content: JSON.stringify({name: "Coracle", about: "a client"}),
|
||||||
|
tags: [
|
||||||
|
["d", "myhandler"],
|
||||||
|
["k", "1"],
|
||||||
|
["k", "30023"],
|
||||||
|
["alt", "x"],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const tmpl = await (await Handler.fromEvent(event)).builder().toTemplate(signer)
|
||||||
|
|
||||||
|
expect(tmpl.tags.filter(t => t[0] === "k").length).toBe(2)
|
||||||
|
// The d identifier is passed through untouched.
|
||||||
|
expect(tmpl.tags.filter(t => t[0] === "d").length).toBe(1)
|
||||||
|
expect(tmpl.tags).toContainEqual(["alt", "x"])
|
||||||
|
|
||||||
|
// Content re-serializes the parsed metadata.
|
||||||
|
const parsed = JSON.parse(tmpl.content)
|
||||||
|
expect(parsed.name).toBe("Coracle")
|
||||||
|
expect(parsed.about).toBe("a client")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("builds from a fresh builder", async () => {
|
||||||
|
const tmpl = await new HandlerBuilder()
|
||||||
|
.setIdentifier("myhandler")
|
||||||
|
.setName("MyApp")
|
||||||
|
.setAbout("does things")
|
||||||
|
.setWebsite("https://my.app")
|
||||||
|
.setKinds([1, 7])
|
||||||
|
.toTemplate(signer)
|
||||||
|
|
||||||
|
expect(tmpl.kind).toBe(HANDLER_INFORMATION)
|
||||||
|
expect(tmpl.tags).toContainEqual(["d", "myhandler"])
|
||||||
|
const parsed = JSON.parse(tmpl.content)
|
||||||
|
expect(parsed.name).toBe("MyApp")
|
||||||
|
expect(parsed.about).toBe("does things")
|
||||||
|
expect(parsed.website).toBe("https://my.app")
|
||||||
|
expect(tmpl.tags).toContainEqual(["k", "1"])
|
||||||
|
expect(tmpl.tags).toContainEqual(["k", "7"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws on the wrong kind", async () => {
|
||||||
|
await expect(Handler.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import {describe, it, expect} from "vitest"
|
||||||
|
import {makeSecret, HANDLER_RECOMMENDATION, NOTE} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {Nip01Signer} from "@welshman/signer"
|
||||||
|
import {
|
||||||
|
HandlerRecommendation,
|
||||||
|
HandlerRecommendationBuilder,
|
||||||
|
} from "../src/kinds/HandlerRecommendation"
|
||||||
|
|
||||||
|
const signer = new Nip01Signer(makeSecret())
|
||||||
|
const pubkey = "ee".repeat(32)
|
||||||
|
|
||||||
|
const webAddress = `31990:${"aa".repeat(32)}:web-handler`
|
||||||
|
const otherAddress = `31990:${"bb".repeat(32)}:other-handler`
|
||||||
|
|
||||||
|
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
|
||||||
|
({
|
||||||
|
id: "ff".repeat(32),
|
||||||
|
pubkey,
|
||||||
|
created_at: 0,
|
||||||
|
kind: HANDLER_RECOMMENDATION,
|
||||||
|
tags: [],
|
||||||
|
content: "",
|
||||||
|
sig: "00".repeat(64),
|
||||||
|
...overrides,
|
||||||
|
}) as TrustedEvent
|
||||||
|
|
||||||
|
describe("HandlerRecommendation", () => {
|
||||||
|
it("parses address tags and prefers the web handler", async () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
tags: [
|
||||||
|
["d", "1"],
|
||||||
|
["a", otherAddress, "wss://relay.one", "android"],
|
||||||
|
["a", webAddress, "wss://relay.two", "web"],
|
||||||
|
["alt", "x"],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const rec = await HandlerRecommendation.fromEvent(event)
|
||||||
|
|
||||||
|
expect(rec.addresses()).toEqual([otherAddress, webAddress])
|
||||||
|
expect(rec.addressTags().length).toBe(2)
|
||||||
|
// Prefers the recommendation marked "web".
|
||||||
|
expect(rec.handlerAddress()).toBe(webAddress)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("falls back to the first recommendation without a web marker", async () => {
|
||||||
|
const rec = await HandlerRecommendation.fromEvent(
|
||||||
|
makeEvent({tags: [["d", "1"], ["a", otherAddress, "", "android"]]}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(rec.handlerAddress()).toBe(otherAddress)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("round-trips with no duplication", async () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
tags: [
|
||||||
|
["d", "1"],
|
||||||
|
["a", otherAddress, "wss://relay.one", "android"],
|
||||||
|
["a", webAddress, "wss://relay.two", "web"],
|
||||||
|
["alt", "x"],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const tmpl = await (await HandlerRecommendation.fromEvent(event)).builder().toTemplate(signer)
|
||||||
|
|
||||||
|
expect(tmpl.tags.filter(t => t[0] === "d").length).toBe(1)
|
||||||
|
expect(tmpl.tags.filter(t => t[0] === "a").length).toBe(2)
|
||||||
|
expect(tmpl.tags).toContainEqual(["alt", "x"])
|
||||||
|
// The d identifier round-trips its value.
|
||||||
|
expect(tmpl.tags.find(t => t[0] === "d")![1]).toBe("1")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("builds from a fresh builder", async () => {
|
||||||
|
const builder = new HandlerRecommendationBuilder()
|
||||||
|
// The d identifier holds the recommended kind.
|
||||||
|
builder.setIdentifier("1")
|
||||||
|
|
||||||
|
const tmpl = await builder
|
||||||
|
.addRecommendation(webAddress, "wss://relay.one", "web")
|
||||||
|
// Duplicate addresses are ignored.
|
||||||
|
.addRecommendation(webAddress, "wss://relay.one", "web")
|
||||||
|
.toTemplate(signer)
|
||||||
|
|
||||||
|
expect(tmpl.kind).toBe(HANDLER_RECOMMENDATION)
|
||||||
|
expect(tmpl.tags).toContainEqual(["d", "1"])
|
||||||
|
expect(tmpl.tags.filter(t => t[0] === "a")).toEqual([
|
||||||
|
["a", webAddress, "wss://relay.one", "web"],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("requires a d identifier", async () => {
|
||||||
|
await expect(
|
||||||
|
new HandlerRecommendationBuilder().addRecommendation(webAddress).toTemplate(signer),
|
||||||
|
).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws on the wrong kind", async () => {
|
||||||
|
await expect(HandlerRecommendation.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import {describe, it, expect} from "vitest"
|
||||||
|
import {makeSecret, MESSAGING_RELAYS, NOTE, getTagValues, normalizeRelayUrl} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {Nip01Signer} from "@welshman/signer"
|
||||||
|
import {MessagingRelayList, MessagingRelayListBuilder} from "../src/kinds/MessagingRelayList"
|
||||||
|
|
||||||
|
const signer = new Nip01Signer(makeSecret())
|
||||||
|
const pubkey = "ee".repeat(32)
|
||||||
|
|
||||||
|
const r1 = "wss://inbox.one.example/"
|
||||||
|
const r2 = "wss://inbox.two.example/"
|
||||||
|
const r3 = "wss://inbox.three.example/"
|
||||||
|
|
||||||
|
const makeEvent = (o: Partial<TrustedEvent> = {}): TrustedEvent =>
|
||||||
|
({
|
||||||
|
id: "ff".repeat(32),
|
||||||
|
pubkey,
|
||||||
|
created_at: 0,
|
||||||
|
kind: MESSAGING_RELAYS,
|
||||||
|
tags: [],
|
||||||
|
content: "",
|
||||||
|
sig: "00".repeat(64),
|
||||||
|
...o,
|
||||||
|
}) as TrustedEvent
|
||||||
|
|
||||||
|
describe("MessagingRelayList", () => {
|
||||||
|
it("reads messaging relay urls", async () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
tags: [
|
||||||
|
["relay", r1],
|
||||||
|
["relay", r2],
|
||||||
|
["alt", "x"],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = await MessagingRelayList.fromEvent(event)
|
||||||
|
|
||||||
|
expect(list.urls().sort()).toEqual([r1, r2].sort())
|
||||||
|
})
|
||||||
|
|
||||||
|
it("round-trips without duplicating tags and preserves passthrough", async () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
tags: [
|
||||||
|
["relay", r1],
|
||||||
|
["relay", r2],
|
||||||
|
["alt", "x"],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = await MessagingRelayList.fromEvent(event)
|
||||||
|
const tmpl = await list.builder().toTemplate(signer)
|
||||||
|
|
||||||
|
expect(tmpl.kind).toBe(MESSAGING_RELAYS)
|
||||||
|
expect(tmpl.tags.filter(t => t[0] === "relay").length).toBe(2)
|
||||||
|
expect(tmpl.tags).toContainEqual(["alt", "x"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("builds from a fresh builder and normalizes urls", async () => {
|
||||||
|
const tmpl = await new MessagingRelayListBuilder()
|
||||||
|
.addUrl("wss://inbox.one.example")
|
||||||
|
.toTemplate(signer)
|
||||||
|
|
||||||
|
expect(getTagValues("relay", tmpl.tags)).toEqual([normalizeRelayUrl("wss://inbox.one.example")])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("setRelays replaces existing relays", async () => {
|
||||||
|
const event = makeEvent({tags: [["relay", r1]]})
|
||||||
|
const list = await MessagingRelayList.fromEvent(event)
|
||||||
|
|
||||||
|
const tmpl = await list.builder().setUrls([r2, r3]).toTemplate(signer)
|
||||||
|
|
||||||
|
expect(getTagValues("relay", tmpl.tags).sort()).toEqual([r2, r3].sort())
|
||||||
|
})
|
||||||
|
|
||||||
|
it("round-trips public and private entries through encryption", async () => {
|
||||||
|
const event = await new MessagingRelayListBuilder()
|
||||||
|
.addUrl(r1)
|
||||||
|
.addPrivate(["relay", r2])
|
||||||
|
.toEvent(signer)
|
||||||
|
|
||||||
|
expect(getTagValues("relay", event.tags)).toEqual([r1])
|
||||||
|
expect(event.content).not.toBe("")
|
||||||
|
|
||||||
|
const decrypted = await MessagingRelayList.fromEvent(event, signer)
|
||||||
|
|
||||||
|
expect(decrypted.decrypted).toBe(true)
|
||||||
|
expect(decrypted.urls().sort()).toEqual([r1, r2].sort())
|
||||||
|
|
||||||
|
const publicOnly = await MessagingRelayList.fromEvent(event)
|
||||||
|
|
||||||
|
expect(publicOnly.decrypted).toBe(false)
|
||||||
|
expect(publicOnly.urls()).toEqual([r1])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves undecrypted ciphertext on pass-through", async () => {
|
||||||
|
const event = await new MessagingRelayListBuilder().addPrivate(["relay", r2]).toEvent(signer)
|
||||||
|
const undecrypted = await MessagingRelayList.fromEvent(event)
|
||||||
|
|
||||||
|
const tmpl = await undecrypted.builder().toTemplate(signer)
|
||||||
|
|
||||||
|
expect(tmpl.content).toBe(event.content)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws on the wrong kind", async () => {
|
||||||
|
await expect(MessagingRelayList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import {describe, it, expect} from "vitest"
|
||||||
|
import {makeSecret, MUTES, FOLLOWS, getPubkeyTagValues} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {Nip01Signer} from "@welshman/signer"
|
||||||
|
import {MuteList, MuteListBuilder} from "../src/kinds/MuteList"
|
||||||
|
|
||||||
|
const signer = new Nip01Signer(makeSecret())
|
||||||
|
|
||||||
|
const a = "aa".repeat(32)
|
||||||
|
const b = "bb".repeat(32)
|
||||||
|
const c = "cc".repeat(32)
|
||||||
|
|
||||||
|
describe("MuteList", () => {
|
||||||
|
it("round-trips public and private mutes through encryption", async () => {
|
||||||
|
const event = await new MuteListBuilder().mutePublicly(a).mutePrivately(b).toEvent(signer)
|
||||||
|
|
||||||
|
expect(event.kind).toBe(MUTES)
|
||||||
|
expect(event.sig).toBeTruthy()
|
||||||
|
// Public entry is visible in tags; private entry is encrypted in content.
|
||||||
|
expect(getPubkeyTagValues(event.tags)).toEqual([a])
|
||||||
|
expect(event.content).not.toBe("")
|
||||||
|
|
||||||
|
// Re-parsing with a capable signer recovers the private entries.
|
||||||
|
const decrypted = await MuteList.fromEvent(event, signer)
|
||||||
|
|
||||||
|
expect(decrypted.decrypted).toBe(true)
|
||||||
|
expect(decrypted.pubkeys().sort()).toEqual([a, b].sort())
|
||||||
|
expect(decrypted.includes(a)).toBe(true)
|
||||||
|
expect(decrypted.includes(b)).toBe(true)
|
||||||
|
expect(decrypted.includes(c)).toBe(false)
|
||||||
|
|
||||||
|
// Parsing without a signer exposes only the public entries.
|
||||||
|
const publicOnly = await MuteList.fromEvent(event)
|
||||||
|
|
||||||
|
expect(publicOnly.decrypted).toBe(false)
|
||||||
|
expect(publicOnly.pubkeys()).toEqual([a])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("removes from both public and private entries", async () => {
|
||||||
|
const event = await new MuteListBuilder()
|
||||||
|
.mutePublicly(a)
|
||||||
|
.mutePrivately(b)
|
||||||
|
.unmute(a)
|
||||||
|
.unmute(b)
|
||||||
|
.toEvent(signer)
|
||||||
|
|
||||||
|
const parsed = await MuteList.fromEvent(event, signer)
|
||||||
|
|
||||||
|
expect(parsed.pubkeys()).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves undecrypted ciphertext on pass-through serialization", async () => {
|
||||||
|
const event = await new MuteListBuilder().mutePrivately(b).toEvent(signer)
|
||||||
|
const undecrypted = await MuteList.fromEvent(event)
|
||||||
|
|
||||||
|
// We never decrypted, so the original ciphertext must survive untouched.
|
||||||
|
const template = await undecrypted.builder().toTemplate(signer)
|
||||||
|
|
||||||
|
expect(template.content).toBe(event.content)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("refuses private mutation when undecrypted", async () => {
|
||||||
|
const event = await new MuteListBuilder().mutePrivately(b).toEvent(signer)
|
||||||
|
const undecrypted = await MuteList.fromEvent(event)
|
||||||
|
|
||||||
|
// Mutation is now deferred-validated: adding a private entry to a list we
|
||||||
|
// couldn't decrypt throws at emit time, not on the mutating call.
|
||||||
|
await expect(undecrypted.builder().mutePrivately(c).toEvent(signer)).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("toRumor encrypts but does not sign", async () => {
|
||||||
|
const rumor = await new MuteListBuilder().mutePrivately(b).toRumor(signer)
|
||||||
|
|
||||||
|
expect(rumor.id).toBeTruthy()
|
||||||
|
expect((rumor as TrustedEvent).sig).toBeUndefined()
|
||||||
|
expect(rumor.content).not.toBe("")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws on the wrong kind", async () => {
|
||||||
|
const event = {kind: FOLLOWS, tags: [], content: "", pubkey: a} as TrustedEvent
|
||||||
|
|
||||||
|
await expect(MuteList.fromEvent(event)).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import {describe, it, expect} from "vitest"
|
||||||
|
import {makeSecret, PINS, NOTE} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {Nip01Signer} from "@welshman/signer"
|
||||||
|
import {PinList, PinListBuilder} from "../src/kinds/PinList"
|
||||||
|
|
||||||
|
const signer = new Nip01Signer(makeSecret())
|
||||||
|
const pubkey = "ee".repeat(32)
|
||||||
|
|
||||||
|
const eventId = "11".repeat(32)
|
||||||
|
const address = `31890:${"22".repeat(32)}:feed`
|
||||||
|
|
||||||
|
const makeEvent = (o: Partial<TrustedEvent> = {}): TrustedEvent =>
|
||||||
|
({
|
||||||
|
id: "ff".repeat(32),
|
||||||
|
pubkey,
|
||||||
|
created_at: 0,
|
||||||
|
kind: PINS,
|
||||||
|
tags: [],
|
||||||
|
content: "",
|
||||||
|
sig: "00".repeat(64),
|
||||||
|
...o,
|
||||||
|
}) as TrustedEvent
|
||||||
|
|
||||||
|
describe("PinList", () => {
|
||||||
|
it("reads pinned event ids and addresses", async () => {
|
||||||
|
const reader = await PinList.fromEvent(
|
||||||
|
makeEvent({tags: [["e", eventId], ["a", address], ["alt", "x"]]}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(reader.ids()).toEqual([eventId])
|
||||||
|
expect(reader.addresses()).toEqual([address])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("round-trips without duplicating represented tags", async () => {
|
||||||
|
const reader = await PinList.fromEvent(
|
||||||
|
makeEvent({tags: [["e", eventId], ["a", address], ["alt", "x"]]}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tmpl = await reader.builder().toTemplate(signer)
|
||||||
|
|
||||||
|
expect(tmpl.tags.filter(t => t[0] === "e").length).toBe(1)
|
||||||
|
expect(tmpl.tags.filter(t => t[0] === "a").length).toBe(1)
|
||||||
|
expect(tmpl.tags).toContainEqual(["alt", "x"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("builds from a fresh builder", async () => {
|
||||||
|
const tmpl = await new PinListBuilder().pinPublicly(["e", eventId]).toTemplate(signer)
|
||||||
|
|
||||||
|
expect(tmpl.kind).toBe(PINS)
|
||||||
|
expect(tmpl.tags).toContainEqual(["e", eventId])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("round-trips public and private pins through encryption", async () => {
|
||||||
|
const event = await new PinListBuilder()
|
||||||
|
.pinPublicly(["e", eventId])
|
||||||
|
.pinPrivately(["a", address])
|
||||||
|
.toEvent(signer)
|
||||||
|
|
||||||
|
const decrypted = await PinList.fromEvent(event, signer)
|
||||||
|
|
||||||
|
expect(decrypted.decrypted).toBe(true)
|
||||||
|
expect(decrypted.ids()).toEqual([eventId])
|
||||||
|
expect(decrypted.addresses()).toEqual([address])
|
||||||
|
|
||||||
|
const publicOnly = await PinList.fromEvent(event)
|
||||||
|
|
||||||
|
expect(publicOnly.decrypted).toBe(false)
|
||||||
|
expect(publicOnly.ids()).toEqual([eventId])
|
||||||
|
expect(publicOnly.addresses()).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws on the wrong kind", async () => {
|
||||||
|
await expect(PinList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import {describe, it, expect} from "vitest"
|
||||||
|
import {makeSecret, POLL, NOTE} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {Nip01Signer} from "@welshman/signer"
|
||||||
|
import {Poll, PollBuilder} from "../src/kinds/Poll"
|
||||||
|
|
||||||
|
const signer = new Nip01Signer(makeSecret())
|
||||||
|
const pubkey = "ee".repeat(32)
|
||||||
|
|
||||||
|
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
|
||||||
|
({
|
||||||
|
id: "ff".repeat(32),
|
||||||
|
pubkey,
|
||||||
|
created_at: 0,
|
||||||
|
kind: POLL,
|
||||||
|
tags: [],
|
||||||
|
content: "",
|
||||||
|
sig: "00".repeat(64),
|
||||||
|
...overrides,
|
||||||
|
}) as TrustedEvent
|
||||||
|
|
||||||
|
describe("Poll", () => {
|
||||||
|
it("parses the represented tags and plain-text title", async () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
content: "Favorite color?",
|
||||||
|
tags: [
|
||||||
|
["option", "1", "Red"],
|
||||||
|
["option", "2", "Blue"],
|
||||||
|
["polltype", "multiplechoice"],
|
||||||
|
["endsAt", "1234"],
|
||||||
|
["relay", "wss://relay.one"],
|
||||||
|
["relay", "wss://relay.two"],
|
||||||
|
["alt", "x"],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const poll = await Poll.fromEvent(event)
|
||||||
|
|
||||||
|
expect(poll.title()).toBe("Favorite color?")
|
||||||
|
expect(poll.options()).toEqual([
|
||||||
|
{id: "1", label: "Red"},
|
||||||
|
{id: "2", label: "Blue"},
|
||||||
|
])
|
||||||
|
expect(poll.pollType()).toBe("multiplechoice")
|
||||||
|
expect(poll.endsAt()).toBe(1234)
|
||||||
|
expect(poll.urls()).toEqual(["wss://relay.one", "wss://relay.two"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("tallies results from response events", async () => {
|
||||||
|
const poll = await Poll.fromEvent(
|
||||||
|
makeEvent({
|
||||||
|
content: "Pick one",
|
||||||
|
tags: [
|
||||||
|
["option", "1", "Red"],
|
||||||
|
["option", "2", "Blue"],
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const responses = [
|
||||||
|
{pubkey: "a", created_at: 1, tags: [["response", "1"]]},
|
||||||
|
{pubkey: "b", created_at: 1, tags: [["response", "2"]]},
|
||||||
|
// Latest response per pubkey wins.
|
||||||
|
{pubkey: "a", created_at: 2, tags: [["response", "2"]]},
|
||||||
|
] as TrustedEvent[]
|
||||||
|
|
||||||
|
const result = poll.results(responses)
|
||||||
|
|
||||||
|
expect(result.voters).toBe(2)
|
||||||
|
expect(result.options.find(o => o.id === "1")!.votes).toBe(0)
|
||||||
|
expect(result.options.find(o => o.id === "2")!.votes).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("round-trips with no duplication", async () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
content: "Favorite color?",
|
||||||
|
tags: [
|
||||||
|
["option", "1", "Red"],
|
||||||
|
["option", "2", "Blue"],
|
||||||
|
["polltype", "multiplechoice"],
|
||||||
|
["endsAt", "1234"],
|
||||||
|
["relay", "wss://relay.one"],
|
||||||
|
["alt", "x"],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const tmpl = await (await Poll.fromEvent(event)).builder().toTemplate(signer)
|
||||||
|
|
||||||
|
expect(tmpl.content).toBe("Favorite color?")
|
||||||
|
expect(tmpl.tags.filter(t => t[0] === "option").length).toBe(2)
|
||||||
|
expect(tmpl.tags.filter(t => t[0] === "polltype").length).toBe(1)
|
||||||
|
expect(tmpl.tags.filter(t => t[0] === "endsAt").length).toBe(1)
|
||||||
|
expect(tmpl.tags.filter(t => t[0] === "relay").length).toBe(1)
|
||||||
|
// Unknown tag survives the round-trip.
|
||||||
|
expect(tmpl.tags).toContainEqual(["alt", "x"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("builds from a fresh builder", async () => {
|
||||||
|
const tmpl = await new PollBuilder()
|
||||||
|
.setTitle("Q?")
|
||||||
|
.addOption("Red", "1")
|
||||||
|
.addOption("Blue", "2")
|
||||||
|
.setPollType("multiplechoice")
|
||||||
|
.setEndsAt(9999)
|
||||||
|
.setUrls(["wss://relay.one"])
|
||||||
|
.toTemplate(signer)
|
||||||
|
|
||||||
|
expect(tmpl.kind).toBe(POLL)
|
||||||
|
expect(tmpl.content).toBe("Q?")
|
||||||
|
expect(tmpl.tags).toContainEqual(["option", "1", "Red"])
|
||||||
|
expect(tmpl.tags).toContainEqual(["polltype", "multiplechoice"])
|
||||||
|
expect(tmpl.tags).toContainEqual(["endsAt", "9999"])
|
||||||
|
expect(tmpl.tags).toContainEqual(["relay", "wss://relay.one"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("requires at least one option", async () => {
|
||||||
|
await expect(new PollBuilder().setTitle("Q?").toTemplate(signer)).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws on the wrong kind", async () => {
|
||||||
|
await expect(Poll.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user