NIP fixes: - RelayMembers (13534): use NIP-43 `member` tags (not `p`) and set the required NIP-70 `-` protected tag. - Profile (kind 0): remove display-name support entirely (getter, setter, display() fallback, and the search weight). - Comment (1111): A/a tags now carry a real address, not the event id. - BlossomServerList (10063): normalize server URLs with normalizeUrl (HTTP), not normalizeRelayUrl (which forced wss://). - HandlerRecommendation (31989): fix inverted removeRecommendation filter; add setSupportedKind()/supportedKind() for the NIP-89 d-tag. - Report (1984): place the report-type string on the e tag (note reports) or p tag (profile reports); always emit the p tag. Docs/skills: - Add @welshman/domain docs (docs/domain/) and the welshman-domain skill. - Prune @welshman/util docs/skill of the moved Profile/List/Handler/Encryptable helpers; register domain in the sidebar, index, and skills README. - Apply accuracy fixes to the @welshman/app docs/skill. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BsMjvv7krpZeHK1Njeneru
This commit is contained in:
@@ -35,6 +35,20 @@ export default defineConfig({
|
||||
{text: "Feeds & Search", link: "/app/feeds-and-search"},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "@welshman/domain",
|
||||
link: "/domain/",
|
||||
items: [
|
||||
{text: "The App Reader/Builder model", link: "/domain/readers-and-builders"},
|
||||
{text: "Profile", link: "/domain/profile"},
|
||||
{text: "Lists", link: "/domain/lists"},
|
||||
{text: "Rooms", link: "/domain/rooms"},
|
||||
{text: "Relay Membership", link: "/domain/relay-membership"},
|
||||
{text: "Handlers", link: "/domain/handlers"},
|
||||
{text: "Zaps", link: "/domain/zaps"},
|
||||
{text: "Content", link: "/domain/content"},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "@welshman/util",
|
||||
link: "/util/",
|
||||
@@ -45,11 +59,7 @@ export default defineConfig({
|
||||
{text: "Events", link: "/util/events"},
|
||||
{text: "Filters", link: "/util/filters"},
|
||||
{text: "Tags", link: "/util/tags"},
|
||||
{text: "Encryptable", link: "/util/encryptable"},
|
||||
{text: "Relays", link: "/util/relay"},
|
||||
{text: "Profiles", link: "/util/profile"},
|
||||
{text: "Handlers", link: "/util/handlers"},
|
||||
{text: "Lists", link: "/util/list"},
|
||||
{text: "Zaps", link: "/util/zaps"},
|
||||
{text: "Relay Auth", link: "/util/nip42"},
|
||||
{text: "HTTP Auth", link: "/util/nip98"},
|
||||
|
||||
+5
-5
@@ -15,7 +15,7 @@ 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)
|
||||
await profiles.publish(values) // merge a partial values record over the current profile and publish (kind 0)
|
||||
```
|
||||
|
||||
`profiles.display(pubkey).$` is the right thing to bind in a component for a user's name.
|
||||
@@ -27,7 +27,7 @@ Kind-3 follow lists keyed by pubkey.
|
||||
```typescript
|
||||
const follows = app.use(FollowLists)
|
||||
|
||||
follows.one(pubkey) // Readable<Maybe<List>>
|
||||
follows.one(pubkey) // Readable<Maybe<FollowList>>
|
||||
await follows.follow(["p", otherPubkey]) // add a tag and publish to outbox
|
||||
await follows.unfollow(otherPubkey) // remove and publish
|
||||
```
|
||||
@@ -39,7 +39,7 @@ Kind-10000 mute lists keyed by pubkey. Private entries are NIP-44 encrypted, so
|
||||
```typescript
|
||||
const mutes = app.use(MuteLists)
|
||||
|
||||
mutes.one(pubkey) // Readable<Maybe<PublishedList>>
|
||||
mutes.one(pubkey) // Readable<Maybe<MuteList>>
|
||||
await mutes.mutePublicly(["p", pubkey]) // public mute
|
||||
await mutes.mutePrivately(["p", pubkey]) // encrypted mute
|
||||
await mutes.unmute(pubkey)
|
||||
@@ -79,7 +79,7 @@ await relayLists.setRelays(tags)
|
||||
|
||||
### Specialized relay lists
|
||||
|
||||
Each of these is a separate kind with the same shape (`urls(pubkey)`, `addRelay`, `removeRelay`, `setRelays`):
|
||||
Each of these is a separate kind with the same shape (`urls(pubkey)`, `addUrl`, `removeUrl`, `setUrls`):
|
||||
|
||||
| Plugin | Kind | Purpose |
|
||||
|---|---|---|
|
||||
@@ -122,7 +122,7 @@ NIP-05 identifiers verified over HTTP, keyed by `name@domain`. Lookups are batch
|
||||
const handles = app.use(Handles)
|
||||
|
||||
handles.forPubkey(pubkey) // Projection<Maybe<Handle>> — resolves via the profile's nip05
|
||||
handles.display(nip05) // Projection<string>
|
||||
handles.display(nip05) // string — displayable nip05
|
||||
await handles.loadForPubkey(pubkey)
|
||||
```
|
||||
|
||||
|
||||
@@ -51,8 +51,8 @@ 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
|
||||
profileSearch.searchOptions("alice") // Profile[]
|
||||
profileSearch.getOption(pubkey) // Profile | 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.
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
# Content
|
||||
|
||||
A grab-bag of content kinds: NIP-22 comments, NIP-7D forum threads, NIP-99 classifieds, NIP-52 calendar events, NIP-88 polls, and NIP-56 reports. Each is a plain `EventReader` / `EventBuilder` pair — see [Readers & Builders](./readers-and-builders) for the base pattern. The parameterized-replaceable kinds (`Classified`, `TimeEvent`) need a `d` tag (`setIdentifier()`).
|
||||
|
||||
## Comment (kind 1111)
|
||||
|
||||
NIP-22 comments distinguish the **thread root** (uppercase `E`/`A`/`K`/`P` tags) from the **immediate parent** (lowercase `e`/`a`/`k`/`p`). Both are read as a `CommentRef` (`{id?, address?, kind?, pubkey?}`).
|
||||
|
||||
```typescript
|
||||
import {Comment, CommentBuilder} from "@welshman/domain"
|
||||
import type {CommentRef} from "@welshman/domain"
|
||||
|
||||
const comment = await Comment.fromEvent(event)
|
||||
comment.root() // CommentRef — uppercase tags (thread root)
|
||||
comment.parent() // CommentRef — lowercase tags (immediate parent)
|
||||
|
||||
// Set refs explicitly...
|
||||
const template = await new CommentBuilder()
|
||||
.setContent("nice thread")
|
||||
.setRoot(rootKind, rootId, rootPubkey)
|
||||
.setParent(parentKind, parentId, parentPubkey)
|
||||
.toTemplate()
|
||||
|
||||
// ...or derive them from events (uses the event's d tag as identifier)
|
||||
await new CommentBuilder()
|
||||
.setContent("reply")
|
||||
.setRootFromEvent(rootEvent)
|
||||
.setParentFromEvent(parentEvent)
|
||||
.toEvent(signer)
|
||||
```
|
||||
|
||||
::: warning Identifier quirk
|
||||
When you pass an `identifier` to `setRoot`/`setParent`, the builder pushes an `A`/`a` tag whose value is the `id` you passed — not a constructed `kind:pubkey:identifier` address. Be aware of this if you read those tags back expecting a full address.
|
||||
:::
|
||||
|
||||
## Thread (kind 11)
|
||||
|
||||
A NIP-7D forum thread root. Just a title plus the body content.
|
||||
|
||||
```typescript
|
||||
import {Thread, ThreadBuilder} from "@welshman/domain"
|
||||
|
||||
const thread = await Thread.fromEvent(event)
|
||||
thread.title() // "title" tag value
|
||||
|
||||
await new ThreadBuilder()
|
||||
.setTitle("Welcome")
|
||||
.setContent("Read the rules first.")
|
||||
.toEvent(signer)
|
||||
```
|
||||
|
||||
## Classified (kind 30402)
|
||||
|
||||
A NIP-99 marketplace listing. The price parses into a `ClassifiedPrice` (`{amount, currency, frequency}`), defaulting currency to `SAT`.
|
||||
|
||||
```typescript
|
||||
import {Classified, ClassifiedBuilder} from "@welshman/domain"
|
||||
import type {ClassifiedPrice} from "@welshman/domain"
|
||||
|
||||
const listing = await Classified.fromEvent(event)
|
||||
listing.title() // "title" tag value
|
||||
listing.summary() // "summary" tag value
|
||||
listing.price() // ClassifiedPrice | undefined
|
||||
listing.status() // "status" tag value
|
||||
listing.images() // "image" tag values
|
||||
listing.topics() // t-tag values
|
||||
|
||||
await new ClassifiedBuilder()
|
||||
.setIdentifier() // required d tag for kind 30402
|
||||
.setTitle("Bike for sale")
|
||||
.setSummary("lightly used")
|
||||
.setPrice(150, "USD", "") // amount, currency = "SAT", frequency = ""
|
||||
.setStatus("active")
|
||||
.setImages(["https://example.com/bike.jpg"])
|
||||
.setTopics(["bikes", "forsale"])
|
||||
.toEvent(signer)
|
||||
```
|
||||
|
||||
## TimeEvent (kind 31923)
|
||||
|
||||
A NIP-52 time-based calendar event.
|
||||
|
||||
```typescript
|
||||
import {TimeEvent, TimeEventBuilder} from "@welshman/domain"
|
||||
|
||||
const evt = await TimeEvent.fromEvent(event)
|
||||
evt.title() // "title" tag value
|
||||
evt.location() // "location" tag value
|
||||
evt.start() // unix seconds as int, or undefined
|
||||
evt.end() // unix seconds as int, or undefined
|
||||
|
||||
await new TimeEventBuilder()
|
||||
.setIdentifier() // required d tag for kind 31923
|
||||
.setTitle("Nostrica")
|
||||
.setLocation("Costa Rica")
|
||||
.setStart(startTs)
|
||||
.setEnd(endTs)
|
||||
.toEvent(signer)
|
||||
```
|
||||
|
||||
When both `start` and `end` are set, `buildTags` auto-generates one `["D", dayIndex]` tag per day in `[start, end)` (day-bucket index tags), so the event is discoverable by day.
|
||||
|
||||
## Poll (kind 1068) and PollResponse (kind 1018)
|
||||
|
||||
NIP-88 polls. A `Poll` has a title (content), options, a type, and an optional close time. Types are exported as `PollType` (`"singlechoice" | "multiplechoice"`), options as `PollOption`, and tallies as `PollResult`.
|
||||
|
||||
```typescript
|
||||
import {Poll, PollBuilder} from "@welshman/domain"
|
||||
import type {PollType, PollOption, PollResult} from "@welshman/domain"
|
||||
|
||||
const poll = await Poll.fromEvent(event)
|
||||
poll.title() // event.content (or "")
|
||||
poll.options() // PollOption[] — {id, label}
|
||||
poll.pollType() // PollType, default "singlechoice"
|
||||
poll.endsAt() // unix seconds | undefined
|
||||
poll.isClosed() // boolean (endsAt <= now)
|
||||
poll.urls() // "relay" tag values
|
||||
|
||||
await new PollBuilder()
|
||||
.setTitle("Favorite client?")
|
||||
.addOption("Coracle") // id defaults to a random id
|
||||
.addOption("Flotilla")
|
||||
.setPollType("singlechoice")
|
||||
.setEndsAt(closeTs)
|
||||
.toEvent(signer)
|
||||
```
|
||||
|
||||
`validate()` requires at least one option. To tally votes, pass the response events to `results`:
|
||||
|
||||
```typescript
|
||||
const result: PollResult = poll.results(responseEvents)
|
||||
result.options // [{id, label, votes}, …]
|
||||
result.voters // number of distinct voters
|
||||
```
|
||||
|
||||
`results` keeps only each pubkey's latest response, takes the first selection for single-choice polls, and the unique selections for multiple-choice.
|
||||
|
||||
A `PollResponse` is one voter's answer:
|
||||
|
||||
```typescript
|
||||
import {PollResponse, PollResponseBuilder} from "@welshman/domain"
|
||||
|
||||
const response = await PollResponse.fromEvent(event)
|
||||
response.pollId() // e-tag value
|
||||
response.selections() // unique "response" tag values
|
||||
|
||||
await new PollResponseBuilder()
|
||||
.setPollId(pollId)
|
||||
.addSelection(optionId) // deduped
|
||||
.toEvent(signer)
|
||||
```
|
||||
|
||||
`PollResponse.validate()` requires a pollId.
|
||||
|
||||
## Report (kind 1984)
|
||||
|
||||
A NIP-56 report flags a pubkey and/or an event with a reason.
|
||||
|
||||
```typescript
|
||||
import {Report, ReportBuilder} from "@welshman/domain"
|
||||
|
||||
const report = await Report.fromEvent(event)
|
||||
report.reportedPubkey() // p-tag value
|
||||
report.eventId() // e-tag value (tag[1])
|
||||
report.reason() // e-tag reason (tag[2])
|
||||
|
||||
await new ReportBuilder()
|
||||
.setReportedPubkey(pubkey)
|
||||
.setEventId(noteId)
|
||||
.setReason("spam")
|
||||
.toEvent(signer)
|
||||
```
|
||||
|
||||
`buildTags` emits `["p", pubkey]` and `["e", id, reason?]`.
|
||||
|
||||
## See also
|
||||
|
||||
- [Readers & Builders](./readers-and-builders) — the base `EventReader`/`EventBuilder` pattern, including `d`-tag validation for `Classified` and `TimeEvent`.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Handlers
|
||||
|
||||
NIP-89 lets clients advertise which event kinds they can handle, and lets users recommend handlers to each other. `@welshman/domain` models both sides: `Handler` (the handler's own information) and `HandlerRecommendation` (a user pointing at a handler). Both are parameterized-replaceable, so their builders need a `d` tag (`setIdentifier()`). See [Readers & Builders](./readers-and-builders) for the base pattern.
|
||||
|
||||
## Handler information (kind 31990)
|
||||
|
||||
`Handler` carries a JSON metadata blob (name, about, picture, …) in its content plus the list of kinds it handles as `k` tags. The metadata shape is exported as `HandlerMeta`.
|
||||
|
||||
```typescript
|
||||
import {Handler, HandlerBuilder} from "@welshman/domain"
|
||||
import type {HandlerMeta} from "@welshman/domain"
|
||||
|
||||
const handler = await Handler.fromEvent(event)
|
||||
handler.name() // string | undefined
|
||||
handler.about() // string | undefined
|
||||
handler.picture() // string | undefined
|
||||
handler.website() // string | undefined
|
||||
handler.lud16() // string | undefined
|
||||
handler.nip05() // string | undefined
|
||||
handler.kinds() // number[] — the k tags, as numbers
|
||||
handler.values // the raw decoded HandlerMeta object
|
||||
```
|
||||
|
||||
The builder seeds metadata from the reader and lifts the `k` tags into its own field. Setters mirror the getters; `setKinds` takes an array of kind numbers.
|
||||
|
||||
```typescript
|
||||
const template = await new HandlerBuilder()
|
||||
.setIdentifier() // required d tag for kind 31990
|
||||
.setName("My Client")
|
||||
.setAbout("a great nostr app")
|
||||
.setKinds([1, 30023]) // writes ["k", "1"], ["k", "30023"]
|
||||
.toTemplate()
|
||||
```
|
||||
|
||||
Available setters: `setName`, `setAbout`, `setPicture`, `setWebsite`, `setLud16`, `setNip05`, `setKinds(kinds)`. `buildContent` re-serializes `values` to JSON; `buildTags` emits the kind tags.
|
||||
|
||||
## Handler recommendation (kind 31989)
|
||||
|
||||
`HandlerRecommendation` is a list of `a` tags pointing at handler events, optionally annotated with a relay hint and a platform marker (e.g. `"web"`).
|
||||
|
||||
```typescript
|
||||
import {HandlerRecommendation, HandlerRecommendationBuilder} from "@welshman/domain"
|
||||
|
||||
const rec = await HandlerRecommendation.fromEvent(event)
|
||||
rec.addressTags() // raw a-tags, e.g. [["a", "31990:pk:d", "wss://…", "web"]]
|
||||
rec.addresses() // just the address values
|
||||
rec.handlerAddress() // prefers the a-tag whose last element is "web", else the first → tag[1]
|
||||
|
||||
const template = await new HandlerRecommendationBuilder()
|
||||
.setIdentifier() // required d tag for kind 31989
|
||||
.addRecommendation("31990:pubkey:d", "wss://relay.example", "web")
|
||||
.toTemplate()
|
||||
```
|
||||
|
||||
`addRecommendation(address, relay?, platform?)` writes `["a", address, relay || "", platform || ""]` and is deduped by address. `removeRecommendation(address)` filters the address tags.
|
||||
|
||||
## See also
|
||||
|
||||
- [Readers & Builders](./readers-and-builders) — the base pattern, including `d`-tag validation for these parameterized-replaceable kinds.
|
||||
@@ -0,0 +1,88 @@
|
||||
# @welshman/domain
|
||||
|
||||
[](https://npmjs.com/package/@welshman/domain)
|
||||
|
||||
Stateless utilities for translating nostr events to and from domain objects. Where `@welshman/util` gives you the raw building blocks — events, tags, kind constants, tag getters — `@welshman/domain` gives you a typed, ergonomic object per kind: a `Profile` you can ask `.name()`, a `FollowList` you can ask `.pubkeys()`, a `ZapReceipt` you can `.verify()`. Each of those comes with a matching builder that turns edits back into a signable event template.
|
||||
|
||||
## The core idea: Readers and Builders
|
||||
|
||||
Every supported kind is modeled by a pair of classes:
|
||||
|
||||
- A **Reader** — a read-only view over a single `TrustedEvent`. You construct it from an event, and it decodes the content/tags into convenient getters (`profile.name()`, `list.pubkeys()`, `zap.amount()`). Readers are stateless: they hold the event and answer questions about it.
|
||||
- A **Builder** — a mutable, chainable producer of an `EventTemplate`. You construct it empty (to author a new event) or from a Reader (to edit an existing one), apply setters, and finish with `toTemplate()` / `toRumor()` / `toEvent()`.
|
||||
|
||||
```typescript
|
||||
import {Profile, ProfileBuilder} from "@welshman/domain"
|
||||
|
||||
// Read an event into a domain object
|
||||
const profile = await Profile.fromEvent(event)
|
||||
profile.name() // string | undefined
|
||||
profile.display() // best-effort display name, falls back to a short npub
|
||||
|
||||
// Build a new event template
|
||||
const template = await new ProfileBuilder()
|
||||
.setName("alice")
|
||||
.setAbout("hello nostr")
|
||||
.toTemplate() // EventTemplate {kind, content, tags}
|
||||
```
|
||||
|
||||
Readers and Builders are two halves of a round-trip. `reader.builder()` returns the matching builder pre-populated from the reader, so editing is just "read, mutate, rebuild":
|
||||
|
||||
```typescript
|
||||
const next = await profile.builder().setName("alice2").toTemplate()
|
||||
```
|
||||
|
||||
## Where it sits
|
||||
|
||||
`@welshman/domain` lives between `@welshman/util` and `@welshman/app`.
|
||||
|
||||
- It depends on `@welshman/util` for the primitives it wraps — kind constants (`PROFILE`, `FOLLOWS`, `RELAYS`, …), tag getters (`getTagValue`, `getPubkeyTagValues`, …), `Address`, `stamp`/`prep`, and the `TrustedEvent`/`EventTemplate` types. It depends on `@welshman/signer` for the `ISigner` interface (used to sign, and to decrypt/encrypt private list tags).
|
||||
- `@welshman/app` is built on top of it. The reactive data plugins (`Profiles`, `FollowLists`, `MuteLists`, `RelayLists`, …) decode repository events into exactly these Reader objects and expose the Builders' setters as collection methods. If you have used `app.use(Profiles).one(pk)` and gotten a `Profile` back, that `Profile` is this package's `Profile`.
|
||||
|
||||
This means `@welshman/domain` is the right layer to reach for when you are working with events directly — parsing or constructing them — without the app's reactivity, networking, or repository. It has no module-level state and no side effects.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @welshman/domain
|
||||
# or
|
||||
pnpm add @welshman/domain
|
||||
yarn add @welshman/domain
|
||||
```
|
||||
|
||||
Peer dependencies: the welshman workspace packages it builds on (`@welshman/lib`, `@welshman/util`, `@welshman/signer`, and `@welshman/feeds` for the saved-feed kind), plus `nostr-tools`.
|
||||
|
||||
## A larger example
|
||||
|
||||
```typescript
|
||||
import {FollowList, FollowListBuilder} from "@welshman/domain"
|
||||
|
||||
// Read — pass a signer to unlock private (encrypted) tags on lists.
|
||||
// Without the author's own signer, only public tags are visible.
|
||||
const list = await FollowList.fromEvent(event, signer)
|
||||
list.pubkeys() // string[] of followed pubkeys
|
||||
list.includes(somePubkey) // boolean
|
||||
|
||||
// Edit and sign in one chain. buildContent encrypts private tags
|
||||
// (NIP-44, self-encrypted to the author) when there are any.
|
||||
const signed = await list.builder()
|
||||
.addFollow(["p", newPubkey])
|
||||
.toEvent(signer) // SignedEvent
|
||||
|
||||
// Or start fresh
|
||||
const template = await new FollowListBuilder()
|
||||
.addFollow(["p", pubkeyA])
|
||||
.addFollow(["p", pubkeyB])
|
||||
.toTemplate()
|
||||
```
|
||||
|
||||
## Pages
|
||||
|
||||
- [Readers & Builders](./readers-and-builders) — the `EventReader`/`EventBuilder` and `ListReader`/`ListBuilder` base classes in depth: construction, async parsing, getters/setters, the build pipeline, validation, extra-tag passthrough, and how list encryption works.
|
||||
- [Profile](./profile) — kind-0 metadata (`Profile` / `ProfileBuilder`).
|
||||
- [Lists](./lists) — NIP-51 public/private lists: follows, mutes, pins, bookmarks, relay sets, and friends.
|
||||
- [Rooms](./rooms) — NIP-29 group rooms: metadata, membership, and the join/leave/create/delete ops.
|
||||
- [Relay membership](./relay-membership) — Flotilla relay/space membership ops and snapshots.
|
||||
- [Handlers](./handlers) — NIP-89 handler information and recommendations.
|
||||
- [Zaps](./zaps) — NIP-57/NIP-75 zap requests, receipts, and goals.
|
||||
- [Content](./content) — comments, threads, classifieds, calendar events, polls, and reports.
|
||||
@@ -0,0 +1,115 @@
|
||||
# Lists
|
||||
|
||||
NIP-51 lists — follows, mutes, pins, bookmarks, relay lists, and friends — all share one shape: a set of **public** tags and an optional set of **private** tags that are NIP-44-encrypted into the event's content, self-encrypted to the author. Every list kind is a thin subclass of `ListReader` / `ListBuilder`, so once you know the shared pattern you know all of them. The base classes are documented in depth in [Readers & Builders](./readers-and-builders); this page covers the per-kind getters and setters.
|
||||
|
||||
## The shared pattern
|
||||
|
||||
A `ListReader` exposes its tags as a merged view (`publicTags` + `privateTags`), and every getter reads through that merged view. **Private tags only decrypt when you pass the list author's own signer** to `fromEvent`/`factory`; reading someone else's list, or your own without a signer, yields public tags only.
|
||||
|
||||
```typescript
|
||||
import {MuteList, MuteListBuilder} from "@welshman/domain"
|
||||
|
||||
// Read. Pass the author's signer to surface private (encrypted) entries.
|
||||
const mutes = await MuteList.fromEvent(event, signer)
|
||||
mutes.pubkeys() // both public and private mutes, when decrypted
|
||||
mutes.includes(somePk) // boolean
|
||||
|
||||
// Build. Public vs private goes to different setters; encryption happens
|
||||
// in buildContent when there are private tags.
|
||||
const signed = await new MuteListBuilder()
|
||||
.mutePublicly(pubkeyA)
|
||||
.mutePrivately(pubkeyB)
|
||||
.toEvent(signer) // toEvent/toRumor always pass the signer through
|
||||
```
|
||||
|
||||
Every list builder inherits the base tag mutators from `ListBuilder`:
|
||||
|
||||
```typescript
|
||||
builder
|
||||
.addPublic(...tags) // append to the public set
|
||||
.addPrivate(...tags) // append to the private (encrypted) set
|
||||
.keepPublic(pred) // filter; also keepPrivate, keep (both sets)
|
||||
.dropPublic(pred) // filter out; also dropPrivate, drop (both sets)
|
||||
.clearPublic() // empty; also clearPrivate, clear (both sets)
|
||||
```
|
||||
|
||||
The per-kind methods below (`addFollow`, `mutePrivately`, `bookmarkPublicly`, …) are just named wrappers over these. Anything ending in `*Privately` is `addPrivate`; everything else is `addPublic`; `remove*`/`un*` is a `drop`.
|
||||
|
||||
::: tip toTemplate and the signer
|
||||
Because encryption lives in `ListBuilder.buildContent`, `toTemplate(signer)` only needs a signer when you have actually written private tags. With private mutes/follows you must pass one (`A signer is required to encrypt private tags`); a purely public edit needs none. `toEvent`/`toRumor` always pass the signer through for you.
|
||||
:::
|
||||
|
||||
## The kinds
|
||||
|
||||
| Class | Kind | NIP | Reader getters | Builder methods |
|
||||
|---|---|---|---|---|
|
||||
| `FollowList` | 3 | NIP-02 | `pubkeys()`, `includes(pk)` | `addFollow(tag)`, `removeFollow(value)` |
|
||||
| `MuteList` | 10000 | NIP-51 | `pubkeys()`, `includes(pk)` | `mutePublicly(pk)`, `mutePrivately(pk)`, `unmute(pk)` |
|
||||
| `PinList` | 10001 | NIP-51 | `ids()`, `addresses()` | `pinPublicly(tag)`, `pinPrivately(tag)`, `unpin(value)` |
|
||||
| `RelayList` | 10002 | NIP-65 | `urls()`, `readUrls()`, `writeUrls()` | `addUrl(url, mode)`, `removeUrl(url, mode)`, `setReadUrls(urls)`, `setWriteUrls(urls)`, `setTags(tags)` |
|
||||
| `BookmarkList` | 10003 | NIP-51 | `ids()`, `addresses()`, `topics()`, `urls()` | `bookmarkPublicly(tag)`, `bookmarkPrivately(tag)`, `removeBookmark(value)` |
|
||||
| `GroupList` | 10004 | NIP-51 | `addresses()` | `addGroup(address, relayHint?)`, `removeGroup(address)` |
|
||||
| `BlockedRelayList` | 10006 | NIP-51 | `urls()`, `includes(url)` | `addUrl(url)`, `removeUrl(url)`, `setUrls(urls)` |
|
||||
| `SearchRelayList` | 10007 | NIP-51 | `urls()`, `includes(url)` | `addUrl(url)`, `removeUrl(url)`, `setUrls(urls)` |
|
||||
| `RoomList` | 10009 | NIP-51 | `groups()`, `groupTags()` | `join(groupId, url)`, `leave(groupId)` |
|
||||
| `FeedList` | 10014 | NIP-51 | `addresses()`, `includes(address)` | `addFeed(address, relayHint?)`, `addFeedPrivately(address, relayHint?)`, `removeFeed(address)` |
|
||||
| `TopicList` | 10015 | NIP-51 | `topics()`, `addresses()`, `includes(topic)` | `followPublicly(topic)`, `followPrivately(topic)`, `follow(topic)`, `unfollow(topic)` |
|
||||
| `EmojiList` | 10030 | NIP-51 | `emojis()`, `emojiSets()` | `addEmoji(shortcode, url)`, `removeEmoji(value)`, `addEmojiSet(address)`, `removeEmojiSet(value)` |
|
||||
| `MessagingRelayList` | 10050 | NIP-17 | `urls()` | `addUrl(url)`, `removeUrl(url)`, `setUrls(urls)` |
|
||||
| `BlossomServerList` | 10063 | Blossom BUD-03 | `urls()`, `includes(url)` | `addUrl(url)`, `removeUrl(url)`, `setUrls(urls)` |
|
||||
| `RelaySet` | 30002 | NIP-51 | `title()`, `description()`, `image()`, `urls()` | `setTitle`, `setDescription`, `setImage`, `addUrl(url)`, `removeUrl(url)`, `setUrls(urls)` |
|
||||
|
||||
Each comes with a matching `*Builder` (`FollowListBuilder`, `MuteListBuilder`, …).
|
||||
|
||||
## Public vs private (encrypted) tags
|
||||
|
||||
A few of these kinds expose a deliberate public/private choice, mapping to the encrypted-content split:
|
||||
|
||||
```typescript
|
||||
import {FollowListBuilder, BookmarkListBuilder, TopicListBuilder} from "@welshman/domain"
|
||||
|
||||
// Follows are public-only in practice (addFollow → addPublic)
|
||||
new FollowListBuilder().addFollow(["p", pubkey])
|
||||
|
||||
// Bookmarks, pins, mutes, feeds, and topics offer both:
|
||||
new BookmarkListBuilder().bookmarkPublicly(["e", noteId]) // visible to everyone
|
||||
new BookmarkListBuilder().bookmarkPrivately(["e", noteId]) // encrypted, author-only
|
||||
new TopicListBuilder().followPrivately("nostr") // encrypted interest
|
||||
```
|
||||
|
||||
The relay-config lists (`RelayList`, `BlockedRelayList`, `SearchRelayList`, `MessagingRelayList`, `BlossomServerList`, `RelaySet`) keep everything in public tags — there is no private variant of `addUrl`.
|
||||
|
||||
## Notes per family
|
||||
|
||||
**`RelayList` (NIP-65).** Read/write are encoded by the `r`-tag's third element; a bare `["r", url]` counts as both. `urls()` returns all, `readUrls()`/`writeUrls()` filter by `RelayMode`. On the builder, `addUrl(url, mode)`/`removeUrl(url, mode)` preserve the *other* mode if it was present, so flipping write on a read-only relay produces a bare both-mode tag rather than clobbering it. URLs are normalized via `normalizeRelayUrl`.
|
||||
|
||||
```typescript
|
||||
import {RelayListBuilder} from "@welshman/domain"
|
||||
import {RelayMode} from "@welshman/util"
|
||||
|
||||
await new RelayListBuilder()
|
||||
.addUrl("wss://relay.example", RelayMode.Write)
|
||||
.addUrl("wss://read.example", RelayMode.Read)
|
||||
.toEvent(signer)
|
||||
```
|
||||
|
||||
**Relay/server set lists.** `BlockedRelayList`, `SearchRelayList`, and `MessagingRelayList` store URLs under the `relay` tag key; `BlossomServerList` uses the `server` key instead. All four share `addUrl`/`removeUrl`/`setUrls` (where `setUrls` clears then re-adds), with `normalizeRelayUrl` applied.
|
||||
|
||||
**`RelaySet` (kind 30002).** A named, addressable relay set — it is parameterized-replaceable, so the builder needs a `d` tag (`setIdentifier()`). Its constructor lifts `title`/`description`/`image` out of the tags into dedicated fields, and `buildTags` prepends them ahead of the `relay` tags.
|
||||
|
||||
```typescript
|
||||
import {RelaySetBuilder} from "@welshman/domain"
|
||||
|
||||
await new RelaySetBuilder()
|
||||
.setIdentifier() // required d tag for kind 30002
|
||||
.setTitle("My relays")
|
||||
.addUrl("wss://relay.example")
|
||||
.toEvent(signer)
|
||||
```
|
||||
|
||||
**`RoomList` (kind 10009).** A simple-groups membership list. `join(groupId, url)` writes `["group", groupId, url]`; `leave(groupId)` drops the matching tag. (NIP-29 room *operations* themselves live in [Rooms](./rooms).)
|
||||
|
||||
## See also
|
||||
|
||||
- [Readers & Builders](./readers-and-builders) — the `ListReader`/`ListBuilder` base, including exactly how private-tag encryption and decryption work.
|
||||
- [Rooms](./rooms) — NIP-29 room ops, referenced by `RoomList`.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Profile
|
||||
|
||||
`Profile` / `ProfileBuilder` model NIP-01 kind-0 metadata — the JSON blob that carries a user's name, picture, NIP-05, lightning address, and so on. Like every kind in `@welshman/domain`, it is a thin pair of classes over the [base Reader/Builder machinery](./readers-and-builders): a read-only view plus a chainable producer of an event template.
|
||||
|
||||
The content of a kind-0 event is a JSON object, so `Profile.parse` decodes it into a `values` record and the getters read fields off that.
|
||||
|
||||
## Reading
|
||||
|
||||
```typescript
|
||||
import {Profile} from "@welshman/domain"
|
||||
|
||||
const profile = await Profile.fromEvent(event) // no signer needed — kind 0 is not encrypted
|
||||
|
||||
profile.name() // string | undefined
|
||||
profile.displayName() // values.display_name
|
||||
profile.about() // string | undefined
|
||||
profile.picture() // string | undefined
|
||||
profile.banner() // string | undefined
|
||||
profile.website() // string | undefined
|
||||
profile.nip05() // string | undefined
|
||||
profile.lnurl() // lud16/lud06 → lnurl, via parseLnUrl
|
||||
profile.values // the raw decoded JSON object
|
||||
```
|
||||
|
||||
`display(fallback = "")` is the best-effort label you usually want in UI. It prefers `name`, then `display_name` (each truncated to 60 chars via `ellipsize`), and finally falls back to a shortened npub:
|
||||
|
||||
```typescript
|
||||
profile.display() // "alice" · "npub1abc…wxyz" · fallback
|
||||
profile.display("anonymous") // fallback used only when there is nothing else
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
Construct empty to author a new profile, or from a reader to edit one. Setters are chainable; finish with `toTemplate()` / `toEvent(signer)`.
|
||||
|
||||
```typescript
|
||||
import {ProfileBuilder} from "@welshman/domain"
|
||||
|
||||
const template = await new ProfileBuilder()
|
||||
.setName("alice")
|
||||
.setAbout("hello nostr")
|
||||
.setPicture("https://example.com/avatar.png")
|
||||
.setNip05("alice@example.com")
|
||||
.toTemplate() // EventTemplate {kind: 0, content, tags: []}
|
||||
```
|
||||
|
||||
Editing round-trips through the reader. `buildContent` re-serializes `values` to JSON, so unknown profile fields you never touched are preserved:
|
||||
|
||||
```typescript
|
||||
const signed = await profile.builder()
|
||||
.setAbout("updated bio")
|
||||
.toEvent(signer) // SignedEvent
|
||||
```
|
||||
|
||||
Available setters: `setName`, `setDisplayName`, `setNip05`, `setAbout`, `setBanner`, `setPicture`, `setWebsite`, plus `update(values)` to merge an arbitrary object into `values`.
|
||||
|
||||
```typescript
|
||||
new ProfileBuilder().update({name: "alice", lud16: "alice@walletofsatoshi.com"})
|
||||
```
|
||||
|
||||
::: warning Asymmetric display name
|
||||
`setDisplayName(x)` writes `values.displayName` (camelCase), but `displayName()` reads `values.display_name` (snake_case, the NIP-01 field). They are **not** symmetric — a value you set with `setDisplayName` will not be read back by `displayName()`. Use `update({display_name: x})` if you need the getter to see it.
|
||||
:::
|
||||
|
||||
## Free functions
|
||||
|
||||
`Profile.ts` also exports two standalone helpers, used internally by the getters above but available on their own:
|
||||
|
||||
```typescript
|
||||
import {parseLnUrl, displayPubkey} from "@welshman/domain"
|
||||
|
||||
// Resolve an lnurl from a metadata object: checks lud06 then lud16.
|
||||
parseLnUrl({lud16: "alice@example.com"}) // string | undefined
|
||||
|
||||
// A short, human-readable npub: first 8 chars + "…" + last 5.
|
||||
displayPubkey(pubkey) // "npub1abc…wxyz"
|
||||
```
|
||||
|
||||
## See also
|
||||
|
||||
- [Readers & Builders](./readers-and-builders) — the base `EventReader`/`EventBuilder` pattern every kind shares.
|
||||
@@ -0,0 +1,270 @@
|
||||
# Readers & Builders
|
||||
|
||||
Every kind in `@welshman/domain` is a thin subclass of four base classes:
|
||||
|
||||
- `EventReader` — read-only view over one event.
|
||||
- `EventBuilder` — mutable producer of an event template.
|
||||
- `ListReader` — `EventReader` with a public/private (encrypted) tag split.
|
||||
- `ListBuilder` — `EventBuilder` with the same split, and NIP-44 encryption baked into its build step.
|
||||
|
||||
Understanding these four classes means you understand every kind: the per-kind files (`Profile`, `FollowList`, `ZapReceipt`, …) only add getters and setters on top of the machinery described here.
|
||||
|
||||
## EventReader
|
||||
|
||||
A `Reader` wraps a single `TrustedEvent` and answers questions about it. It is abstract — each kind pins its `kind` and provides a `builder()` — but the construction, parsing, and base getters all live here.
|
||||
|
||||
### Construction: factory and fromEvent
|
||||
|
||||
You never call the constructor directly to get a parsed reader, because parsing is async. Use one of the two static entry points instead. Both validate the event's kind (throwing `Expected a kind X event, got kind Y` on mismatch) and then `await reader.parse(signer)` before handing the reader back.
|
||||
|
||||
```typescript
|
||||
import {Profile} from "@welshman/domain"
|
||||
|
||||
// One-shot: the usual entry point.
|
||||
const profile = await Profile.fromEvent(event)
|
||||
const profile2 = await Profile.fromEvent(event, signer) // signer optional
|
||||
|
||||
// Reusable, class-bound factory over a fixed signer. Returns an
|
||||
// async (event) => Promise<Reader>, safe to use point-free.
|
||||
const toProfile = Profile.factory(signer)
|
||||
const profile3 = await toProfile(event)
|
||||
```
|
||||
|
||||
`factory(signer?)` is what `@welshman/app`'s data plugins use as their `eventToItem`: a single bound function that turns each incoming event into a parsed reader. Both `fromEvent` and the function `factory` returns are **async**.
|
||||
|
||||
The signer is always optional. A reader that does not need it (every non-list kind) simply ignores it, so callers can pass a signer unconditionally without knowing which kinds carry encrypted content.
|
||||
|
||||
### Async parse
|
||||
|
||||
```typescript
|
||||
protected async parse(signer?: ISigner): Promise<void> {}
|
||||
```
|
||||
|
||||
`parse` is the one async hook. The base implementation is a no-op; subclasses override it to decode whatever they need:
|
||||
|
||||
- `Profile.parse` JSON-parses `event.content` into a `values` object.
|
||||
- `ZapReceipt.parse` decodes the embedded zap-request JSON out of the `description` tag.
|
||||
- `ListReader.parse` decrypts the private tags (see below).
|
||||
|
||||
Because `parse` is the only async step, everything downstream — the getters — is synchronous.
|
||||
|
||||
### Getters
|
||||
|
||||
All base getters are synchronous reads over the wrapped event:
|
||||
|
||||
| Getter | Returns |
|
||||
|---|---|
|
||||
| `id()` | `event.id` |
|
||||
| `author()` | `event.pubkey` |
|
||||
| `content()` | `event.content` |
|
||||
| `tags()` | `event.tags` (overridden by `ListReader` to merge public + private) |
|
||||
| `createdAt()` | `event.created_at` |
|
||||
| `identifier()` | the `d` tag value |
|
||||
| `address()` | the replaceable address `kind:pubkey:d` (via `getAddress`) |
|
||||
| `group()` | the NIP-29 `h` tag value |
|
||||
| `protect()` | `true` if a `["-"]` tag is present |
|
||||
| `expires()` | the parsed `expiration` tag as a number, or `undefined` |
|
||||
|
||||
```typescript
|
||||
const reader = await SomeKind.fromEvent(event)
|
||||
reader.author() // pubkey
|
||||
reader.identifier() // d tag, if any
|
||||
reader.expires() // number | undefined
|
||||
```
|
||||
|
||||
Each subclass adds its own getters on top — `profile.name()`, `followList.pubkeys()`, `zapGoal.amount()`, and so on.
|
||||
|
||||
### builder()
|
||||
|
||||
```typescript
|
||||
abstract builder(): EventBuilder<EventReader>
|
||||
```
|
||||
|
||||
Every reader returns its matching builder, pre-populated from itself — internally just `new XBuilder(this)`. This is the read-edit-rebuild bridge:
|
||||
|
||||
```typescript
|
||||
const edited = await profile.builder().setName("alice").toTemplate()
|
||||
```
|
||||
|
||||
## EventBuilder
|
||||
|
||||
A `Builder` is a mutable, chainable producer of an `EventTemplate`. Construct it empty to author a new event, or from a reader to edit an existing one. Every setter returns `this`.
|
||||
|
||||
### Construction and extra-tag passthrough
|
||||
|
||||
```typescript
|
||||
constructor(readonly reader?: Reader)
|
||||
```
|
||||
|
||||
When you pass a reader, the builder seeds `content` from `reader.event.content` and copies **all** of `event.tags` into `extraTags`. It then *consumes* the tags it manages — `h`, `-`, `expiration`, and `d` — lifting each out of `extraTags` into a dedicated field (`groupTag`, `protectTag`, `expirationTag`, `identifierTag`).
|
||||
|
||||
Whatever remains in `extraTags` is **passed through verbatim** when the event is rebuilt. This is the extra-tag passthrough guarantee: tags the package does not model (or a subclass does not claim) survive an edit round-trip instead of being silently dropped.
|
||||
|
||||
```typescript
|
||||
protected consumeTags(key: string): string[][]
|
||||
```
|
||||
|
||||
Subclasses call `consumeTags` in their own constructors to lift the tags they understand out of the passthrough set. For example `RelaySetBuilder` consumes `title`/`description`/`image`, `CommentBuilder` consumes its root/parent ref tags, and `ListBuilder` takes over everything left as `publicTags`.
|
||||
|
||||
### Setters
|
||||
|
||||
The base behavior setters, all chainable:
|
||||
|
||||
```typescript
|
||||
new SomeBuilder()
|
||||
.setContent("…")
|
||||
.setGroup(groupId) // h tag / clearGroup()
|
||||
.setProtected(true) // ["-"] tag
|
||||
.setExpiration(timestamp) // / clearExpiration()
|
||||
.setIdentifier() // d tag (defaults to a random id) / clearIdentifier()
|
||||
```
|
||||
|
||||
`setIdentifier(identifier = randomId())` defaults to a freshly generated id, which is what you want for new parameterized-replaceable events. Each subclass adds its own setters (`setName`, `addFollow`, `setAmount`, …) on top of these.
|
||||
|
||||
### The build pipeline
|
||||
|
||||
Subclasses customize the output by overriding two protected hooks (both may be async, both receive the optional signer) and `validate`:
|
||||
|
||||
```typescript
|
||||
protected buildTags(signer?): MaybeAsync<string[][]> // default: [] — kind-specific tags
|
||||
protected buildContent(signer?): MaybeAsync<string> // default: this.content
|
||||
protected validate(): void // default: enforce d tag for replaceable kinds
|
||||
```
|
||||
|
||||
`validate` by default throws `A d tag is required for kind X` for parameterized-replaceable kinds with no identifier. Subclasses call `super.validate()` and add their own checks — `ZapGoal requires a title`, `RoomDelete` requires an `h` group, and so on.
|
||||
|
||||
The three output methods assemble these into a result. All are async:
|
||||
|
||||
```typescript
|
||||
const template = await builder.toTemplate(signer?) // EventTemplate {kind, content, tags}
|
||||
const rumor = await builder.toRumor(signer) // HashedEvent (needs signer for pubkey)
|
||||
const signed = await builder.toEvent(signer) // SignedEvent (signs + stamps)
|
||||
```
|
||||
|
||||
`toTemplate` is the heart of it. It runs `validate()`, then awaits `buildContent`, `buildTags`, and the internal behavior tags in parallel, and concatenates the final tag list as:
|
||||
|
||||
```
|
||||
[...implTags, ...behaviorTags, ...extraTags]
|
||||
```
|
||||
|
||||
That ordering is the passthrough in action: kind-specific tags first, then the group/protect/expiration/identifier tags, then the untouched leftovers.
|
||||
|
||||
The signer rules:
|
||||
|
||||
- `toTemplate`'s signer is **optional** and only consulted by kinds whose `buildContent`/`buildTags` need it — in practice, the encrypting list kinds.
|
||||
- `toRumor` and `toEvent` always **require** a signer (they need the author's pubkey, and `toEvent` signs).
|
||||
|
||||
```typescript
|
||||
// Plain kind: no signer needed for a template
|
||||
const template = await new ProfileBuilder().setName("alice").toTemplate()
|
||||
|
||||
// Any kind, signed
|
||||
const event = await new ProfileBuilder().setName("alice").toEvent(signer)
|
||||
```
|
||||
|
||||
## ListReader
|
||||
|
||||
NIP-51-style lists split their tags into a public set and a private (encrypted) set. `ListReader` extends `EventReader` and handles the decryption.
|
||||
|
||||
```typescript
|
||||
decrypted = false
|
||||
publicTags: string[][] = []
|
||||
privateTags: string[][] = []
|
||||
```
|
||||
|
||||
Its `parse` override:
|
||||
|
||||
1. Sets `publicTags = event.tags`.
|
||||
2. If `event.content` is empty, there is nothing to decrypt → `decrypted = true`.
|
||||
3. Otherwise, if a signer is supplied **and it belongs to the event's author** (`signer.getPubkey() === event.pubkey`), it decrypts the content, marks `decrypted = true`, parses the JSON array, and keeps only well-formed string-tuple tags into `privateTags`. A decryption failure is swallowed — `decrypted` simply stays `false`.
|
||||
|
||||
The practical consequence: **private tags only appear when you pass the author's own signer** to `fromEvent`/`factory`. Reading someone else's list, or your own list without a signer, gives you the public tags only.
|
||||
|
||||
```typescript
|
||||
const list = await MuteList.fromEvent(event, signer) // signer = the list author's
|
||||
list.pubkeys() // includes both public and private mutes, when decrypted
|
||||
```
|
||||
|
||||
`tags()` is overridden to return `[...publicTags, ...privateTags]`, so every inherited getter that reads `this.tags()` transparently sees the merged view.
|
||||
|
||||
## ListBuilder
|
||||
|
||||
`ListBuilder` extends `EventBuilder` with the same public/private split and a chainable set of tag mutators. Its constructor takes over the leftover `extraTags` as `publicTags` (`this.publicTags = this.extraTags.splice(0)`) and copies `privateTags` from the reader.
|
||||
|
||||
### Tag mutators
|
||||
|
||||
All chainable (return `this`):
|
||||
|
||||
```typescript
|
||||
builder
|
||||
.addPublic(...tags) // append to public set
|
||||
.addPrivate(...tags) // append to private (encrypted) set
|
||||
.keepPublic(pred) // filter public to matches; also keepPrivate, keep (both)
|
||||
.dropPublic(pred) // filter out matches; also dropPrivate, drop (both)
|
||||
.clearPublic() // empty the set; also clearPrivate, clear (both)
|
||||
```
|
||||
|
||||
Subclasses build their domain methods on these. For instance `FollowListBuilder.addFollow(tag)` is `addPublic(tag)`, and `MuteListBuilder` exposes `mutePublicly` (public) vs `mutePrivately` (private).
|
||||
|
||||
### validate
|
||||
|
||||
```typescript
|
||||
protected validate()
|
||||
```
|
||||
|
||||
`ListBuilder.validate` throws `Unable to modify list when decryption was not performed` if the source event had encrypted content that was never decrypted (because you did not pass the author's signer) yet you are trying to write private tags. This guards against clobbering private data you could not read.
|
||||
|
||||
### buildContent: where encryption lives
|
||||
|
||||
This is the important part. In the old `@welshman/util` design, encryption was a separate `Encryptable` wrapper you composed around an event. In `@welshman/domain` it is folded directly into the list builder's `buildContent`:
|
||||
|
||||
```typescript
|
||||
protected async buildContent(signer?: ISigner): Promise<string> {
|
||||
// Preserve the original ciphertext when we never decrypted it.
|
||||
if (this.reader?.decrypted === false) return this.reader.event.content
|
||||
|
||||
// No need to encrypt an empty array
|
||||
if (this.privateTags.length === 0) return ""
|
||||
|
||||
if (!signer) {
|
||||
throw new Error("A signer is required to encrypt private tags")
|
||||
}
|
||||
|
||||
const pubkey = await signer.getPubkey()
|
||||
|
||||
return signer.nip44.encrypt(pubkey, JSON.stringify(this.privateTags))
|
||||
}
|
||||
```
|
||||
|
||||
Three branches:
|
||||
|
||||
1. **Never decrypted** — return the original ciphertext untouched. You can edit public tags on a list you could not decrypt without destroying its private contents.
|
||||
2. **No private tags** — return `""`. Nothing to encrypt.
|
||||
3. **Has private tags** — require a signer (else throw `A signer is required to encrypt private tags`), then `signer.nip44.encrypt(pubkey, JSON.stringify(privateTags))`. The encryption is **NIP-44, self-encrypted to the author's own pubkey**.
|
||||
|
||||
`buildTags` simply returns `publicTags`. Because encryption happens here and needs the signer, list kinds are the case where `toTemplate(signer)` actually uses its optional argument:
|
||||
|
||||
```typescript
|
||||
import {MuteListBuilder} from "@welshman/domain"
|
||||
|
||||
// Private mute → buildContent encrypts, so toTemplate needs the signer
|
||||
const template = await new MuteListBuilder()
|
||||
.mutePrivately(targetPubkey)
|
||||
.toTemplate(signer)
|
||||
|
||||
// toEvent/toRumor always pass the signer through for you
|
||||
const signed = await new MuteListBuilder()
|
||||
.mutePublicly(targetPubkey)
|
||||
.toEvent(signer)
|
||||
```
|
||||
|
||||
## Old API → new API
|
||||
|
||||
| Old (`@welshman/util` helpers) | New (`@welshman/domain`) |
|
||||
|---|---|
|
||||
| `readProfile(event)` / free `read*` functions | `await Profile.fromEvent(event)` (Reader per kind) |
|
||||
| `editProfile(...)` / free `create*`/`edit*` functions | `new ProfileBuilder(reader?)` → `toTemplate()/toEvent()` |
|
||||
| `Encryptable` wrapper around an event | `ListBuilder.buildContent` (NIP-44, self-encrypted) — no separate wrapper |
|
||||
| manual `signer.nip44.encrypt(...)` for private list tags | `addPrivate(...)` then `toTemplate(signer)` |
|
||||
| manual `decrypt(...)` to read private tags | pass the author's signer to `fromEvent`/`factory` |
|
||||
| hand-built `{kind, content, tags}` templates | `builder.toTemplate(signer?)` |
|
||||
@@ -0,0 +1,78 @@
|
||||
# Relay membership
|
||||
|
||||
These kinds model **relay-level** membership — Flotilla's notion of joining a relay/space, distinct from NIP-29 room membership (which is scoped to a room by an `h` tag). Where [Rooms](./rooms) deal with groups hosted *on* a relay, these events deal with belonging to the relay itself. They are all plain `EventReader` / `EventBuilder` subclasses; see [Readers & Builders](./readers-and-builders) for the base pattern.
|
||||
|
||||
## Ops and snapshots
|
||||
|
||||
| Class | Kind | Purpose | Reader | Builder |
|
||||
|---|---|---|---|---|
|
||||
| `RelayJoin` | 28934 | join request (ephemeral) | `claim()`, `reason()` | `setClaim(claim)`, `setReason(reason)` |
|
||||
| `RelayInvite` | 28935 | invite (NIP-29) | `claim()` | `setClaim(claim)` |
|
||||
| `RelayLeave` | 28936 | leave marker (ephemeral) | — | — |
|
||||
| `RelayAddMember` | 8000 | add a member | `pubkeys()` | `addPubkey(pk)` |
|
||||
| `RelayRemoveMember` | 8001 | remove a member | `pubkeys()` | `addPubkey(pk)` |
|
||||
| `RelayMembers` | 13534 | member-list snapshot | `pubkeys()`, `isMember(pk)` | `addPubkey(pk)`, `removePubkey(pk)` |
|
||||
|
||||
Each has a matching `*Builder` (`RelayJoinBuilder`, `RelayMembersBuilder`, …).
|
||||
|
||||
## Joining and leaving
|
||||
|
||||
A `RelayJoin` carries an optional claim code (the `claim` tag) and a free-text reason (the event content):
|
||||
|
||||
```typescript
|
||||
import {RelayJoin, RelayJoinBuilder, RelayLeaveBuilder} from "@welshman/domain"
|
||||
|
||||
const join = await RelayJoin.fromEvent(event)
|
||||
join.claim() // string | undefined — the "claim" tag value
|
||||
join.reason() // event.content, or undefined when empty
|
||||
|
||||
await new RelayJoinBuilder()
|
||||
.setClaim(inviteCode) // ["claim", inviteCode]
|
||||
.setReason("hello") // becomes the content
|
||||
.toEvent(signer)
|
||||
|
||||
// Leaving is a bare marker — no fields.
|
||||
await new RelayLeaveBuilder().toEvent(signer)
|
||||
```
|
||||
|
||||
`RelayInvite` (kind 28935) is the invite counterpart, carrying just a `claim`:
|
||||
|
||||
```typescript
|
||||
import {RelayInviteBuilder} from "@welshman/domain"
|
||||
|
||||
await new RelayInviteBuilder().setClaim(inviteCode).toEvent(signer)
|
||||
```
|
||||
|
||||
## Member management
|
||||
|
||||
`RelayAddMember` (8000) and `RelayRemoveMember` (8001) are admin ops that list affected pubkeys via `addPubkey` (deduped by pubkey):
|
||||
|
||||
```typescript
|
||||
import {RelayAddMemberBuilder, RelayRemoveMemberBuilder} from "@welshman/domain"
|
||||
|
||||
await new RelayAddMemberBuilder()
|
||||
.addPubkey(pubkeyA)
|
||||
.addPubkey(pubkeyB)
|
||||
.toEvent(signer)
|
||||
|
||||
await new RelayRemoveMemberBuilder().addPubkey(pubkeyA).toEvent(signer)
|
||||
```
|
||||
|
||||
`RelayMembers` (13534) is the resulting member-list snapshot. Its reader answers membership questions; its builder supports both add and remove:
|
||||
|
||||
```typescript
|
||||
import {RelayMembers, RelayMembersBuilder} from "@welshman/domain"
|
||||
|
||||
const members = await RelayMembers.fromEvent(event)
|
||||
members.pubkeys() // string[]
|
||||
members.isMember(pubkey) // boolean
|
||||
|
||||
await new RelayMembersBuilder()
|
||||
.addPubkey(pubkeyA) // also removePubkey(pk)
|
||||
.toEvent(signer)
|
||||
```
|
||||
|
||||
## See also
|
||||
|
||||
- [Readers & Builders](./readers-and-builders) — the base `EventReader`/`EventBuilder` pattern.
|
||||
- [Rooms](./rooms) — NIP-29 room-level (not relay-level) membership and metadata ops.
|
||||
@@ -0,0 +1,118 @@
|
||||
# Rooms
|
||||
|
||||
NIP-29 rooms are relay-hosted groups. `@welshman/domain` models the room metadata kinds plus the full set of moderation/membership operations. There are two flavors of event here:
|
||||
|
||||
- **Addressable metadata** (kinds 39000–39002) — replaceable, identified by a `d` tag, written by the relay. These are the canonical state of a room.
|
||||
- **Action ops** (kinds 9000–9022, 19004) — regular events scoped to a target room by the NIP-29 **`h` group tag** (set via the base `setGroup` / `clearGroup`, read via `group()`).
|
||||
|
||||
All of these are plain `EventReader` / `EventBuilder` subclasses — none of them are encrypted lists. See [Readers & Builders](./readers-and-builders) for the base pattern. The reactive `app.use(Rooms)` plugin in `@welshman/app` builds on these classes.
|
||||
|
||||
## Room metadata
|
||||
|
||||
`RoomMeta` (kind 39000) is the addressable metadata record. Because it is parameterized-replaceable, the builder needs a `d` tag (`setIdentifier()`).
|
||||
|
||||
```typescript
|
||||
import {RoomMeta, RoomMetaBuilder} from "@welshman/domain"
|
||||
|
||||
const meta = await RoomMeta.fromEvent(event)
|
||||
meta.name() // string | undefined
|
||||
meta.about() // string | undefined
|
||||
meta.picture() // tag[1]
|
||||
meta.pictureMeta() // tag.slice(2) — imeta-style extras, or undefined
|
||||
meta.isClosed() // boolean (presence of a ["closed"] tag)
|
||||
meta.isHidden() // boolean
|
||||
meta.isPrivate() // boolean
|
||||
meta.isRestricted() // boolean
|
||||
meta.hasLivekit() // boolean
|
||||
|
||||
const template = await new RoomMetaBuilder()
|
||||
.setIdentifier("my-room")
|
||||
.setName("General")
|
||||
.setAbout("anything goes")
|
||||
.setPicture("https://example.com/room.png")
|
||||
.setClosed() // ["closed"]; pass false to clear
|
||||
.toTemplate()
|
||||
```
|
||||
|
||||
Flag setters (`setClosed`, `setHidden`, `setPrivate`, `setRestricted`, `setLivekit`) each take an optional boolean (default `true`) — passing `false` clears the tag. `setPicture(picture, meta = [])` appends the extra imeta elements.
|
||||
|
||||
`RoomEdit` (kind 9002) carries the **same metadata fields** but as an *action* event scoped via the `h` group tag rather than a `d` identifier — it is how a client requests a metadata change. Its reader/builder are identical to `RoomMeta`, with one naming difference: the livekit getter is `livekit()` here (vs `hasLivekit()` on `RoomMeta`).
|
||||
|
||||
```typescript
|
||||
import {RoomEditBuilder} from "@welshman/domain"
|
||||
|
||||
await new RoomEditBuilder()
|
||||
.setGroup(roomId) // h tag — which room to edit
|
||||
.setName("Renamed")
|
||||
.toEvent(signer)
|
||||
```
|
||||
|
||||
## Admins and members (addressable)
|
||||
|
||||
`RoomAdmins` (39001) and `RoomMembers` (39002) are addressable p-tag lists.
|
||||
|
||||
```typescript
|
||||
import {RoomAdmins, RoomMembers, RoomMembersBuilder} from "@welshman/domain"
|
||||
|
||||
const admins = await RoomAdmins.fromEvent(event)
|
||||
admins.pubkeys() // string[]
|
||||
|
||||
const members = await RoomMembers.fromEvent(event)
|
||||
members.members() // string[]
|
||||
members.isMember(pubkey) // boolean
|
||||
|
||||
await new RoomMembersBuilder()
|
||||
.setIdentifier(roomId)
|
||||
.addMember(pubkeyA) // also removeMember(pk)
|
||||
.toEvent(signer)
|
||||
```
|
||||
|
||||
`RoomAdminsBuilder` exposes `addAdmin(pk)` / `removeAdmin(pk)`; `RoomMembersBuilder` exposes `addMember(pk)` / `removeMember(pk)`. Both dedupe by pubkey.
|
||||
|
||||
## Membership and lifecycle ops
|
||||
|
||||
These are scoped to a room by the `h` group tag. Several enforce its presence in `validate()`.
|
||||
|
||||
| Class | Kind | Purpose | Reader | Builder |
|
||||
|---|---|---|---|---|
|
||||
| `RoomCreate` | 9007 | create a room | — | — |
|
||||
| `RoomDelete` | 9008 | delete a room | — | requires `h` group |
|
||||
| `RoomJoin` | 9021 | request to join | `code()`, `reason()` | `setCode(code)`, `setReason(reason)`; requires `h` group |
|
||||
| `RoomLeave` | 9022 | leave a room | — | requires `h` group |
|
||||
| `RoomAddMember` | 9000 | add a member | `pubkeys()` | `addPubkey(pk)` |
|
||||
| `RoomRemoveMember` | 9001 | remove a member | `pubkeys()` | `addPubkey(pk)` |
|
||||
| `RoomCreatePermission` | 19004 | grant room-creation rights | `pubkeys()`, `canCreate(pk)` | `setPubkeys(pks)` |
|
||||
|
||||
`RoomCreate` and `RoomLeave`/`RoomDelete` have no extra fields beyond the base builder — they are marker events. The members ops (`RoomAddMember`, `RoomRemoveMember`) use `addPubkey` to accumulate the affected pubkeys (deduped); `RoomRemoveMember`'s p-tags list the pubkeys to remove.
|
||||
|
||||
```typescript
|
||||
import {RoomJoinBuilder, RoomAddMemberBuilder, RoomCreateBuilder} from "@welshman/domain"
|
||||
|
||||
// Join request — code is the "claim" tag, reason is the content.
|
||||
await new RoomJoinBuilder()
|
||||
.setGroup(roomId)
|
||||
.setCode(inviteCode) // ["claim", inviteCode]
|
||||
.setReason("please let me in")
|
||||
.toEvent(signer)
|
||||
|
||||
// Add a member (relay/admin op)
|
||||
await new RoomAddMemberBuilder()
|
||||
.setGroup(roomId)
|
||||
.addPubkey(newMemberPubkey)
|
||||
.toEvent(signer)
|
||||
|
||||
// Create a room
|
||||
await new RoomCreateBuilder().setGroup(roomId).toEvent(signer)
|
||||
```
|
||||
|
||||
::: tip Naming quirks
|
||||
- `RoomJoin` stores the invite under the `claim` tag, but the accessor is `code()` (and the setter is `setCode`).
|
||||
- `RoomCreatePermission` uses `setPubkeys(pks)` (replace-all), not an `add*` method.
|
||||
- `RoomDelete`, `RoomLeave`, and `RoomJoin` builders throw from `validate()` if no `h` group is set — always `setGroup(roomId)` first.
|
||||
:::
|
||||
|
||||
## See also
|
||||
|
||||
- [Readers & Builders](./readers-and-builders) — the base pattern, the `h`/group tag handling (`setGroup`/`clearGroup`/`group()`), and `d`-tag validation for the addressable kinds.
|
||||
- [Lists](./lists) — `RoomList` (kind 10009), the NIP-51 membership list that tracks which rooms a user belongs to.
|
||||
- [Relay membership](./relay-membership) — the Flotilla relay-level (non-NIP-29) membership ops.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Zaps
|
||||
|
||||
The lightning-zap flow (NIP-57) and zap goals (NIP-75) are modeled by three kinds: `ZapRequest` (what a sender publishes to ask for a zap), `ZapReceipt` (what the recipient's LN service publishes as proof of payment), and `ZapGoal` (a fundraising target). All three are plain `EventReader` / `EventBuilder` subclasses — see [Readers & Builders](./readers-and-builders) for the base pattern.
|
||||
|
||||
## Zap request (kind 9734)
|
||||
|
||||
The zap request you send to a recipient's LNURL callback. The comment is the event content; everything else is tags.
|
||||
|
||||
```typescript
|
||||
import {ZapRequest, ZapRequestBuilder} from "@welshman/domain"
|
||||
|
||||
const req = await ZapRequest.fromEvent(event)
|
||||
req.amount() // millisats as an int, or undefined ("amount" tag)
|
||||
req.lnurl() // string | undefined
|
||||
req.recipient() // p-tag value
|
||||
req.eventId() // e-tag value (the zapped event)
|
||||
req.urls() // the "relays" tag, sliced past the key
|
||||
req.comment() // event.content
|
||||
|
||||
const template = await new ZapRequestBuilder()
|
||||
.setAmount(21000) // millisats
|
||||
.setRecipient(recipientPubkey)
|
||||
.setLnurl(lnurl)
|
||||
.setEventId(zappedNoteId)
|
||||
.setUrls(["wss://relay.example"]) // ["relays", ...urls]
|
||||
.setComment("great post")
|
||||
.toTemplate()
|
||||
```
|
||||
|
||||
`buildTags` always emits a `relays` tag (bare if you never called `setUrls`).
|
||||
|
||||
## Zap receipt (kind 9735)
|
||||
|
||||
The receipt is generated by the recipient's lightning service, so it is effectively **read-only** in practice. `parse` decodes the embedded zap request out of the `description` tag into `plain`, which the getters read through.
|
||||
|
||||
```typescript
|
||||
import {ZapReceipt} from "@welshman/domain"
|
||||
|
||||
const receipt = await ZapReceipt.fromEvent(event)
|
||||
receipt.bolt11() // the invoice
|
||||
receipt.invoiceAmount() // amount parsed from bolt11, or undefined on parse failure
|
||||
receipt.request() // the embedded zap-request event (TrustedEvent | undefined)
|
||||
receipt.sender() // request.pubkey
|
||||
receipt.recipient() // p-tag value
|
||||
receipt.eventId() // e-tag value
|
||||
receipt.comment() // the embedded request's content
|
||||
receipt.preimage() // string | undefined
|
||||
```
|
||||
|
||||
The important method is `verify(zapper)`, which validates the receipt against a `Zapper` (the recipient's LNURL zapper info from `@welshman/util`). It checks that the request is present, that the invoice amount matches the requested amount, that the sender is not the zapper itself, and that the recipient / lnurl / nostr pubkey are consistent. It returns a boolean.
|
||||
|
||||
```typescript
|
||||
import type {Zapper} from "@welshman/util"
|
||||
|
||||
const ok: boolean = receipt.verify(zapper)
|
||||
```
|
||||
|
||||
A `ZapReceiptBuilder` exists (`setBolt11`, `setDescription`, `setRecipient`, `setEventId`, `setPreimage`) for completeness — e.g. tests or a service generating receipts — but you rarely construct these by hand.
|
||||
|
||||
## Zap goal (kind 9041)
|
||||
|
||||
A fundraising target. The title is the content; the goal amount and relays are tags.
|
||||
|
||||
```typescript
|
||||
import {ZapGoal, ZapGoalBuilder} from "@welshman/domain"
|
||||
|
||||
const goal = await ZapGoal.fromEvent(event)
|
||||
goal.title() // event.content (or "")
|
||||
goal.summary() // "summary" tag value
|
||||
goal.amount() // millisats as an int, default 0
|
||||
goal.urls() // "relays" tag values
|
||||
|
||||
const template = await new ZapGoalBuilder()
|
||||
.setTitle("Fund the relay")
|
||||
.setSummary("keeps the lights on")
|
||||
.setAmount(1000000) // millisats
|
||||
.setUrls(["wss://relay.example"]) // one ["relays", url] per url
|
||||
.toTemplate()
|
||||
```
|
||||
|
||||
`validate()` requires a title (throws `ZapGoal requires a title`), and `buildTags` always emits an `amount` tag (defaulting to `["amount", "0"]`).
|
||||
|
||||
## See also
|
||||
|
||||
- [Readers & Builders](./readers-and-builders) — the base `EventReader`/`EventBuilder` pattern.
|
||||
@@ -24,6 +24,9 @@ features:
|
||||
- title: "@welshman/util"
|
||||
details: Core Nostr utilities for events, filters, and data structures.
|
||||
link: "/util"
|
||||
- title: "@welshman/domain"
|
||||
details: Nostr event kinds modeled as Reader/Builder classes for profiles, lists, rooms, handlers, and zaps.
|
||||
link: "/domain"
|
||||
- title: "@welshman/net"
|
||||
details: Networking layer for Nostr with relay connection management and message status handling.
|
||||
link: "/net"
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
# Encryptable
|
||||
|
||||
The Encryptable module provides utilities for handling encrypted Nostr events, allowing you to merge plaintext updates into events and encrypt them before publishing.
|
||||
|
||||
## API
|
||||
|
||||
```typescript
|
||||
// Encryption function type
|
||||
export type Encrypt = (x: string) => Promise<string>;
|
||||
|
||||
// Partial event content for updates
|
||||
export type EncryptableUpdates = Partial<EventContent>;
|
||||
|
||||
// Event with attached plaintext data
|
||||
export type DecryptedEvent = TrustedEvent & {
|
||||
plaintext: EncryptableUpdates;
|
||||
};
|
||||
|
||||
// Creates a DecryptedEvent by attaching plaintext to an event
|
||||
export declare const asDecryptedEvent: (
|
||||
event: TrustedEvent,
|
||||
plaintext?: EncryptableUpdates
|
||||
) => DecryptedEvent;
|
||||
|
||||
// Encryptable class for handling encrypted events
|
||||
export declare class Encryptable<T extends EventTemplate> {
|
||||
constructor(
|
||||
event: Partial<T>,
|
||||
updates: EncryptableUpdates
|
||||
);
|
||||
|
||||
// Encrypts updates and merges them into the event
|
||||
reconcile(encrypt: Encrypt): Promise<T>;
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
import { Encryptable } from '@welshman/util';
|
||||
|
||||
// Create encryptable with plaintext updates
|
||||
const encryptable = new Encryptable(
|
||||
{ kind: 10000 }, // Base event template
|
||||
{ content: "secret mute list data" } // Plaintext content to encrypt
|
||||
);
|
||||
|
||||
// Encrypt and get final event
|
||||
const encryptFn = async (text: string) => {
|
||||
// Your encryption logic here
|
||||
return await encrypt(text);
|
||||
};
|
||||
|
||||
const event = await encryptable.reconcile(encryptFn);
|
||||
// event.content is now encrypted
|
||||
```
|
||||
|
||||
### Encrypting Tags
|
||||
|
||||
```typescript
|
||||
import { Encryptable } from '@welshman/util';
|
||||
|
||||
// Encrypt both content and tag values
|
||||
const encryptable = new Encryptable(
|
||||
{ kind: 10000, tags: [] },
|
||||
{
|
||||
content: JSON.stringify(['pubkey1', 'pubkey2']),
|
||||
tags: [['p', 'sensitive-pubkey'], ['e', 'sensitive-event-id']]
|
||||
}
|
||||
);
|
||||
|
||||
// The reconcile method encrypts tag values at index 1
|
||||
const event = await encryptable.reconcile(encryptFn);
|
||||
// event.tags[0] = ['p', 'encrypted-pubkey']
|
||||
// event.tags[1] = ['e', 'encrypted-event-id']
|
||||
```
|
||||
|
||||
### Working with Decrypted Events
|
||||
|
||||
```typescript
|
||||
import { asDecryptedEvent } from '@welshman/util';
|
||||
|
||||
// Add plaintext data to an event for reference
|
||||
const event = { kind: 10000, content: "encrypted...", tags: [] };
|
||||
const plaintext = { content: "original content", tags: [['p', 'pubkey']] };
|
||||
|
||||
const decryptedEvent = asDecryptedEvent(event, plaintext);
|
||||
console.log(decryptedEvent.plaintext.content); // "original content"
|
||||
```
|
||||
@@ -1,134 +0,0 @@
|
||||
# Handlers (NIP-89)
|
||||
|
||||
The Handlers module provides functionality for working with handler recommendations and information (NIP-89).
|
||||
Handlers are events that describe which kinds a given application can display.
|
||||
|
||||
This module provides utilities for transforming these events into structured handler objects that applications can easily process.
|
||||
|
||||
|
||||
## Types
|
||||
|
||||
### Handler Definition
|
||||
|
||||
```typescript
|
||||
type Handler = {
|
||||
kind: number // Event kind this handler can process
|
||||
name: string // Display name of the handler
|
||||
about: string // Description
|
||||
image: string // Icon or image URL
|
||||
identifier: string // Unique identifier (d-tag)
|
||||
event: TrustedEvent // Original handler event
|
||||
website?: string // Optional website URL
|
||||
lud16?: string // Optional Lightning address
|
||||
nip05?: string // Optional NIP-05 identifier
|
||||
}
|
||||
```
|
||||
|
||||
## Core Functions
|
||||
|
||||
### Reading Handlers
|
||||
```typescript
|
||||
function readHandlers(event: TrustedEvent): Handler[]
|
||||
|
||||
// Example
|
||||
const handlers = readHandlers(handlerEvent)
|
||||
handlers.forEach(handler => {
|
||||
console.log(`Handler for kind ${handler.kind}: ${handler.name}`)
|
||||
})
|
||||
```
|
||||
|
||||
### Handler Identification
|
||||
```typescript
|
||||
function getHandlerKey(handler: Handler): string
|
||||
// Returns "kind:address" format
|
||||
|
||||
function getHandlerAddress(event: TrustedEvent): string | undefined
|
||||
// Gets handler address from event tags
|
||||
```
|
||||
|
||||
### Display Formatting
|
||||
```typescript
|
||||
function displayHandler(
|
||||
handler?: Handler,
|
||||
fallback = ""
|
||||
): string
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Reading Handler Information
|
||||
```typescript
|
||||
const event = {
|
||||
kind: 31990, // Handler Information kind
|
||||
content: JSON.stringify({
|
||||
name: "Note Viewer",
|
||||
about: "Displays text notes with formatting",
|
||||
image: "https://example.com/icon.png"
|
||||
}),
|
||||
tags: [
|
||||
['k', '1'], // Handles kind 1 (text notes)
|
||||
['d', 'note-viewer']
|
||||
]
|
||||
}
|
||||
|
||||
const handlers = readHandlers(event)
|
||||
// Returns array of handlers defined in the event
|
||||
```
|
||||
|
||||
### Working with Handlers
|
||||
```typescript
|
||||
// Get unique handler identifier
|
||||
const key = getHandlerKey(handler)
|
||||
// => "1:31990:pubkey:identifier" (handler-kind:address)
|
||||
// where address is the "kind:pubkey:identifier" of the handler event
|
||||
|
||||
// Display handler name
|
||||
const name = displayHandler(handler, "Unknown Handler")
|
||||
// => "Note Viewer" or fallback if handler undefined
|
||||
|
||||
// Get handler address
|
||||
const address = getHandlerAddress(event)
|
||||
// Returns address from tags with 'web' marker or first address
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
```typescript
|
||||
// Process handler information event
|
||||
function processHandlerEvent(event: TrustedEvent) {
|
||||
// Read all handlers from event
|
||||
const handlers = readHandlers(event)
|
||||
|
||||
// Process each handler
|
||||
handlers.forEach(handler => {
|
||||
// Generate unique key
|
||||
const key = getHandlerKey(handler)
|
||||
|
||||
// Store handler information
|
||||
handlerRegistry.set(key, {
|
||||
name: handler.name,
|
||||
kind: handler.kind,
|
||||
about: handler.about,
|
||||
image: handler.image,
|
||||
website: handler.website,
|
||||
address: getHandlerAddress(handler.event)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Find handler for event kind
|
||||
function findHandler(kind: number): Handler | undefined {
|
||||
return Array.from(handlerRegistry.values())
|
||||
.find(h => h.kind === kind)
|
||||
}
|
||||
|
||||
// Display handler information
|
||||
function renderHandler(handler: Handler) {
|
||||
return {
|
||||
title: displayHandler(handler, "Unknown"),
|
||||
description: handler.about,
|
||||
icon: handler.image,
|
||||
website: handler.website || null
|
||||
}
|
||||
}
|
||||
```
|
||||
+3
-3
@@ -2,20 +2,20 @@
|
||||
|
||||
[](https://npmjs.com/package/@welshman/util)
|
||||
|
||||
A utility package for Nostr application development, providing essential tools and types for working with Nostr events, addresses, profiles, and more.
|
||||
A utility package for Nostr application development, providing essential tools and types for working with Nostr events, addresses, filters, tags, and more.
|
||||
|
||||
## What's Included
|
||||
|
||||
- **Event Management**: Create, validate, and process Nostr events
|
||||
- **Repository**: In-memory event storage with querying and indexing
|
||||
- **Filters**: Advanced event filtering and subscription management
|
||||
- **Profiles**: User profile handling and formatting
|
||||
- **Lists**: Public and private list management
|
||||
- **Zaps**: Lightning Network payment integration
|
||||
- **Tags**: Comprehensive tag parsing and manipulation
|
||||
- **Addresses**: NIP-19 address handling
|
||||
- **Relays**: Relay URL handling, event dispatching and in-memory storage
|
||||
|
||||
> Note: profiles, lists, handlers, rooms, and event Reader/Builder helpers now live in [@welshman/domain](/domain/).
|
||||
|
||||
## Installation
|
||||
|
||||
```
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
# Lists
|
||||
|
||||
The Lists module provides utilities for working with Nostr lists, including both public and private lists (like bookmarks, mute lists, etc.). It handles list creation, encryption, and manipulation.
|
||||
|
||||
## Core Types
|
||||
|
||||
### List Parameters
|
||||
```typescript
|
||||
interface ListParams {
|
||||
kind: number // List kind (e.g., 10000 for mutes)
|
||||
}
|
||||
```
|
||||
|
||||
### List Structure
|
||||
```typescript
|
||||
interface List extends ListParams {
|
||||
publicTags: string[][] // Publicly visible tags
|
||||
privateTags: string[][] // Encrypted tags
|
||||
event?: DecryptedEvent // Original event if list exists
|
||||
}
|
||||
```
|
||||
|
||||
### Published List
|
||||
```typescript
|
||||
interface PublishedList extends List {
|
||||
event: DecryptedEvent // Required event for published lists
|
||||
}
|
||||
```
|
||||
|
||||
## List Creation
|
||||
|
||||
### Create New List
|
||||
```typescript
|
||||
function makeList(list: ListParams & Partial<List>): List
|
||||
|
||||
// Example
|
||||
const muteList = makeList({
|
||||
kind: 10000,
|
||||
publicTags: [['d', 'mutes']],
|
||||
privateTags: [['p', 'pubkey1'], ['p', 'pubkey2']]
|
||||
})
|
||||
```
|
||||
|
||||
### Read Existing List
|
||||
```typescript
|
||||
function readList(event: DecryptedEvent): PublishedList
|
||||
|
||||
// Example
|
||||
const list = readList(decryptedEvent)
|
||||
```
|
||||
|
||||
## List Operations
|
||||
|
||||
### Get All Tags
|
||||
```typescript
|
||||
function getListTags(list: List | undefined): string[][]
|
||||
|
||||
// Example
|
||||
const allTags = getListTags(list) // Combines public and private tags
|
||||
```
|
||||
|
||||
### Remove Items
|
||||
```typescript
|
||||
// Remove by predicate
|
||||
function removeFromListByPredicate(
|
||||
list: List,
|
||||
pred: (t: string[]) => boolean
|
||||
): Encryptable
|
||||
|
||||
// Remove by value
|
||||
function removeFromList(
|
||||
list: List,
|
||||
value: string
|
||||
): Encryptable
|
||||
```
|
||||
|
||||
### Add Items
|
||||
```typescript
|
||||
// Add public items
|
||||
function addToListPublicly(
|
||||
list: List,
|
||||
...tags: string[][]
|
||||
): Encryptable
|
||||
|
||||
// Add private items
|
||||
function addToListPrivately(
|
||||
list: List,
|
||||
...tags: string[][]
|
||||
): Encryptable
|
||||
|
||||
// Update list with new tags
|
||||
function updateList(
|
||||
list: List,
|
||||
options: {publicTags?: string[][], privateTags?: string[][]}
|
||||
): Encryptable
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Creating a Private List
|
||||
```typescript
|
||||
// Create new mute list
|
||||
const muteList = makeList({
|
||||
kind: 10000,
|
||||
publicTags: [
|
||||
['d', 'mutes'],
|
||||
['name', 'My Mute List']
|
||||
]
|
||||
})
|
||||
|
||||
// Add items privately
|
||||
const updated = addToListPrivately(
|
||||
muteList,
|
||||
['p', 'pubkey1'],
|
||||
['p', 'pubkey2']
|
||||
)
|
||||
|
||||
// Add new items publicly
|
||||
const addItems = addToListPublicly(
|
||||
list,
|
||||
['p', 'pubkey3'],
|
||||
['p', 'pubkey4']
|
||||
)
|
||||
|
||||
// Encrypt and publish
|
||||
const encrypted = await updated.reconcile(encrypt)
|
||||
```
|
||||
|
||||
### Reading and Updating Lists
|
||||
```typescript
|
||||
// Read existing list
|
||||
const list = readList(decryptedEvent)
|
||||
|
||||
// Remove item
|
||||
const removeItem = removeFromList(list, 'pubkey1')
|
||||
|
||||
// Remove by predicate
|
||||
const noMentions = removeFromListByPredicate(
|
||||
list,
|
||||
tag => tag[0] === 'p'
|
||||
)
|
||||
```
|
||||
|
||||
### Working with Tags
|
||||
```typescript
|
||||
// Get all list tags
|
||||
const tags = getListTags(list)
|
||||
```
|
||||
@@ -1,115 +0,0 @@
|
||||
# Profile
|
||||
|
||||
The Profile module provides utilities for handling Nostr user profiles (kind 0 events), including profile creation, reading, and display formatting.
|
||||
|
||||
## Core Types
|
||||
|
||||
### Profile Structure
|
||||
```typescript
|
||||
interface Profile {
|
||||
name?: string // Display name
|
||||
nip05?: string // NIP-05 verification
|
||||
lud06?: string // Legacy Lightning address
|
||||
lud16?: string // Lightning address
|
||||
lnurl?: string // Lightning URL
|
||||
about?: string // Bio/description
|
||||
banner?: string // Banner image URL
|
||||
picture?: string // Profile picture URL
|
||||
website?: string // Website URL
|
||||
display_name?: string // Alternative display name
|
||||
event?: TrustedEvent // Original profile event
|
||||
}
|
||||
```
|
||||
|
||||
### Published Profile
|
||||
```typescript
|
||||
interface PublishedProfile extends Omit<Profile, "event"> {
|
||||
event: TrustedEvent // Required event for published profiles
|
||||
}
|
||||
```
|
||||
|
||||
## Core Functions
|
||||
|
||||
### Profile Creation & Reading
|
||||
```typescript
|
||||
// Create new profile
|
||||
function makeProfile(profile: Partial<Profile>): Profile
|
||||
|
||||
// Read profile from event
|
||||
function readProfile(event: TrustedEvent): PublishedProfile
|
||||
|
||||
// Create profile event
|
||||
function createProfile(profile: Profile): EventTemplate
|
||||
|
||||
// Edit existing profile
|
||||
function editProfile(profile: PublishedProfile): EventTemplate
|
||||
```
|
||||
|
||||
### Display Formatting
|
||||
```typescript
|
||||
// Format pubkey for display
|
||||
function displayPubkey(pubkey: string): string
|
||||
|
||||
// Format profile name for display
|
||||
function displayProfile(
|
||||
profile?: Profile,
|
||||
fallback = ""
|
||||
): string
|
||||
|
||||
// Check if profile has name
|
||||
function profileHasName(profile?: Profile): boolean
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Creating New Profile
|
||||
```typescript
|
||||
// Create basic profile
|
||||
const profile = makeProfile({
|
||||
name: "Alice",
|
||||
about: "Nostr user",
|
||||
picture: "https://example.com/avatar.jpg",
|
||||
lud16: "alice@getalby.com"
|
||||
})
|
||||
|
||||
// Create profile event
|
||||
const profileEvent = createProfile(profile)
|
||||
```
|
||||
|
||||
### Reading Profile
|
||||
```typescript
|
||||
// Read profile from event
|
||||
const profile = readProfile(profileEvent)
|
||||
|
||||
// Access profile data
|
||||
console.log(profile.name)
|
||||
console.log(profile.about)
|
||||
console.log(profile.lnurl) // Auto-generated from lud16/lud06
|
||||
```
|
||||
|
||||
### Displaying Profile
|
||||
```typescript
|
||||
// Display profile name
|
||||
const name = displayProfile(profile, "Anonymous")
|
||||
|
||||
// Display pubkey
|
||||
const shortPubkey = displayPubkey(profile.event.pubkey)
|
||||
// => "npub1abc...xyz"
|
||||
|
||||
// Check for name
|
||||
if (profileHasName(profile)) {
|
||||
showName(profile)
|
||||
} else {
|
||||
showPubkey(profile)
|
||||
}
|
||||
```
|
||||
|
||||
### Updating Profile
|
||||
```typescript
|
||||
// Edit existing profile
|
||||
const profileEvent = editProfile({
|
||||
...existingProfile,
|
||||
name: "New Name",
|
||||
about: "Updated bio"
|
||||
})
|
||||
```
|
||||
Reference in New Issue
Block a user