Files
welshman/docs/domain/content.md
T
hodlbod 5b8fef5b23
tests / tests (push) Failing after 5m8s
Fix NIP conformance in domain kinds; add domain docs/skill
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
2026-06-20 14:55:21 +00:00

6.1 KiB

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

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.

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.

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.

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.

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:

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:

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.

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 — the base EventReader/EventBuilder pattern, including d-tag validation for Classified and TimeEvent.