5.5 KiB
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
dtag, 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
hgroup tag (set via the basesetGroup/clearGroup, read viagroup()).
All of these are plain EventReader / EventBuilder subclasses — none of them are encrypted lists. See Readers & 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()).
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).
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.
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.
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
RoomJoinstores the invite under theclaimtag, but the accessor iscode()(and the setter issetCode).RoomCreatePermissionusessetPubkeys(pks)(replace-all), not anadd*method.RoomDelete,RoomLeave, andRoomJoinbuilders throw fromvalidate()if nohgroup is set — alwayssetGroup(roomId)first. :::
See also
- Readers & Builders — the base pattern, the
h/group tag handling (setGroup/clearGroup/group()), andd-tag validation for the addressable kinds. - Lists —
RoomList(kind 10009), the NIP-51 membership list that tracks which rooms a user belongs to. - Relay membership — the Flotilla relay-level (non-NIP-29) membership ops.