ContentFilter.ts raw
1 import { Event } from 'nostr-tools'
2
3 /**
4 * NSFW display policy options
5 */
6 export type NsfwDisplayPolicy = 'hide' | 'hide_content' | 'show'
7
8 /**
9 * Context required for filtering decisions
10 */
11 export interface FilterContext {
12 mutedPubkeys: Set<string>
13 trustedPubkeys?: Set<string>
14 deletedEventIds?: Set<string>
15 currentUserPubkey?: string
16 pinnedEventIds?: Set<string>
17 }
18
19 /**
20 * Result of a filter check with reason
21 */
22 export type FilterResult = {
23 shouldShow: boolean
24 reason?: FilterReason
25 }
26
27 /**
28 * Reason why an event was filtered
29 */
30 export type FilterReason =
31 | 'muted_author'
32 | 'mentions_muted_user'
33 | 'untrusted_author'
34 | 'deleted'
35 | 'reply_filtered'
36 | 'repost_filtered'
37 | 'nsfw_hidden'
38 | 'kind_not_allowed'
39
40 /**
41 * ContentFilter Value Object
42 *
43 * Encapsulates all filtering criteria for timeline content.
44 * Immutable - all modifications return new instances.
45 */
46 export class ContentFilter {
47 private constructor(
48 private readonly _hideMutedUsers: boolean,
49 private readonly _hideContentMentioningMuted: boolean,
50 private readonly _hideUntrustedUsers: boolean,
51 private readonly _hideReplies: boolean,
52 private readonly _hideReposts: boolean,
53 private readonly _allowedKinds: readonly number[],
54 private readonly _nsfwPolicy: NsfwDisplayPolicy
55 ) {}
56
57 /**
58 * Create default content filter with sensible defaults
59 */
60 static default(): ContentFilter {
61 return new ContentFilter(
62 true, // hideMutedUsers
63 true, // hideContentMentioningMuted
64 false, // hideUntrustedUsers
65 false, // hideReplies
66 false, // hideReposts
67 [], // allowedKinds (empty = allow all)
68 'hide_content' // nsfwPolicy
69 )
70 }
71
72 /**
73 * Create filter from user preferences
74 */
75 static fromPreferences(prefs: {
76 hideMutedUsers?: boolean
77 hideContentMentioningMuted?: boolean
78 hideUntrustedUsers?: boolean
79 hideReplies?: boolean
80 hideReposts?: boolean
81 allowedKinds?: number[]
82 nsfwPolicy?: NsfwDisplayPolicy
83 }): ContentFilter {
84 return new ContentFilter(
85 prefs.hideMutedUsers ?? true,
86 prefs.hideContentMentioningMuted ?? true,
87 prefs.hideUntrustedUsers ?? false,
88 prefs.hideReplies ?? false,
89 prefs.hideReposts ?? false,
90 prefs.allowedKinds ?? [],
91 prefs.nsfwPolicy ?? 'hide_content'
92 )
93 }
94
95 // Getters
96 get hideMutedUsers(): boolean {
97 return this._hideMutedUsers
98 }
99
100 get hideContentMentioningMuted(): boolean {
101 return this._hideContentMentioningMuted
102 }
103
104 get hideUntrustedUsers(): boolean {
105 return this._hideUntrustedUsers
106 }
107
108 get hideReplies(): boolean {
109 return this._hideReplies
110 }
111
112 get hideReposts(): boolean {
113 return this._hideReposts
114 }
115
116 get allowedKinds(): readonly number[] {
117 return this._allowedKinds
118 }
119
120 get nsfwPolicy(): NsfwDisplayPolicy {
121 return this._nsfwPolicy
122 }
123
124 /**
125 * Check if a kind is allowed by this filter
126 */
127 isKindAllowed(kind: number): boolean {
128 // Empty array means all kinds allowed
129 if (this._allowedKinds.length === 0) return true
130 return this._allowedKinds.includes(kind)
131 }
132
133 /**
134 * Check if an event should be shown based on this filter and context
135 */
136 shouldShow(event: Event, context: FilterContext): FilterResult {
137 // Check kind filter first
138 if (!this.isKindAllowed(event.kind)) {
139 return { shouldShow: false, reason: 'kind_not_allowed' }
140 }
141
142 // Check if event is pinned (pinned events bypass most filters)
143 if (context.pinnedEventIds?.has(event.id)) {
144 return { shouldShow: true }
145 }
146
147 // Check deleted
148 if (context.deletedEventIds?.has(event.id)) {
149 return { shouldShow: false, reason: 'deleted' }
150 }
151
152 // Check muted author
153 if (this._hideMutedUsers && context.mutedPubkeys.has(event.pubkey)) {
154 return { shouldShow: false, reason: 'muted_author' }
155 }
156
157 // Check if content mentions muted users
158 if (this._hideContentMentioningMuted) {
159 const mentionedPubkeys = this.extractMentionedPubkeys(event)
160 for (const pk of mentionedPubkeys) {
161 if (context.mutedPubkeys.has(pk)) {
162 return { shouldShow: false, reason: 'mentions_muted_user' }
163 }
164 }
165 }
166
167 // Check untrusted
168 if (this._hideUntrustedUsers && context.trustedPubkeys) {
169 if (!context.trustedPubkeys.has(event.pubkey)) {
170 return { shouldShow: false, reason: 'untrusted_author' }
171 }
172 }
173
174 // Check reply filter
175 if (this._hideReplies && this.isReply(event)) {
176 return { shouldShow: false, reason: 'reply_filtered' }
177 }
178
179 // Check repost filter
180 if (this._hideReposts && this.isRepost(event)) {
181 return { shouldShow: false, reason: 'repost_filtered' }
182 }
183
184 return { shouldShow: true }
185 }
186
187 /**
188 * Extract pubkeys mentioned in an event
189 */
190 private extractMentionedPubkeys(event: Event): string[] {
191 const pubkeys: string[] = []
192 for (const tag of event.tags) {
193 if (tag[0] === 'p' && tag[1]) {
194 pubkeys.push(tag[1])
195 }
196 }
197 return pubkeys
198 }
199
200 /**
201 * Check if event is a reply
202 */
203 private isReply(event: Event): boolean {
204 // Check for 'e' or 'E' tags with reply marker, or just any 'e' tag
205 for (const tag of event.tags) {
206 if ((tag[0] === 'e' || tag[0] === 'E') && tag[1]) {
207 // If marker is 'reply' or 'root', it's a reply
208 if (tag[3] === 'reply' || tag[3] === 'root') {
209 return true
210 }
211 // Legacy: any 'e' tag indicates reply
212 return true
213 }
214 }
215 return false
216 }
217
218 /**
219 * Check if event is a repost
220 */
221 private isRepost(event: Event): boolean {
222 return event.kind === 6 || event.kind === 16
223 }
224
225 // Immutable modification methods
226 withHideMutedUsers(hide: boolean): ContentFilter {
227 return new ContentFilter(
228 hide,
229 this._hideContentMentioningMuted,
230 this._hideUntrustedUsers,
231 this._hideReplies,
232 this._hideReposts,
233 this._allowedKinds,
234 this._nsfwPolicy
235 )
236 }
237
238 withHideContentMentioningMuted(hide: boolean): ContentFilter {
239 return new ContentFilter(
240 this._hideMutedUsers,
241 hide,
242 this._hideUntrustedUsers,
243 this._hideReplies,
244 this._hideReposts,
245 this._allowedKinds,
246 this._nsfwPolicy
247 )
248 }
249
250 withHideUntrustedUsers(hide: boolean): ContentFilter {
251 return new ContentFilter(
252 this._hideMutedUsers,
253 this._hideContentMentioningMuted,
254 hide,
255 this._hideReplies,
256 this._hideReposts,
257 this._allowedKinds,
258 this._nsfwPolicy
259 )
260 }
261
262 withHideReplies(hide: boolean): ContentFilter {
263 return new ContentFilter(
264 this._hideMutedUsers,
265 this._hideContentMentioningMuted,
266 this._hideUntrustedUsers,
267 hide,
268 this._hideReposts,
269 this._allowedKinds,
270 this._nsfwPolicy
271 )
272 }
273
274 withHideReposts(hide: boolean): ContentFilter {
275 return new ContentFilter(
276 this._hideMutedUsers,
277 this._hideContentMentioningMuted,
278 this._hideUntrustedUsers,
279 this._hideReplies,
280 hide,
281 this._allowedKinds,
282 this._nsfwPolicy
283 )
284 }
285
286 withAllowedKinds(kinds: number[]): ContentFilter {
287 return new ContentFilter(
288 this._hideMutedUsers,
289 this._hideContentMentioningMuted,
290 this._hideUntrustedUsers,
291 this._hideReplies,
292 this._hideReposts,
293 [...kinds],
294 this._nsfwPolicy
295 )
296 }
297
298 withNsfwPolicy(policy: NsfwDisplayPolicy): ContentFilter {
299 return new ContentFilter(
300 this._hideMutedUsers,
301 this._hideContentMentioningMuted,
302 this._hideUntrustedUsers,
303 this._hideReplies,
304 this._hideReposts,
305 this._allowedKinds,
306 policy
307 )
308 }
309
310 equals(other: ContentFilter): boolean {
311 if (this._hideMutedUsers !== other._hideMutedUsers) return false
312 if (this._hideContentMentioningMuted !== other._hideContentMentioningMuted) return false
313 if (this._hideUntrustedUsers !== other._hideUntrustedUsers) return false
314 if (this._hideReplies !== other._hideReplies) return false
315 if (this._hideReposts !== other._hideReposts) return false
316 if (this._nsfwPolicy !== other._nsfwPolicy) return false
317 if (this._allowedKinds.length !== other._allowedKinds.length) return false
318 for (let i = 0; i < this._allowedKinds.length; i++) {
319 if (this._allowedKinds[i] !== other._allowedKinds[i]) return false
320 }
321 return true
322 }
323 }
324