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