PinnedUsersList.ts raw
1 import { Event } from 'nostr-tools'
2 import { ExtendedKind } from '@/constants'
3 import { Pubkey, Timestamp } from '../shared'
4
5 /**
6 * Represents a pinned user entry
7 */
8 export type PinnedUserEntry = {
9 pubkey: Pubkey
10 isPrivate: boolean
11 }
12
13 /**
14 * Result of a pin/unpin operation
15 */
16 export type PinnedUsersListChange =
17 | { type: 'pinned'; pubkey: Pubkey }
18 | { type: 'unpinned'; pubkey: Pubkey }
19 | { type: 'no_change' }
20
21 /**
22 * PinnedUsersList Aggregate
23 *
24 * Represents a user's pinned users list (kind 10003 in Nostr).
25 * Supports both public (in tags) and private (encrypted content) pins.
26 *
27 * Invariants:
28 * - Cannot pin self
29 * - No duplicate entries
30 * - Pubkeys must be valid
31 */
32 export class PinnedUsersList {
33 private readonly _publicPins: Map<string, PinnedUserEntry>
34 private readonly _privatePins: Map<string, PinnedUserEntry>
35 private _encryptedContent: string
36
37 private constructor(
38 private readonly _owner: Pubkey,
39 publicPins: PinnedUserEntry[],
40 privatePins: PinnedUserEntry[],
41 encryptedContent: string = ''
42 ) {
43 this._publicPins = new Map()
44 this._privatePins = new Map()
45 this._encryptedContent = encryptedContent
46
47 for (const pin of publicPins) {
48 this._publicPins.set(pin.pubkey.hex, pin)
49 }
50 for (const pin of privatePins) {
51 this._privatePins.set(pin.pubkey.hex, pin)
52 }
53 }
54
55 /**
56 * Create an empty PinnedUsersList for a user
57 */
58 static empty(owner: Pubkey): PinnedUsersList {
59 return new PinnedUsersList(owner, [], [])
60 }
61
62 /**
63 * Reconstruct a PinnedUsersList from a Nostr event (public pins only)
64 * Private pins must be added separately after decryption
65 */
66 static fromEvent(event: Event): PinnedUsersList {
67 if (event.kind !== ExtendedKind.PINNED_USERS) {
68 throw new Error(`Expected kind ${ExtendedKind.PINNED_USERS}, got ${event.kind}`)
69 }
70
71 const owner = Pubkey.fromHex(event.pubkey)
72 const publicPins: PinnedUserEntry[] = []
73
74 for (const tag of event.tags) {
75 if (tag[0] === 'p' && tag[1]) {
76 const pubkey = Pubkey.tryFromString(tag[1])
77 if (pubkey) {
78 publicPins.push({ pubkey, isPrivate: false })
79 }
80 }
81 }
82
83 return new PinnedUsersList(owner, publicPins, [], event.content)
84 }
85
86 /**
87 * The owner of this pinned users list
88 */
89 get owner(): Pubkey {
90 return this._owner
91 }
92
93 /**
94 * Total number of pinned users (public + private)
95 */
96 get count(): number {
97 return this._publicPins.size + this._privatePins.size
98 }
99
100 /**
101 * Number of public pins
102 */
103 get publicCount(): number {
104 return this._publicPins.size
105 }
106
107 /**
108 * Number of private pins
109 */
110 get privateCount(): number {
111 return this._privatePins.size
112 }
113
114 /**
115 * The encrypted content (private pins)
116 */
117 get encryptedContent(): string {
118 return this._encryptedContent
119 }
120
121 /**
122 * Set decrypted private pins
123 */
124 setPrivatePins(privateTags: string[][]): void {
125 this._privatePins.clear()
126 for (const tag of privateTags) {
127 if (tag[0] === 'p' && tag[1]) {
128 const pubkey = Pubkey.tryFromString(tag[1])
129 if (pubkey) {
130 this._privatePins.set(pubkey.hex, { pubkey, isPrivate: true })
131 }
132 }
133 }
134 }
135
136 /**
137 * Get all pinned pubkeys
138 */
139 getPinnedPubkeys(): Pubkey[] {
140 const all = new Map(this._publicPins)
141 for (const [hex, entry] of this._privatePins) {
142 all.set(hex, entry)
143 }
144 return Array.from(all.values()).map((e) => e.pubkey)
145 }
146
147 /**
148 * Get all pinned entries
149 */
150 getEntries(): PinnedUserEntry[] {
151 const all = new Map(this._publicPins)
152 for (const [hex, entry] of this._privatePins) {
153 all.set(hex, entry)
154 }
155 return Array.from(all.values())
156 }
157
158 /**
159 * Get public entries only
160 */
161 getPublicEntries(): PinnedUserEntry[] {
162 return Array.from(this._publicPins.values())
163 }
164
165 /**
166 * Get private entries only
167 */
168 getPrivateEntries(): PinnedUserEntry[] {
169 return Array.from(this._privatePins.values())
170 }
171
172 /**
173 * Check if a user is pinned
174 */
175 isPinned(pubkey: Pubkey): boolean {
176 return this._publicPins.has(pubkey.hex) || this._privatePins.has(pubkey.hex)
177 }
178
179 /**
180 * Pin a user publicly
181 *
182 * @throws Error if attempting to pin self
183 * @returns PinnedUsersListChange indicating what changed
184 */
185 pin(pubkey: Pubkey): PinnedUsersListChange {
186 if (pubkey.equals(this._owner)) {
187 throw new Error('Cannot pin self')
188 }
189
190 if (this.isPinned(pubkey)) {
191 return { type: 'no_change' }
192 }
193
194 this._publicPins.set(pubkey.hex, { pubkey, isPrivate: false })
195 return { type: 'pinned', pubkey }
196 }
197
198 /**
199 * Unpin a user
200 *
201 * @returns PinnedUsersListChange indicating what changed
202 */
203 unpin(pubkey: Pubkey): PinnedUsersListChange {
204 const wasPublic = this._publicPins.delete(pubkey.hex)
205 const wasPrivate = this._privatePins.delete(pubkey.hex)
206
207 if (wasPublic || wasPrivate) {
208 return { type: 'unpinned', pubkey }
209 }
210
211 return { type: 'no_change' }
212 }
213
214 /**
215 * Convert public pins to Nostr event tags format
216 */
217 toTags(): string[][] {
218 return Array.from(this._publicPins.values()).map((entry) => ['p', entry.pubkey.hex])
219 }
220
221 /**
222 * Convert private pins to tags for encryption
223 */
224 toPrivateTags(): string[][] {
225 return Array.from(this._privatePins.values()).map((entry) => ['p', entry.pubkey.hex])
226 }
227
228 /**
229 * Set encrypted content (after encrypting private tags)
230 */
231 setEncryptedContent(content: string): void {
232 this._encryptedContent = content
233 }
234
235 /**
236 * Convert to a draft event for publishing
237 */
238 toDraftEvent(): { kind: number; content: string; created_at: number; tags: string[][] } {
239 return {
240 kind: ExtendedKind.PINNED_USERS,
241 content: this._encryptedContent,
242 created_at: Timestamp.now().unix,
243 tags: this.toTags()
244 }
245 }
246 }
247
248 /**
249 * Try to create a PinnedUsersList from an event
250 * Returns null if the event is not a valid pinned users event
251 */
252 export function tryToPinnedUsersList(event: Event | null | undefined): PinnedUsersList | null {
253 if (!event || event.kind !== ExtendedKind.PINNED_USERS) {
254 return null
255 }
256 try {
257 return PinnedUsersList.fromEvent(event)
258 } catch {
259 return null
260 }
261 }
262