FavoriteRelays.ts raw
1 import { Event, kinds } from 'nostr-tools'
2 import { Pubkey, RelayUrl, Timestamp } from '../shared'
3 import { RelaySet } from './RelaySet'
4
5 /**
6 * Result of a favorite relays modification
7 */
8 export type FavoriteRelaysChange =
9 | { type: 'relay_added'; relay: RelayUrl }
10 | { type: 'relay_removed'; relay: RelayUrl }
11 | { type: 'set_added'; set: RelaySet }
12 | { type: 'set_removed'; setId: string }
13 | { type: 'no_change' }
14
15 /**
16 * FavoriteRelays Aggregate
17 *
18 * Represents a user's favorite relays collection (kind 10012 in Nostr).
19 * Combines individual relay URLs and references to relay sets.
20 *
21 * This is the user's curated list of relays they want quick access to,
22 * separate from their mailbox relays (kind 10002).
23 */
24 export class FavoriteRelays {
25 private readonly _relays: Map<string, RelayUrl>
26 private readonly _sets: Map<string, RelaySet>
27 private readonly _setOrder: string[]
28
29 private constructor(
30 private readonly _owner: Pubkey,
31 relays: RelayUrl[],
32 sets: RelaySet[]
33 ) {
34 this._relays = new Map()
35 this._sets = new Map()
36 this._setOrder = []
37
38 for (const relay of relays) {
39 this._relays.set(relay.value, relay)
40 }
41 for (const set of sets) {
42 this._sets.set(set.id, set)
43 this._setOrder.push(set.id)
44 }
45 }
46
47 /**
48 * Create an empty FavoriteRelays for a user
49 */
50 static empty(owner: Pubkey): FavoriteRelays {
51 return new FavoriteRelays(owner, [], [])
52 }
53
54 /**
55 * Create FavoriteRelays from URLs only
56 */
57 static fromUrls(owner: Pubkey, urls: string[]): FavoriteRelays {
58 const relays: RelayUrl[] = []
59 for (const url of urls) {
60 const relay = RelayUrl.tryCreate(url)
61 if (relay && !relays.some((r) => r.value === relay.value)) {
62 relays.push(relay)
63 }
64 }
65 return new FavoriteRelays(owner, relays, [])
66 }
67
68 /**
69 * Reconstruct FavoriteRelays from a Nostr kind 10012 event
70 *
71 * @param event The favorite relays event
72 * @param relaySets The relay set events referenced by 'a' tags
73 */
74 static fromEvent(event: Event, relaySets: RelaySet[] = []): FavoriteRelays {
75 const owner = Pubkey.fromHex(event.pubkey)
76 const relays: RelayUrl[] = []
77 const setIds: string[] = []
78
79 for (const tag of event.tags) {
80 if (tag[0] === 'relay' && tag[1]) {
81 const relay = RelayUrl.tryCreate(tag[1])
82 if (relay && !relays.some((r) => r.value === relay.value)) {
83 relays.push(relay)
84 }
85 } else if (tag[0] === 'a' && tag[1]) {
86 const [kind, , setId] = tag[1].split(':')
87 if (kind === kinds.Relaysets.toString() && setId && !setIds.includes(setId)) {
88 setIds.push(setId)
89 }
90 }
91 }
92
93 // Match relay sets to their IDs in order
94 const orderedSets: RelaySet[] = []
95 for (const id of setIds) {
96 const set = relaySets.find((s) => s.id === id)
97 if (set) {
98 orderedSets.push(set)
99 }
100 }
101
102 return new FavoriteRelays(owner, relays, orderedSets)
103 }
104
105 /**
106 * The owner of this favorite relays list
107 */
108 get owner(): Pubkey {
109 return this._owner
110 }
111
112 /**
113 * Number of individual favorite relays
114 */
115 get relayCount(): number {
116 return this._relays.size
117 }
118
119 /**
120 * Number of relay sets
121 */
122 get setCount(): number {
123 return this._sets.size
124 }
125
126 /**
127 * Get all individual favorite relays
128 */
129 getRelays(): RelayUrl[] {
130 return Array.from(this._relays.values())
131 }
132
133 /**
134 * Get all relay URLs as strings
135 */
136 getRelayUrls(): string[] {
137 return Array.from(this._relays.keys())
138 }
139
140 /**
141 * Get all relay sets in order
142 */
143 getSets(): RelaySet[] {
144 return this._setOrder.map((id) => this._sets.get(id)!).filter(Boolean)
145 }
146
147 /**
148 * Get a relay set by ID
149 */
150 getSet(id: string): RelaySet | undefined {
151 return this._sets.get(id)
152 }
153
154 /**
155 * Get all unique relays (from both individual relays and sets)
156 */
157 getAllUniqueRelays(): RelayUrl[] {
158 const all = new Map<string, RelayUrl>()
159
160 for (const relay of this._relays.values()) {
161 all.set(relay.value, relay)
162 }
163
164 for (const set of this._sets.values()) {
165 for (const relay of set.getRelays()) {
166 all.set(relay.value, relay)
167 }
168 }
169
170 return Array.from(all.values())
171 }
172
173 /**
174 * Check if a relay is in the favorites
175 */
176 hasRelay(relay: RelayUrl): boolean {
177 return this._relays.has(relay.value)
178 }
179
180 /**
181 * Check if a relay set is in the favorites
182 */
183 hasSet(id: string): boolean {
184 return this._sets.has(id)
185 }
186
187 /**
188 * Add a relay to favorites
189 *
190 * @returns FavoriteRelaysChange indicating what changed
191 */
192 addRelay(relay: RelayUrl): FavoriteRelaysChange {
193 if (this._relays.has(relay.value)) {
194 return { type: 'no_change' }
195 }
196
197 this._relays.set(relay.value, relay)
198 return { type: 'relay_added', relay }
199 }
200
201 /**
202 * Add multiple relays to favorites
203 */
204 addRelays(relays: RelayUrl[]): FavoriteRelaysChange[] {
205 return relays.map((r) => this.addRelay(r))
206 }
207
208 /**
209 * Add a relay by URL string
210 */
211 addRelayUrl(url: string): FavoriteRelaysChange | null {
212 const relay = RelayUrl.tryCreate(url)
213 if (!relay) return null
214 return this.addRelay(relay)
215 }
216
217 /**
218 * Remove a relay from favorites
219 *
220 * @returns FavoriteRelaysChange indicating what changed
221 */
222 removeRelay(relay: RelayUrl): FavoriteRelaysChange {
223 if (!this._relays.has(relay.value)) {
224 return { type: 'no_change' }
225 }
226
227 this._relays.delete(relay.value)
228 return { type: 'relay_removed', relay }
229 }
230
231 /**
232 * Remove multiple relays from favorites
233 */
234 removeRelays(relays: RelayUrl[]): FavoriteRelaysChange[] {
235 return relays.map((r) => this.removeRelay(r))
236 }
237
238 /**
239 * Add a relay set to favorites
240 *
241 * @returns FavoriteRelaysChange indicating what changed
242 */
243 addSet(set: RelaySet): FavoriteRelaysChange {
244 if (this._sets.has(set.id)) {
245 return { type: 'no_change' }
246 }
247
248 this._sets.set(set.id, set)
249 this._setOrder.push(set.id)
250 return { type: 'set_added', set }
251 }
252
253 /**
254 * Remove a relay set from favorites
255 *
256 * @returns FavoriteRelaysChange indicating what changed
257 */
258 removeSet(id: string): FavoriteRelaysChange {
259 if (!this._sets.has(id)) {
260 return { type: 'no_change' }
261 }
262
263 this._sets.delete(id)
264 const index = this._setOrder.indexOf(id)
265 if (index !== -1) {
266 this._setOrder.splice(index, 1)
267 }
268 return { type: 'set_removed', setId: id }
269 }
270
271 /**
272 * Update a relay set
273 */
274 updateSet(set: RelaySet): boolean {
275 if (!this._sets.has(set.id)) {
276 return false
277 }
278 this._sets.set(set.id, set)
279 return true
280 }
281
282 /**
283 * Reorder the favorite relays
284 */
285 reorderRelays(newOrder: RelayUrl[]): void {
286 this._relays.clear()
287 for (const relay of newOrder) {
288 this._relays.set(relay.value, relay)
289 }
290 }
291
292 /**
293 * Reorder the relay sets
294 */
295 reorderSets(newOrder: RelaySet[]): void {
296 this._setOrder.length = 0
297 for (const set of newOrder) {
298 if (this._sets.has(set.id)) {
299 this._setOrder.push(set.id)
300 }
301 }
302 }
303
304 /**
305 * Convert to Nostr event tags format
306 */
307 toTags(pubkey: string): string[][] {
308 const tags: string[][] = []
309
310 for (const relay of this._relays.values()) {
311 tags.push(['relay', relay.value])
312 }
313
314 for (const id of this._setOrder) {
315 const set = this._sets.get(id)
316 if (set) {
317 tags.push(['a', `${kinds.Relaysets}:${pubkey}:${id}`])
318 }
319 }
320
321 return tags
322 }
323
324 /**
325 * Convert to a draft event for publishing
326 */
327 toDraftEvent(pubkey: string): {
328 kind: number
329 content: string
330 created_at: number
331 tags: string[][]
332 } {
333 return {
334 kind: 10012, // ExtendedKind.FAVORITE_RELAYS
335 content: '',
336 created_at: Timestamp.now().unix,
337 tags: this.toTags(pubkey)
338 }
339 }
340 }
341