Compare commits

29 Commits

Author SHA1 Message Date
Jon Staab e2a6ef21cd Refine domain, integrate into app
tests / tests (push) Failing after 5m14s
2026-06-19 22:21:06 -07:00
Jon Staab 1bd62d3024 Move domain stuff to sub directory, clean up base classes 2026-06-19 11:57:40 -07:00
hodlbod bfd91f2d39 Rewrite domain objects as a Reader/Builder split
tests / tests (push) Failing after 5m7s
Replace the single DomainObject/EncryptableList classes with a read/write split
that removes the optional-event ambiguity:

- base.ts: EventReader<P> (static kind; fromEvent(event, signer?) eagerly computes
  a generic `plain`, validates leniently, throws-or-passes; lazy method accessors;
  group/protect/expires + extraTags carry-over; builder()) and EventBuilder<P>
  (chainable setters, buildTags/buildContent, validate-on-emit).
- List.ts: ListReader/ListBuilder for NIP-51 lists (decrypt-on-read into `plain`,
  re-encrypt-on-emit, tag mutators).
- Every kind converted to a <Noun> reader + <Noun>Builder pair; membership ops
  split into per-kind reader/builder pairs over a shared abstract base.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V67tPYdvh1qCkjEBhJGZUR
2026-06-19 16:01:42 +00:00
hodlbod 5e142e4db4 Refine domain classes: behavior tags, extra-tag passthrough, cleanups
tests / tests (push) Failing after 5m10s
Iterate on @welshman/domain following review:

- base: add `group`/`protect`/`expires` behavior tags (parsed in base, emitted
  via addBehaviorTags before hashing) and an `extraTags` passthrough (opt-in via
  reservedTagKeys) so tag carry-over lives in one place; migrate Handler, Comment,
  Thread onto it. Comment gains nested root/parent ref structs + setters.
- List: fix inverted keepTags; add clearTags/clearPublicTags/clearPrivateTags and
  use them in the relay/server list setters.
- RelayList: preserve complementary read/write capability instead of dropping
  modeless entries.
- Split Relay/Room membership ops into per-kind classes (RelayAddMember/
  RelayRemoveMember, RoomAddMember/RoomRemoveMember) over a shared base.
- TimeEvent (renamed from CalendarEvent): derive "D" day tags in toTemplate.
- Feed: default to an empty feed, fail parse when the "feed" tag is missing.
- RelaySet added; CommunityList renamed to GroupList; predicate bare add/remove
  mutators; RoomMeta uses randomId; PollResponse.selections drops pollType.
- Remove ChannelList, FileServerList, Settings, and event-asserting getAddress/
  display accessors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V67tPYdvh1qCkjEBhJGZUR
2026-06-18 22:42:10 +00:00
hodlbod 99f5233e05 Add domain object classes for nostr event types
tests / tests (push) Failing after 5m6s
Build out @welshman/domain on top of the DomainObject/EncryptableList base
patterns, porting domain-object use cases from @welshman/util and flotilla
that weren't yet represented.

New classes:
- Relay lists: RelayList (NIP-65 read/write markers), Blocked/Search/Messaging
  relay lists, RelaySet (NIP-51 30002 named set)
- Server lists: Blossom, FileServer
- NIP-51 lists: Follow, Pin, Bookmark, Community, Channel, Room, Feed, Topic,
  Emoji
- Zaps: ZapRequest, ZapReceipt, ZapGoal
- NIP-89 handlers: Handler, HandlerRecommendation
- Rooms/groups (NIP-29): RoomMeta, RoomAdmins, RoomMembers, RoomMembershipOp,
  Room create/delete/join/leave, RoomCreatePermission, RelayMembers,
  RelayMembershipOp, Relay join/leave/invite
- Content: Poll, PollResponse, Thread, Comment, Classified, CalendarEvent,
  Report, Feed, Settings

