Files
welshman/docs/domain/lists.md
T
2026-06-20 09:12:18 -07:00

7.4 KiB

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; 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.

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:

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:

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.

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.

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.)

See also

  • Readers & Builders — the ListReader/ListBuilder base, including exactly how private-tag encryption and decryption work.
  • Rooms — NIP-29 room ops, referenced by RoomList.