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:
@@ -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`.
|
||||
Reference in New Issue
Block a user