Also fix unfinished accessors in Profile, method-call bugs in MuteList, and
correct RelayList.set{Read,Write}Relays to preserve a relay's complementary
read/write capability instead of dropping modeless entries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V67tPYdvh1qCkjEBhJGZUR
2026-06-18 21:16:27 +00:00
Jon Staab 925f540640 Clean up domain a bit
tests / tests (push) Failing after 5m5s
2026-06-18 12:49:13 -07:00
Jon Staab 0a08057786 Remove serialization from domain 2026-06-18 12:33:15 -07:00
hodlbod fe5c11b00f rename client, update docs/skills
tests / tests (push) Failing after 5m4s
2026-06-18 19:31:14 +00:00
Jon Staab dfeb7a747b Simplify client plugin interfaces
tests / tests (push) Failing after 5m11s
2026-06-18 11:48:14 -07:00
Jon Staab eb451d795b Add domain package 2026-06-18 11:41:21 -07:00
Jon Staab 393c95e107 Clean up projections 2026-06-18 11:40:39 -07:00
Jon Staab 772895e3ab Tweak relay lists 2026-06-18 11:15:02 -07:00
Jon Staab fafa3b172e Move plugins to a plugin directory 2026-06-18 10:31:48 -07:00
Jon Staab 72ab746254 Add projection utility and type
tests / tests (push) Failing after 5m56s
2026-06-18 10:15:28 -07:00
Jon Staab aae201414d Clean up store semantics 2026-06-18 08:25:23 -07:00
Jon Staab f5124a6c4e Clean up wot 2026-06-17 14:56:39 -07:00
Jon Staab 28219eb64f Small fixes, rework zaps 2026-06-17 10:36:00 -07:00
Jon Staab bc728c680e Rework thunks 2026-06-16 17:07:20 -07:00
Jon Staab abb9f20747 Split up commands and add them to domain modules 2026-06-16 16:34:43 -07:00
Jon Staab 163d2dc355 Tweak outbox loader 2026-06-16 15:00:18 -07:00
Jon Staab 9094d30b89 Add socket policy for authenticating unless blocked 2026-06-16 14:44:33 -07:00
Jon Staab f8130da2bb Rework sessions 2026-06-16 14:07:15 -07:00
Jon Staab 2e12010e26 rework client auth 2026-06-16 13:06:29 -07:00
Jon Staab 87d8a0832d remove net global state 2026-06-16 12:31:46 -07:00
Jon Staab 34065a18cf Remove router singleton 2026-06-16 11:08:46 -07:00
Jon Staab 96b0116c9b Auto register client plugins 2026-06-16 10:32:59 -07:00
Jon Staab ea9cc0bf26 AI refactor 2026-06-16 09:22:26 -07:00
Jon Staab 28339976b9 Add more stuff to client 2026-06-15 18:59:29 -07:00
Jon Staab e0e9ad5834 Add client package 2026-06-15 18:59:28 -07:00
212 changed files with 11943 additions and 6230 deletions
+2
View File
@@ -2,4 +2,6 @@ node_modules
docs docs
docs/reference docs/reference
docs/.vitepress/cache docs/.vitepress/cache
dist
build build
.git
+1
View File
@@ -9,3 +9,4 @@ results
*.tsbuildinfo *.tsbuildinfo
.vscode .vscode
docs/**/*.html docs/**/*.html
.local
+8 -9
View File
@@ -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
View File
@@ -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`).
-76
View File
@@ -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
```
-13
View File
@@ -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'
```
+184
View File
@@ -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
```
+77
View File
@@ -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
View File
@@ -2,82 +2,93 @@
[![version](https://badgen.net/npm/v/@welshman/app)](https://npmjs.com/package/@welshman/app) [![version](https://badgen.net/npm/v/@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.
-147
View File
@@ -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()
```
+157
View File
@@ -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
```
-76
View File
@@ -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)`
+117
View File
@@ -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})
```
-56
View File
@@ -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.
+69
View File
@@ -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
```
+79
View File
@@ -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}),
})
```
-210
View File
@@ -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)`.
-46
View File
@@ -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
View File
@@ -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
View File
@@ -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).
-271
View File
@@ -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)
})
})
})
-119
View File
@@ -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)
})
})
})
+4 -2
View File
@@ -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:*",
+92
View File
@@ -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()
}
}
-42
View File
@@ -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,
)
-40
View File
@@ -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,
)
-378
View File
@@ -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]})
-5
View File
@@ -1,5 +0,0 @@
export type AppContext = {
dufflepudUrl?: string
}
export const appContext: AppContext = {}
-5
View File
@@ -1,5 +0,0 @@
import {Repository, Tracker} from "@welshman/net"
export const tracker = new Tracker()
export const repository = Repository.get()
+14
View File
@@ -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})
-44
View File
@@ -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})
-33
View File
@@ -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)
-150
View File
@@ -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
View File
@@ -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"))
+46
View File
@@ -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
}
})
}
}
-43
View File
@@ -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,
)
-49
View File
@@ -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)
-33
View File
@@ -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)
-44
View File
@@ -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)
}
+178
View File
@@ -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))
}
+23
View File
@@ -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)
}
}
+69
View File
@@ -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,
})
}
+40
View File
@@ -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))
}
+91
View File
@@ -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))
}
+84
View File
@@ -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)
})
}
+63
View File
@@ -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))
}
}
}
}
+40
View File
@@ -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))
}
+27
View File
@@ -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
}
}
+51
View File
@@ -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),
)
}
}
+88
View File
@@ -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)
}
}
+207
View File
@@ -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)
}
}
}
+54
View File
@@ -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
}
+75
View File
@@ -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(),
)
}
+29
View File
@@ -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,
})
}
}
+136
View File
@@ -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))
}
+58
View File
@@ -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)
}
+53
View File
@@ -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]})))
}
}),
)
}
}
+142
View File
@@ -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
}
}
+411
View File
@@ -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
}
}
}
}
+60
View File
@@ -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)
}
}
+192
View File
@@ -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(),
),
)
}
}
+80
View File
@@ -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})
}),
),
)
}
}
+157
View File
@@ -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))]),
)
}
}
+145
View File
@@ -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,
]
-43
View File
@@ -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("")
-87
View File
@@ -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)])
}
-244
View File
@@ -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)
}
}
-73
View File
@@ -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)))
-110
View File
@@ -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}],
},
}),
)
-36
View File
@@ -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
View File
@@ -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)
}
} }
-47
View File
@@ -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}))))
}),
)
}
-131
View File
@@ -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
}
-431
View File
@@ -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)
-49
View File
@@ -1,49 +0,0 @@
import {readable} from "svelte/store"
import {on, call} from "@welshman/lib"
import {deriveItems} from "@welshman/store"
import {getTopicTagValues} from "@welshman/util"
import {repository} from "./core.js"
export type Topic = {
name: string
count: number
}
export const topicsByName = call(() => {
const topicsByName = new Map<string, Topic>()
const addTopic = (name: string) => {
const topic = topicsByName.get(name)
if (topic) {
topic.count++
} else {
topicsByName.set(name, {name, count: 1})
}
}
for (const tagString of repository.eventsByTag.keys()) {
if (tagString.startsWith("t:")) {
addTopic(tagString.slice(2).toLowerCase())
}
}
return readable<Map<string, Topic>>(topicsByName, set => {
return on(repository, "update", ({added}) => {
let dirty = false
for (const event of added) {
for (const name of getTopicTagValues(event.tags)) {
addTopic(name)
dirty = true
}
}
if (dirty) {
set(topicsByName)
}
})
})
})
export const topics = deriveItems(topicsByName)
+47 -84
View File
@@ -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)
} }
const pubkey = await signer.getPubkey()
return new User(pubkey, signer)
} }
export const userProfile = makeUserData(profilesByPubkey, loadProfile) /**
export const forceLoadUserProfile = makeUserLoader(forceLoadProfile) * Reconstruct a signing user from a persisted session, using the registered
export const loadUserProfile = makeUserLoader(loadProfile) * session handlers to find the one for the session's method. The signer is
* wrapped in a `LoggingSigner` (observe it with `makeAppPolicyLogger`) and the
* pubkey is derived from it. Returns undefined when no handler is registered
* for the session's method.
*/
static async fromSession(session: Session): Promise<User | undefined> {
const signer = await getSignerFromSession(session)
export const userFollowList = makeUserData(followListsByPubkey, loadFollowList) return signer ? User.fromSigner(signer) : undefined
export const forceLoadUserFollowList = makeUserLoader(forceLoadFollowList) }
export const loadUserFollowList = makeUserLoader(loadFollowList)
export const userMuteList = makeUserData(muteListsByPubkey, loadMuteList) /**
export const forceLoadUserMuteList = makeUserLoader(forceLoadMuteList) * Return the app's signed-in user, throwing if there isn't one — the entry
export const loadUserMuteList = makeUserLoader(loadMuteList) * point for actions that can only run as a user (publishing, signing).
*/
static require(app: IApp): User {
if (!app.user) {
throw new Error("This action requires a signed-in user")
}
export const userPinList = makeUserData(pinListsByPubkey, loadPinList) return app.user
export const forceLoadUserPinList = makeUserLoader(forceLoadPinList) }
export const loadUserPinList = makeUserLoader(loadPinList)
export const userRelayList = makeUserData(relayListsByPubkey, loadRelayList) sign = (event: StampedEvent) => this.signer.sign(event)
export const forceLoadUserRelayList = makeUserLoader(forceLoadRelayList)
export const loadUserRelayList = makeUserLoader(loadRelayList)
export const userMessagingRelayList = makeUserData( nip44EncryptToSelf = (payload: string) => this.signer.nip44.encrypt(this.pubkey, payload)
messagingRelayListsByPubkey, }
loadMessagingRelayList,
)
export const forceLoadUserMessagingRelayList = makeUserLoader(forceLoadMessagingRelayList)
export const loadUserMessagingRelayList = makeUserLoader(loadMessagingRelayList)
export const userSearchRelayList = makeUserData(searchRelayListsByPubkey, loadSearchRelayList)
export const forceLoadUserSearchRelayList = makeUserLoader(forceLoadSearchRelayList)
export const loadUserSearchRelayList = makeUserLoader(loadSearchRelayList)
export const userBlockedRelayList = makeUserData(blockedRelayListsByPubkey, loadBlockedRelayList)
export const forceLoadUserBlockedRelayList = makeUserLoader(forceLoadBlockedRelayList)
export const loadUserBlockedRelayList = makeUserLoader(loadBlockedRelayList)
export const userBlossomServerList = makeUserData(blossomServerListsByPubkey, loadBlossomServerList)
export const forceLoadUserBlossomServerList = makeUserLoader(forceLoadBlossomServerList)
export const loadUserBlossomServerList = makeUserLoader(loadBlossomServerList)
export const getUserWotScore = (tpk: string) => getWotGraph().get(tpk) || 0
export const deriveUserWotScore = (tpk: string) => derived(wotGraph, $g => $g.get(tpk) || 0)
-101
View File
@@ -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
}
-156
View File
@@ -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
}
+2
View File
@@ -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"]
+2
View File
@@ -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()
})
})
+103
View File
@@ -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()
})
})
+104
View File
@@ -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()
})
})
+89
View File
@@ -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()
})
})
+110
View File
@@ -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()
})
})
+124
View File
@@ -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()
})
})
+76
View File
@@ -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()
})
})
+123
View File
@@ -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