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