Feed.ts raw

   1  import { Pubkey } from '../shared/value-objects/Pubkey'
   2  import { RelayUrl } from '../shared/value-objects/RelayUrl'
   3  import { Timestamp } from '../shared/value-objects/Timestamp'
   4  import { FeedType } from './FeedType'
   5  import { ContentFilter } from './ContentFilter'
   6  import { RelayStrategy } from './RelayStrategy'
   7  import { TimelineQuery } from './TimelineQuery'
   8  import { FeedSwitched, ContentFilterUpdated, FeedRefreshed } from './events'
   9  
  10  /**
  11   * Options for switching feeds
  12   */
  13  export interface FeedSwitchOptions {
  14    relaySetId?: string
  15    relayUrl?: string
  16  }
  17  
  18  /**
  19   * Options for building timeline queries
  20   */
  21  export interface TimelineQueryOptions {
  22    authors?: Pubkey[]
  23    kinds?: number[]
  24    limit?: number
  25  }
  26  
  27  /**
  28   * Serializable state for persistence
  29   */
  30  export interface FeedState {
  31    feedType: string
  32    relaySetId?: string
  33    relayUrl?: string
  34    relayUrls: string[]
  35    contentFilter: {
  36      hideMutedUsers: boolean
  37      hideContentMentioningMuted: boolean
  38      hideUntrustedUsers: boolean
  39      hideReplies: boolean
  40      hideReposts: boolean
  41      allowedKinds: number[]
  42      nsfwPolicy: string
  43    }
  44    lastRefreshedAt?: number
  45  }
  46  
  47  /**
  48   * Feed Aggregate
  49   *
  50   * Represents the user's active feed configuration and state.
  51   * This is the aggregate root for the Feed bounded context's query side.
  52   *
  53   * Invariants:
  54   * - Must have a valid feed type
  55   * - For relay feeds, must have resolved relay URLs
  56   * - Content filter is always present with sensible defaults
  57   */
  58  export class Feed {
  59    private constructor(
  60      private readonly _owner: Pubkey | null,
  61      private _feedType: FeedType,
  62      private _relayStrategy: RelayStrategy,
  63      private _resolvedRelayUrls: RelayUrl[],
  64      private _contentFilter: ContentFilter,
  65      private _lastRefreshedAt: Timestamp | null
  66    ) {}
  67  
  68    // ============================================================================
  69    // Factory Methods
  70    // ============================================================================
  71  
  72    /**
  73     * Create a following feed (shows posts from followed users)
  74     */
  75    static following(owner: Pubkey): Feed {
  76      return new Feed(
  77        owner,
  78        FeedType.following(),
  79        RelayStrategy.authorWriteRelays(),
  80        [],
  81        ContentFilter.default(),
  82        null
  83      )
  84    }
  85  
  86    /**
  87     * Create a pinned users feed
  88     */
  89    static pinned(owner: Pubkey): Feed {
  90      return new Feed(
  91        owner,
  92        FeedType.pinned(),
  93        RelayStrategy.authorWriteRelays(),
  94        [],
  95        ContentFilter.default(),
  96        null
  97      )
  98    }
  99  
 100    /**
 101     * Create a relay set feed
 102     */
 103    static relays(owner: Pubkey, setId: string, relayUrls: RelayUrl[]): Feed {
 104      return new Feed(
 105        owner,
 106        FeedType.relays(setId),
 107        RelayStrategy.specific(relayUrls, setId),
 108        relayUrls,
 109        ContentFilter.default(),
 110        null
 111      )
 112    }
 113  
 114    /**
 115     * Create a single relay feed
 116     */
 117    static singleRelay(relayUrl: RelayUrl): Feed {
 118      return new Feed(
 119        null,
 120        FeedType.relay(relayUrl.value),
 121        RelayStrategy.single(relayUrl),
 122        [relayUrl],
 123        ContentFilter.default(),
 124        null
 125      )
 126    }
 127  
 128    /**
 129     * Create an empty/uninitialized feed
 130     */
 131    static empty(): Feed {
 132      return new Feed(
 133        null,
 134        FeedType.following(),
 135        RelayStrategy.bigRelays(),
 136        [],
 137        ContentFilter.default(),
 138        null
 139      )
 140    }
 141  
 142    /**
 143     * Restore from persisted state
 144     */
 145    static fromState(state: FeedState, owner?: Pubkey): Feed {
 146      const feedType = FeedType.tryFromString(
 147        state.feedType,
 148        state.relaySetId ?? state.relayUrl
 149      )
 150  
 151      if (!feedType) {
 152        return Feed.empty()
 153      }
 154  
 155      const relayUrls = state.relayUrls
 156        .map((url) => RelayUrl.tryCreate(url))
 157        .filter((r): r is RelayUrl => r !== null)
 158  
 159      let relayStrategy: RelayStrategy
 160      if (feedType.value === 'relay' && relayUrls.length > 0) {
 161        relayStrategy = RelayStrategy.single(relayUrls[0])
 162      } else if (feedType.value === 'relays' && relayUrls.length > 0) {
 163        relayStrategy = RelayStrategy.specific(relayUrls, state.relaySetId)
 164      } else if (feedType.isSocialFeed) {
 165        relayStrategy = RelayStrategy.authorWriteRelays()
 166      } else {
 167        relayStrategy = RelayStrategy.bigRelays()
 168      }
 169  
 170      const contentFilter = ContentFilter.fromPreferences({
 171        hideMutedUsers: state.contentFilter.hideMutedUsers,
 172        hideContentMentioningMuted: state.contentFilter.hideContentMentioningMuted,
 173        hideUntrustedUsers: state.contentFilter.hideUntrustedUsers,
 174        hideReplies: state.contentFilter.hideReplies,
 175        hideReposts: state.contentFilter.hideReposts,
 176        allowedKinds: state.contentFilter.allowedKinds,
 177        nsfwPolicy: state.contentFilter.nsfwPolicy as 'hide' | 'hide_content' | 'show'
 178      })
 179  
 180      return new Feed(
 181        owner ?? null,
 182        feedType,
 183        relayStrategy,
 184        relayUrls,
 185        contentFilter,
 186        state.lastRefreshedAt ? Timestamp.fromUnix(state.lastRefreshedAt) : null
 187      )
 188    }
 189  
 190    // ============================================================================
 191    // Queries
 192    // ============================================================================
 193  
 194    get owner(): Pubkey | null {
 195      return this._owner
 196    }
 197  
 198    get type(): FeedType {
 199      return this._feedType
 200    }
 201  
 202    get relayStrategy(): RelayStrategy {
 203      return this._relayStrategy
 204    }
 205  
 206    get relayUrls(): readonly RelayUrl[] {
 207      return this._resolvedRelayUrls
 208    }
 209  
 210    get contentFilter(): ContentFilter {
 211      return this._contentFilter
 212    }
 213  
 214    get lastRefreshedAt(): Timestamp | null {
 215      return this._lastRefreshedAt
 216    }
 217  
 218    /**
 219     * Check if this is a social feed (following or pinned)
 220     */
 221    get isSocialFeed(): boolean {
 222      return this._feedType.isSocialFeed
 223    }
 224  
 225    /**
 226     * Check if this is a relay-based feed
 227     */
 228    get isRelayFeed(): boolean {
 229      return this._feedType.isRelayFeed
 230    }
 231  
 232    /**
 233     * Check if the feed has resolved relay URLs
 234     */
 235    get hasRelayUrls(): boolean {
 236      return this._resolvedRelayUrls.length > 0
 237    }
 238  
 239    /**
 240     * Get relay URLs as strings for compatibility
 241     */
 242    get relayUrlStrings(): string[] {
 243      return this._resolvedRelayUrls.map((r) => r.value)
 244    }
 245  
 246    // ============================================================================
 247    // Commands
 248    // ============================================================================
 249  
 250    /**
 251     * Switch to a different feed type
 252     * Returns a domain event describing the change
 253     */
 254    switchTo(newType: FeedType, relayUrls: RelayUrl[] = []): FeedSwitched {
 255      const previousType = this._feedType
 256  
 257      this._feedType = newType
 258  
 259      // Update relay strategy based on new type
 260      if (newType.value === 'relay' && relayUrls.length > 0) {
 261        this._relayStrategy = RelayStrategy.single(relayUrls[0])
 262        this._resolvedRelayUrls = [relayUrls[0]]
 263      } else if (newType.value === 'relays' && relayUrls.length > 0) {
 264        this._relayStrategy = RelayStrategy.specific(relayUrls, newType.relaySetId ?? undefined)
 265        this._resolvedRelayUrls = relayUrls
 266      } else if (newType.isSocialFeed) {
 267        this._relayStrategy = RelayStrategy.authorWriteRelays()
 268        this._resolvedRelayUrls = []
 269      } else {
 270        this._relayStrategy = RelayStrategy.bigRelays()
 271        this._resolvedRelayUrls = []
 272      }
 273  
 274      this._lastRefreshedAt = Timestamp.now()
 275  
 276      return new FeedSwitched(
 277        this._owner,
 278        previousType,
 279        newType,
 280        newType.relaySetId ?? undefined
 281      )
 282    }
 283  
 284    /**
 285     * Update the resolved relay URLs (after resolution)
 286     */
 287    setResolvedRelayUrls(urls: RelayUrl[]): void {
 288      this._resolvedRelayUrls = [...urls]
 289    }
 290  
 291    /**
 292     * Update content filter settings
 293     * Returns a domain event describing the change
 294     */
 295    updateContentFilter(newFilter: ContentFilter): ContentFilterUpdated {
 296      const previousFilter = this._contentFilter
 297      this._contentFilter = newFilter
 298  
 299      return new ContentFilterUpdated(
 300        this._owner!,
 301        previousFilter,
 302        newFilter
 303      )
 304    }
 305  
 306    /**
 307     * Mark the feed as refreshed
 308     * Returns a domain event
 309     */
 310    refresh(): FeedRefreshed {
 311      this._lastRefreshedAt = Timestamp.now()
 312  
 313      return new FeedRefreshed(this._owner, this._feedType)
 314    }
 315  
 316    // ============================================================================
 317    // Timeline Query Building
 318    // ============================================================================
 319  
 320    /**
 321     * Build a timeline query for this feed configuration
 322     *
 323     * For social feeds, authors should be provided (followings or pinned users).
 324     * For relay feeds, the resolved relay URLs are used.
 325     */
 326    buildTimelineQuery(options: TimelineQueryOptions = {}): TimelineQuery | null {
 327      // Need relay URLs to build a query
 328      if (this._resolvedRelayUrls.length === 0) {
 329        return null
 330      }
 331  
 332      if (this.isSocialFeed) {
 333        // Social feeds need authors
 334        if (!options.authors || options.authors.length === 0) {
 335          return null
 336        }
 337  
 338        return TimelineQuery.forAuthors(
 339          options.authors,
 340          this._resolvedRelayUrls,
 341          {
 342            kinds: options.kinds,
 343            limit: options.limit
 344          }
 345        )
 346      }
 347  
 348      // Relay feeds - global query
 349      return TimelineQuery.forRelay(
 350        this._resolvedRelayUrls[0],
 351        {
 352          kinds: options.kinds,
 353          limit: options.limit
 354        }
 355      ).withRelays(this._resolvedRelayUrls)
 356    }
 357  
 358    // ============================================================================
 359    // Persistence
 360    // ============================================================================
 361  
 362    /**
 363     * Convert to serializable state for persistence
 364     */
 365    toState(): FeedState {
 366      return {
 367        feedType: this._feedType.value,
 368        relaySetId: this._feedType.relaySetId ?? undefined,
 369        relayUrl: this._feedType.relayUrl ?? undefined,
 370        relayUrls: this._resolvedRelayUrls.map((r) => r.value),
 371        contentFilter: {
 372          hideMutedUsers: this._contentFilter.hideMutedUsers,
 373          hideContentMentioningMuted: this._contentFilter.hideContentMentioningMuted,
 374          hideUntrustedUsers: this._contentFilter.hideUntrustedUsers,
 375          hideReplies: this._contentFilter.hideReplies,
 376          hideReposts: this._contentFilter.hideReposts,
 377          allowedKinds: [...this._contentFilter.allowedKinds],
 378          nsfwPolicy: this._contentFilter.nsfwPolicy
 379        },
 380        lastRefreshedAt: this._lastRefreshedAt?.unix
 381      }
 382    }
 383  
 384    /**
 385     * Create a copy of this feed with a new owner
 386     */
 387    withOwner(owner: Pubkey): Feed {
 388      return new Feed(
 389        owner,
 390        this._feedType,
 391        this._relayStrategy,
 392        [...this._resolvedRelayUrls],
 393        this._contentFilter,
 394        this._lastRefreshedAt
 395      )
 396    }
 397  
 398    /**
 399     * Check equality with another feed
 400     */
 401    equals(other: Feed): boolean {
 402      if (!this._feedType.equals(other._feedType)) return false
 403      if (this._resolvedRelayUrls.length !== other._resolvedRelayUrls.length) return false
 404  
 405      for (let i = 0; i < this._resolvedRelayUrls.length; i++) {
 406        if (!this._resolvedRelayUrls[i].equals(other._resolvedRelayUrls[i])) return false
 407      }
 408  
 409      return this._contentFilter.equals(other._contentFilter)
 410    }
 411  }
 412