MuteList.ts raw
1 import { Event, kinds } from 'nostr-tools'
2 import { Pubkey, Timestamp } from '../shared'
3 import { CannotMuteSelfError } from './errors'
4
5 /**
6 * Type of mute visibility
7 */
8 export type MuteVisibility = 'public' | 'private'
9
10 /**
11 * A muted entry (user or other content)
12 */
13 export type MuteEntry = {
14 pubkey: Pubkey
15 visibility: MuteVisibility
16 }
17
18 /**
19 * Result of a mute/unmute operation
20 */
21 export type MuteListChange =
22 | { type: 'muted'; pubkey: Pubkey; visibility: MuteVisibility }
23 | { type: 'unmuted'; pubkey: Pubkey }
24 | { type: 'visibility_changed'; pubkey: Pubkey; from: MuteVisibility; to: MuteVisibility }
25 | { type: 'no_change' }
26
27 /**
28 * MuteList Aggregate
29 *
30 * Represents a user's mute list (kind 10000 event in Nostr).
31 * Supports both public mutes (visible to others) and private mutes (encrypted).
32 *
33 * Invariants:
34 * - Cannot mute self
35 * - No duplicate entries (a user can only be muted once, either public or private)
36 * - Pubkeys must be valid
37 *
38 * Note: The encryption/decryption of private mutes is handled at the infrastructure layer.
39 * This aggregate works with already-decrypted private tags.
40 */
41 export class MuteList {
42 private readonly _publicMutes: Map<string, Pubkey>
43 private readonly _privateMutes: Map<string, Pubkey>
44
45 private constructor(
46 private readonly _owner: Pubkey,
47 publicMutes: Pubkey[],
48 privateMutes: Pubkey[]
49 ) {
50 this._publicMutes = new Map()
51 this._privateMutes = new Map()
52
53 for (const pubkey of publicMutes) {
54 this._publicMutes.set(pubkey.hex, pubkey)
55 }
56 for (const pubkey of privateMutes) {
57 this._privateMutes.set(pubkey.hex, pubkey)
58 }
59 }
60
61 /**
62 * Create an empty MuteList for a user
63 */
64 static empty(owner: Pubkey): MuteList {
65 return new MuteList(owner, [], [])
66 }
67
68 /**
69 * Reconstruct a MuteList from a Nostr kind 10000 event
70 *
71 * @param event The mute list event
72 * @param decryptedPrivateTags The decrypted private tags (if any)
73 */
74 static fromEvent(event: Event, decryptedPrivateTags: string[][] = []): MuteList {
75 if (event.kind !== kinds.Mutelist) {
76 throw new Error(`Expected kind ${kinds.Mutelist}, got ${event.kind}`)
77 }
78
79 const owner = Pubkey.fromHex(event.pubkey)
80 const publicMutes: Pubkey[] = []
81 const privateMutes: Pubkey[] = []
82
83 // Extract public mutes from event tags
84 for (const tag of event.tags) {
85 if (tag[0] === 'p' && tag[1]) {
86 const pubkey = Pubkey.tryFromString(tag[1])
87 if (pubkey) {
88 publicMutes.push(pubkey)
89 }
90 }
91 }
92
93 // Extract private mutes from decrypted content
94 for (const tag of decryptedPrivateTags) {
95 if (tag[0] === 'p' && tag[1]) {
96 const pubkey = Pubkey.tryFromString(tag[1])
97 if (pubkey) {
98 privateMutes.push(pubkey)
99 }
100 }
101 }
102
103 return new MuteList(owner, publicMutes, privateMutes)
104 }
105
106 /**
107 * The owner of this mute list
108 */
109 get owner(): Pubkey {
110 return this._owner
111 }
112
113 /**
114 * Total number of muted users
115 */
116 get count(): number {
117 return this._publicMutes.size + this._privateMutes.size
118 }
119
120 /**
121 * Number of publicly muted users
122 */
123 get publicCount(): number {
124 return this._publicMutes.size
125 }
126
127 /**
128 * Number of privately muted users
129 */
130 get privateCount(): number {
131 return this._privateMutes.size
132 }
133
134 /**
135 * Get all muted pubkeys (both public and private)
136 */
137 getAllMuted(): Pubkey[] {
138 return [...this.getPublicMuted(), ...this.getPrivateMuted()]
139 }
140
141 /**
142 * Get publicly muted pubkeys
143 */
144 getPublicMuted(): Pubkey[] {
145 return Array.from(this._publicMutes.values())
146 }
147
148 /**
149 * Get privately muted pubkeys
150 */
151 getPrivateMuted(): Pubkey[] {
152 return Array.from(this._privateMutes.values())
153 }
154
155 /**
156 * Check if a user is muted (either publicly or privately)
157 */
158 isMuted(pubkey: Pubkey): boolean {
159 return this._publicMutes.has(pubkey.hex) || this._privateMutes.has(pubkey.hex)
160 }
161
162 /**
163 * Get the mute visibility for a user
164 */
165 getMuteVisibility(pubkey: Pubkey): MuteVisibility | null {
166 if (this._publicMutes.has(pubkey.hex)) return 'public'
167 if (this._privateMutes.has(pubkey.hex)) return 'private'
168 return null
169 }
170
171 /**
172 * Mute a user publicly
173 *
174 * @throws CannotMuteSelfError if attempting to mute self
175 * @returns MuteListChange indicating what changed
176 */
177 mutePublicly(pubkey: Pubkey): MuteListChange {
178 if (pubkey.equals(this._owner)) {
179 throw new CannotMuteSelfError()
180 }
181
182 // Already publicly muted
183 if (this._publicMutes.has(pubkey.hex)) {
184 return { type: 'no_change' }
185 }
186
187 // Was privately muted, switch to public
188 if (this._privateMutes.has(pubkey.hex)) {
189 this._privateMutes.delete(pubkey.hex)
190 this._publicMutes.set(pubkey.hex, pubkey)
191 return { type: 'visibility_changed', pubkey, from: 'private', to: 'public' }
192 }
193
194 // New public mute
195 this._publicMutes.set(pubkey.hex, pubkey)
196 return { type: 'muted', pubkey, visibility: 'public' }
197 }
198
199 /**
200 * Mute a user privately
201 *
202 * @throws CannotMuteSelfError if attempting to mute self
203 * @returns MuteListChange indicating what changed
204 */
205 mutePrivately(pubkey: Pubkey): MuteListChange {
206 if (pubkey.equals(this._owner)) {
207 throw new CannotMuteSelfError()
208 }
209
210 // Already privately muted
211 if (this._privateMutes.has(pubkey.hex)) {
212 return { type: 'no_change' }
213 }
214
215 // Was publicly muted, switch to private
216 if (this._publicMutes.has(pubkey.hex)) {
217 this._publicMutes.delete(pubkey.hex)
218 this._privateMutes.set(pubkey.hex, pubkey)
219 return { type: 'visibility_changed', pubkey, from: 'public', to: 'private' }
220 }
221
222 // New private mute
223 this._privateMutes.set(pubkey.hex, pubkey)
224 return { type: 'muted', pubkey, visibility: 'private' }
225 }
226
227 /**
228 * Unmute a user (removes from both public and private)
229 *
230 * @returns MuteListChange indicating what changed
231 */
232 unmute(pubkey: Pubkey): MuteListChange {
233 if (this._publicMutes.has(pubkey.hex)) {
234 this._publicMutes.delete(pubkey.hex)
235 return { type: 'unmuted', pubkey }
236 }
237
238 if (this._privateMutes.has(pubkey.hex)) {
239 this._privateMutes.delete(pubkey.hex)
240 return { type: 'unmuted', pubkey }
241 }
242
243 return { type: 'no_change' }
244 }
245
246 /**
247 * Switch a public mute to private
248 *
249 * @returns MuteListChange indicating what changed
250 */
251 switchToPrivate(pubkey: Pubkey): MuteListChange {
252 return this.mutePrivately(pubkey)
253 }
254
255 /**
256 * Switch a private mute to public
257 *
258 * @returns MuteListChange indicating what changed
259 */
260 switchToPublic(pubkey: Pubkey): MuteListChange {
261 return this.mutePublicly(pubkey)
262 }
263
264 /**
265 * Convert public mutes to Nostr event tags format
266 */
267 toPublicTags(): string[][] {
268 return Array.from(this._publicMutes.values()).map((pubkey) => ['p', pubkey.hex])
269 }
270
271 /**
272 * Convert private mutes to tags format (for encryption)
273 */
274 toPrivateTags(): string[][] {
275 return Array.from(this._privateMutes.values()).map((pubkey) => ['p', pubkey.hex])
276 }
277
278 /**
279 * Convert to a draft event for publishing
280 *
281 * Note: The content field should be encrypted by the caller using NIP-04
282 * with JSON.stringify(this.toPrivateTags())
283 *
284 * @param encryptedContent The NIP-04 encrypted private tags
285 */
286 toDraftEvent(encryptedContent: string = ''): {
287 kind: number
288 content: string
289 created_at: number
290 tags: string[][]
291 } {
292 return {
293 kind: kinds.Mutelist,
294 content: encryptedContent,
295 created_at: Timestamp.now().unix,
296 tags: this.toPublicTags()
297 }
298 }
299
300 /**
301 * Check if private mutes need to be encrypted/updated
302 * Returns true if there are private mutes that need to be persisted
303 */
304 hasPrivateMutes(): boolean {
305 return this._privateMutes.size > 0
306 }
307 }
308