RelayList.ts raw
1 import { Event, kinds } from 'nostr-tools'
2 import { Pubkey, RelayUrl, Timestamp } from '../shared'
3
4 /**
5 * The scope of a relay in a relay list (read, write, or both)
6 */
7 export type RelayScope = 'read' | 'write' | 'both'
8
9 /**
10 * A relay entry with its scope
11 */
12 export type RelayEntry = {
13 relay: RelayUrl
14 scope: RelayScope
15 }
16
17 /**
18 * Result of a relay list modification
19 */
20 export type RelayListChange =
21 | { type: 'added'; relay: RelayUrl; scope: RelayScope }
22 | { type: 'removed'; relay: RelayUrl }
23 | { type: 'scope_changed'; relay: RelayUrl; from: RelayScope; to: RelayScope }
24 | { type: 'no_change' }
25
26 /**
27 * RelayList Aggregate
28 *
29 * Represents a user's mailbox relay preferences (kind 10002 in Nostr, NIP-65).
30 * Defines which relays the user reads from and writes to.
31 *
32 * Invariants:
33 * - No duplicate relay URLs
34 * - All URLs must be valid WebSocket URLs
35 * - At least one read and one write relay is recommended (but not enforced)
36 */
37 export class RelayList {
38 private readonly _relays: Map<string, RelayEntry>
39
40 private constructor(
41 private readonly _owner: Pubkey,
42 entries: RelayEntry[]
43 ) {
44 this._relays = new Map()
45 for (const entry of entries) {
46 this._relays.set(entry.relay.value, entry)
47 }
48 }
49
50 /**
51 * Create an empty RelayList for a user
52 */
53 static empty(owner: Pubkey): RelayList {
54 return new RelayList(owner, [])
55 }
56
57 /**
58 * Create a RelayList with initial relays (all set to 'both')
59 */
60 static fromUrls(owner: Pubkey, urls: string[]): RelayList {
61 const entries: RelayEntry[] = []
62 for (const url of urls) {
63 const relay = RelayUrl.tryCreate(url)
64 if (relay) {
65 entries.push({ relay, scope: 'both' })
66 }
67 }
68 return new RelayList(owner, entries)
69 }
70
71 /**
72 * Reconstruct a RelayList from a Nostr kind 10002 event
73 *
74 * @param event The relay list event
75 * @param filterOutOnion Whether to filter out .onion addresses
76 */
77 static fromEvent(event: Event, filterOutOnion = false): RelayList {
78 if (event.kind !== kinds.RelayList) {
79 throw new Error(`Expected kind ${kinds.RelayList}, got ${event.kind}`)
80 }
81
82 const owner = Pubkey.fromHex(event.pubkey)
83 const entries: RelayEntry[] = []
84
85 for (const tag of event.tags) {
86 if (tag[0] === 'r' && tag[1]) {
87 const relay = RelayUrl.tryCreate(tag[1])
88 if (!relay) continue
89 if (filterOutOnion && relay.isOnion) continue
90
91 let scope: RelayScope = 'both'
92 if (tag[2] === 'read') {
93 scope = 'read'
94 } else if (tag[2] === 'write') {
95 scope = 'write'
96 }
97
98 entries.push({ relay, scope })
99 }
100 }
101
102 return new RelayList(owner, entries)
103 }
104
105 /**
106 * The owner of this relay list
107 */
108 get owner(): Pubkey {
109 return this._owner
110 }
111
112 /**
113 * Total number of relays
114 */
115 get count(): number {
116 return this._relays.size
117 }
118
119 /**
120 * Get all relay entries
121 */
122 getEntries(): RelayEntry[] {
123 return Array.from(this._relays.values())
124 }
125
126 /**
127 * Get all relays (regardless of scope)
128 */
129 getAllRelays(): RelayUrl[] {
130 return Array.from(this._relays.values()).map((e) => e.relay)
131 }
132
133 /**
134 * Get all relay URLs as strings
135 */
136 getAllUrls(): string[] {
137 return Array.from(this._relays.keys())
138 }
139
140 /**
141 * Get read relays (scope is 'read' or 'both')
142 */
143 getReadRelays(): RelayUrl[] {
144 return Array.from(this._relays.values())
145 .filter((e) => e.scope === 'read' || e.scope === 'both')
146 .map((e) => e.relay)
147 }
148
149 /**
150 * Get read relay URLs as strings
151 */
152 getReadUrls(): string[] {
153 return this.getReadRelays().map((r) => r.value)
154 }
155
156 /**
157 * Get write relays (scope is 'write' or 'both')
158 */
159 getWriteRelays(): RelayUrl[] {
160 return Array.from(this._relays.values())
161 .filter((e) => e.scope === 'write' || e.scope === 'both')
162 .map((e) => e.relay)
163 }
164
165 /**
166 * Get write relay URLs as strings
167 */
168 getWriteUrls(): string[] {
169 return this.getWriteRelays().map((r) => r.value)
170 }
171
172 /**
173 * Check if a relay is in this list
174 */
175 hasRelay(relay: RelayUrl): boolean {
176 return this._relays.has(relay.value)
177 }
178
179 /**
180 * Get the scope for a relay
181 */
182 getScope(relay: RelayUrl): RelayScope | null {
183 const entry = this._relays.get(relay.value)
184 return entry ? entry.scope : null
185 }
186
187 /**
188 * Add or update a relay with a specific scope
189 *
190 * @returns RelayListChange indicating what changed
191 */
192 setRelay(relay: RelayUrl, scope: RelayScope): RelayListChange {
193 const existing = this._relays.get(relay.value)
194
195 if (existing) {
196 if (existing.scope === scope) {
197 return { type: 'no_change' }
198 }
199 const oldScope = existing.scope
200 this._relays.set(relay.value, { relay, scope })
201 return { type: 'scope_changed', relay, from: oldScope, to: scope }
202 }
203
204 this._relays.set(relay.value, { relay, scope })
205 return { type: 'added', relay, scope }
206 }
207
208 /**
209 * Add a relay by URL string
210 *
211 * @returns RelayListChange or null if URL is invalid
212 */
213 setRelayUrl(url: string, scope: RelayScope): RelayListChange | null {
214 const relay = RelayUrl.tryCreate(url)
215 if (!relay) return null
216 return this.setRelay(relay, scope)
217 }
218
219 /**
220 * Remove a relay from this list
221 *
222 * @returns RelayListChange indicating what changed
223 */
224 removeRelay(relay: RelayUrl): RelayListChange {
225 if (!this._relays.has(relay.value)) {
226 return { type: 'no_change' }
227 }
228
229 this._relays.delete(relay.value)
230 return { type: 'removed', relay }
231 }
232
233 /**
234 * Remove a relay by URL string
235 *
236 * @returns RelayListChange or null if URL is invalid
237 */
238 removeRelayUrl(url: string): RelayListChange | null {
239 const relay = RelayUrl.tryCreate(url)
240 if (!relay) return null
241 return this.removeRelay(relay)
242 }
243
244 /**
245 * Replace all relays with a new list
246 */
247 setEntries(entries: RelayEntry[]): void {
248 this._relays.clear()
249 for (const entry of entries) {
250 this._relays.set(entry.relay.value, entry)
251 }
252 }
253
254 /**
255 * Convert to Nostr event tags format
256 */
257 toTags(): string[][] {
258 return Array.from(this._relays.values()).map((entry) => {
259 if (entry.scope === 'both') {
260 return ['r', entry.relay.value]
261 }
262 return ['r', entry.relay.value, entry.scope]
263 })
264 }
265
266 /**
267 * Convert to a draft event for publishing
268 */
269 toDraftEvent(): { kind: number; content: string; created_at: number; tags: string[][] } {
270 return {
271 kind: kinds.RelayList,
272 content: '',
273 created_at: Timestamp.now().unix,
274 tags: this.toTags()
275 }
276 }
277
278 /**
279 * Convert to the legacy TRelayList format
280 */
281 toLegacyFormat(): {
282 read: string[]
283 write: string[]
284 originalRelays: Array<{ url: string; scope: RelayScope }>
285 } {
286 return {
287 read: this.getReadUrls(),
288 write: this.getWriteUrls(),
289 originalRelays: Array.from(this._relays.values()).map((e) => ({
290 url: e.relay.value,
291 scope: e.scope
292 }))
293 }
294 }
295 }
296