TimelineQuery.ts raw
1 import { Filter } from 'nostr-tools'
2 import { Pubkey } from '../shared/value-objects/Pubkey'
3 import { RelayUrl } from '../shared/value-objects/RelayUrl'
4 import { Timestamp } from '../shared/value-objects/Timestamp'
5 import { TFeedSubRequest } from '@/types'
6
7 /**
8 * Parameters for creating a timeline query
9 */
10 export interface TimelineQueryParams {
11 relays: RelayUrl[]
12 authors?: Pubkey[]
13 kinds?: number[]
14 since?: Timestamp
15 until?: Timestamp
16 limit?: number
17 hashtags?: string[]
18 mentionedPubkeys?: Pubkey[]
19 eventIds?: string[]
20 }
21
22 /**
23 * Default kinds for timeline queries
24 */
25 export const DEFAULT_TIMELINE_KINDS = [1, 6, 16] // notes, reposts, generic reposts
26
27 /**
28 * Default limit for timeline queries
29 */
30 export const DEFAULT_TIMELINE_LIMIT = 50
31
32 /**
33 * TimelineQuery Value Object
34 *
35 * Represents the parameters needed to subscribe to a timeline.
36 * Immutable and self-validating.
37 */
38 export class TimelineQuery {
39 private constructor(
40 private readonly _relays: readonly RelayUrl[],
41 private readonly _authors: readonly Pubkey[],
42 private readonly _kinds: readonly number[],
43 private readonly _since: Timestamp | null,
44 private readonly _until: Timestamp | null,
45 private readonly _limit: number,
46 private readonly _hashtags: readonly string[],
47 private readonly _mentionedPubkeys: readonly Pubkey[],
48 private readonly _eventIds: readonly string[]
49 ) {}
50
51 /**
52 * Create a timeline query from parameters
53 */
54 static create(params: TimelineQueryParams): TimelineQuery {
55 if (params.relays.length === 0) {
56 throw new Error('TimelineQuery requires at least one relay')
57 }
58
59 return new TimelineQuery(
60 [...params.relays],
61 params.authors ? [...params.authors] : [],
62 params.kinds ?? DEFAULT_TIMELINE_KINDS,
63 params.since ?? null,
64 params.until ?? null,
65 params.limit ?? DEFAULT_TIMELINE_LIMIT,
66 params.hashtags ?? [],
67 params.mentionedPubkeys ?? [],
68 params.eventIds ?? []
69 )
70 }
71
72 /**
73 * Create a query for a specific author's timeline
74 */
75 static forAuthor(author: Pubkey, relays: RelayUrl[], options?: {
76 kinds?: number[]
77 limit?: number
78 }): TimelineQuery {
79 return TimelineQuery.create({
80 relays,
81 authors: [author],
82 kinds: options?.kinds,
83 limit: options?.limit
84 })
85 }
86
87 /**
88 * Create a query for multiple authors (following feed)
89 */
90 static forAuthors(authors: Pubkey[], relays: RelayUrl[], options?: {
91 kinds?: number[]
92 limit?: number
93 }): TimelineQuery {
94 return TimelineQuery.create({
95 relays,
96 authors,
97 kinds: options?.kinds,
98 limit: options?.limit
99 })
100 }
101
102 /**
103 * Create a query for a global relay feed
104 */
105 static forRelay(relay: RelayUrl, options?: {
106 kinds?: number[]
107 limit?: number
108 }): TimelineQuery {
109 return TimelineQuery.create({
110 relays: [relay],
111 kinds: options?.kinds,
112 limit: options?.limit
113 })
114 }
115
116 /**
117 * Create a query for a hashtag
118 */
119 static forHashtag(hashtag: string, relays: RelayUrl[], options?: {
120 kinds?: number[]
121 limit?: number
122 }): TimelineQuery {
123 return TimelineQuery.create({
124 relays,
125 hashtags: [hashtag.replace(/^#/, '')],
126 kinds: options?.kinds,
127 limit: options?.limit
128 })
129 }
130
131 // Getters
132 get relays(): readonly RelayUrl[] {
133 return this._relays
134 }
135
136 get authors(): readonly Pubkey[] {
137 return this._authors
138 }
139
140 get kinds(): readonly number[] {
141 return this._kinds
142 }
143
144 get since(): Timestamp | null {
145 return this._since
146 }
147
148 get until(): Timestamp | null {
149 return this._until
150 }
151
152 get limit(): number {
153 return this._limit
154 }
155
156 get hashtags(): readonly string[] {
157 return this._hashtags
158 }
159
160 get mentionedPubkeys(): readonly Pubkey[] {
161 return this._mentionedPubkeys
162 }
163
164 get eventIds(): readonly string[] {
165 return this._eventIds
166 }
167
168 /**
169 * Check if this query has author filters
170 */
171 get hasAuthors(): boolean {
172 return this._authors.length > 0
173 }
174
175 /**
176 * Check if this query is a global relay query (no author filters)
177 */
178 get isGlobalQuery(): boolean {
179 return this._authors.length === 0 && this._hashtags.length === 0
180 }
181
182 /**
183 * Convert to Nostr filter format
184 */
185 toNostrFilter(): Filter {
186 const filter: Filter = {}
187
188 if (this._authors.length > 0) {
189 filter.authors = this._authors.map((a) => a.hex)
190 }
191
192 if (this._kinds.length > 0) {
193 filter.kinds = [...this._kinds]
194 }
195
196 if (this._since) {
197 filter.since = this._since.unix
198 }
199
200 if (this._until) {
201 filter.until = this._until.unix
202 }
203
204 if (this._limit > 0) {
205 filter.limit = this._limit
206 }
207
208 if (this._hashtags.length > 0) {
209 filter['#t'] = [...this._hashtags]
210 }
211
212 if (this._mentionedPubkeys.length > 0) {
213 filter['#p'] = this._mentionedPubkeys.map((p) => p.hex)
214 }
215
216 if (this._eventIds.length > 0) {
217 filter.ids = [...this._eventIds]
218 }
219
220 return filter
221 }
222
223 /**
224 * Convert to subscription request format used by the application
225 */
226 toSubRequests(): TFeedSubRequest[] {
227 const filter = this.toNostrFilter()
228 // Remove since/until as those are handled by the subscription manager
229 const { since, until, ...filterWithoutTime } = filter
230
231 return [
232 {
233 urls: this._relays.map((r) => r.value),
234 filter: filterWithoutTime
235 }
236 ]
237 }
238
239 /**
240 * Convert to multiple subscription requests (for per-relay optimization)
241 */
242 toSubRequestsPerRelay(): TFeedSubRequest[] {
243 const filter = this.toNostrFilter()
244 const { since, until, ...filterWithoutTime } = filter
245
246 return this._relays.map((relay) => ({
247 urls: [relay.value],
248 filter: filterWithoutTime
249 }))
250 }
251
252 // Immutable modification methods
253 withRelays(relays: RelayUrl[]): TimelineQuery {
254 return new TimelineQuery(
255 [...relays],
256 this._authors,
257 this._kinds,
258 this._since,
259 this._until,
260 this._limit,
261 this._hashtags,
262 this._mentionedPubkeys,
263 this._eventIds
264 )
265 }
266
267 withAuthors(authors: Pubkey[]): TimelineQuery {
268 return new TimelineQuery(
269 this._relays,
270 [...authors],
271 this._kinds,
272 this._since,
273 this._until,
274 this._limit,
275 this._hashtags,
276 this._mentionedPubkeys,
277 this._eventIds
278 )
279 }
280
281 withKinds(kinds: number[]): TimelineQuery {
282 return new TimelineQuery(
283 this._relays,
284 this._authors,
285 [...kinds],
286 this._since,
287 this._until,
288 this._limit,
289 this._hashtags,
290 this._mentionedPubkeys,
291 this._eventIds
292 )
293 }
294
295 withSince(since: Timestamp): TimelineQuery {
296 return new TimelineQuery(
297 this._relays,
298 this._authors,
299 this._kinds,
300 since,
301 this._until,
302 this._limit,
303 this._hashtags,
304 this._mentionedPubkeys,
305 this._eventIds
306 )
307 }
308
309 withUntil(until: Timestamp): TimelineQuery {
310 return new TimelineQuery(
311 this._relays,
312 this._authors,
313 this._kinds,
314 this._since,
315 until,
316 this._limit,
317 this._hashtags,
318 this._mentionedPubkeys,
319 this._eventIds
320 )
321 }
322
323 withLimit(limit: number): TimelineQuery {
324 if (limit <= 0) {
325 throw new Error('Limit must be positive')
326 }
327 return new TimelineQuery(
328 this._relays,
329 this._authors,
330 this._kinds,
331 this._since,
332 this._until,
333 limit,
334 this._hashtags,
335 this._mentionedPubkeys,
336 this._eventIds
337 )
338 }
339
340 withHashtags(hashtags: string[]): TimelineQuery {
341 return new TimelineQuery(
342 this._relays,
343 this._authors,
344 this._kinds,
345 this._since,
346 this._until,
347 this._limit,
348 hashtags.map((t) => t.replace(/^#/, '')),
349 this._mentionedPubkeys,
350 this._eventIds
351 )
352 }
353
354 /**
355 * Generate a cache key for this query
356 */
357 toCacheKey(): string {
358 const parts = [
359 this._relays
360 .map((r) => r.value)
361 .sort()
362 .join(','),
363 this._authors
364 .map((a) => a.hex)
365 .sort()
366 .join(','),
367 [...this._kinds].sort().join(','),
368 [...this._hashtags].sort().join(',')
369 ]
370 return parts.join('|')
371 }
372
373 equals(other: TimelineQuery): boolean {
374 if (this._limit !== other._limit) return false
375 if (this._relays.length !== other._relays.length) return false
376 if (this._authors.length !== other._authors.length) return false
377 if (this._kinds.length !== other._kinds.length) return false
378 if (this._hashtags.length !== other._hashtags.length) return false
379
380 // Compare relays
381 const thisRelaySet = new Set(this._relays.map((r) => r.value))
382 for (const relay of other._relays) {
383 if (!thisRelaySet.has(relay.value)) return false
384 }
385
386 // Compare authors
387 const thisAuthorSet = new Set(this._authors.map((a) => a.hex))
388 for (const author of other._authors) {
389 if (!thisAuthorSet.has(author.hex)) return false
390 }
391
392 // Compare kinds
393 const thisKindSet = new Set(this._kinds)
394 for (const kind of other._kinds) {
395 if (!thisKindSet.has(kind)) return false
396 }
397
398 // Compare hashtags
399 const thisHashtagSet = new Set(this._hashtags)
400 for (const hashtag of other._hashtags) {
401 if (!thisHashtagSet.has(hashtag)) return false
402 }
403
404 return true
405 }
406 }
407