PinList.ts raw
1 import { Event, kinds } from 'nostr-tools'
2 import { EventId, Pubkey, Timestamp } from '../shared'
3
4 /**
5 * Maximum number of pinned notes allowed
6 */
7 export const MAX_PINNED_NOTES = 5
8
9 /**
10 * A pinned note entry
11 */
12 export type PinEntry = {
13 eventId: EventId
14 pubkey?: Pubkey
15 relayHint?: string
16 }
17
18 /**
19 * Result of a pin operation
20 */
21 export type PinListChange =
22 | { type: 'pinned'; entry: PinEntry }
23 | { type: 'unpinned'; eventId: string }
24 | { type: 'no_change' }
25 | { type: 'limit_exceeded'; removed: PinEntry[] }
26
27 /**
28 * Error thrown when trying to pin non-own content
29 */
30 export class CannotPinOthersContentError extends Error {
31 constructor() {
32 super('Cannot pin content from other users')
33 this.name = 'CannotPinOthersContentError'
34 }
35 }
36
37 /**
38 * Error thrown when trying to pin non-note content
39 */
40 export class CanOnlyPinNotesError extends Error {
41 constructor() {
42 super('Can only pin short text notes')
43 this.name = 'CanOnlyPinNotesError'
44 }
45 }
46
47 /**
48 * PinList Aggregate
49 *
50 * Represents a user's pinned notes list (kind 10001 in Nostr).
51 * Users can pin their own short text notes to highlight them on their profile.
52 *
53 * Invariants:
54 * - Can only pin own notes (same pubkey)
55 * - Can only pin short text notes (kind 1)
56 * - Maximum of MAX_PINNED_NOTES entries (oldest removed when exceeded)
57 * - No duplicate entries
58 */
59 export class PinList {
60 private readonly _entries: Map<string, PinEntry>
61 private readonly _order: string[] // Maintains insertion order
62 private readonly _content: string
63
64 private constructor(
65 private readonly _owner: Pubkey,
66 entries: PinEntry[],
67 content: string = ''
68 ) {
69 this._entries = new Map()
70 this._order = []
71 for (const entry of entries) {
72 this._entries.set(entry.eventId.hex, entry)
73 this._order.push(entry.eventId.hex)
74 }
75 this._content = content
76 }
77
78 /**
79 * Create an empty PinList for a user
80 */
81 static empty(owner: Pubkey): PinList {
82 return new PinList(owner, [])
83 }
84
85 /**
86 * Reconstruct a PinList from a Nostr kind 10001 event
87 */
88 static fromEvent(event: Event): PinList {
89 if (event.kind !== kinds.Pinlist) {
90 throw new Error(`Expected kind ${kinds.Pinlist}, got ${event.kind}`)
91 }
92
93 const owner = Pubkey.fromHex(event.pubkey)
94 const entries: PinEntry[] = []
95
96 for (const tag of event.tags) {
97 if (tag[0] === 'e' && tag[1]) {
98 const eventId = EventId.tryFromString(tag[1])
99 if (eventId && !entries.some((e) => e.eventId.hex === eventId.hex)) {
100 const pubkey = tag[2] ? Pubkey.tryFromString(tag[2]) : undefined
101 entries.push({
102 eventId,
103 pubkey: pubkey || undefined,
104 relayHint: tag[3] || undefined
105 })
106 }
107 }
108 }
109
110 return new PinList(owner, entries, event.content)
111 }
112
113 /**
114 * Try to create a PinList from an event, returns null if invalid
115 */
116 static tryFromEvent(event: Event | null | undefined): PinList | null {
117 if (!event) return null
118 try {
119 return PinList.fromEvent(event)
120 } catch {
121 return null
122 }
123 }
124
125 /**
126 * The owner of this pin list
127 */
128 get owner(): Pubkey {
129 return this._owner
130 }
131
132 /**
133 * Number of pinned notes
134 */
135 get count(): number {
136 return this._entries.size
137 }
138
139 /**
140 * Whether the pin list is at maximum capacity
141 */
142 get isFull(): boolean {
143 return this._entries.size >= MAX_PINNED_NOTES
144 }
145
146 /**
147 * The raw content field
148 */
149 get content(): string {
150 return this._content
151 }
152
153 /**
154 * Get all pinned entries in order
155 */
156 getEntries(): PinEntry[] {
157 return this._order.map((id) => this._entries.get(id)!).filter(Boolean)
158 }
159
160 /**
161 * Get all pinned event IDs
162 */
163 getEventIds(): string[] {
164 return [...this._order]
165 }
166
167 /**
168 * Get pinned event IDs as a Set for fast lookup
169 */
170 getEventIdSet(): Set<string> {
171 return new Set(this._order)
172 }
173
174 /**
175 * Check if a note is pinned
176 */
177 isPinned(eventId: string): boolean {
178 return this._entries.has(eventId)
179 }
180
181 /**
182 * Pin a note
183 *
184 * @throws CannotPinOthersContentError if note is from another user
185 * @throws CanOnlyPinNotesError if event is not a short text note
186 * @returns PinListChange indicating what changed
187 */
188 pin(event: Event): PinListChange {
189 // Validate: only own notes
190 if (event.pubkey !== this._owner.hex) {
191 throw new CannotPinOthersContentError()
192 }
193
194 // Validate: only short text notes
195 if (event.kind !== kinds.ShortTextNote) {
196 throw new CanOnlyPinNotesError()
197 }
198
199 const eventId = EventId.fromHex(event.id)
200
201 // Check for duplicate
202 if (this._entries.has(eventId.hex)) {
203 return { type: 'no_change' }
204 }
205
206 const entry: PinEntry = {
207 eventId,
208 pubkey: this._owner,
209 relayHint: undefined
210 }
211
212 // Check capacity and remove oldest if needed
213 const removed: PinEntry[] = []
214 while (this._entries.size >= MAX_PINNED_NOTES) {
215 const oldestId = this._order.shift()
216 if (oldestId) {
217 const oldEntry = this._entries.get(oldestId)
218 if (oldEntry) {
219 removed.push(oldEntry)
220 }
221 this._entries.delete(oldestId)
222 }
223 }
224
225 // Add new pin
226 this._entries.set(eventId.hex, entry)
227 this._order.push(eventId.hex)
228
229 if (removed.length > 0) {
230 return { type: 'limit_exceeded', removed }
231 }
232
233 return { type: 'pinned', entry }
234 }
235
236 /**
237 * Unpin a note
238 *
239 * @returns PinListChange indicating what changed
240 */
241 unpin(eventId: string): PinListChange {
242 if (!this._entries.has(eventId)) {
243 return { type: 'no_change' }
244 }
245
246 this._entries.delete(eventId)
247 const index = this._order.indexOf(eventId)
248 if (index !== -1) {
249 this._order.splice(index, 1)
250 }
251
252 return { type: 'unpinned', eventId }
253 }
254
255 /**
256 * Unpin by event
257 */
258 unpinEvent(event: Event): PinListChange {
259 return this.unpin(event.id)
260 }
261
262 /**
263 * Convert to Nostr event tags format
264 */
265 toTags(): string[][] {
266 const tags: string[][] = []
267
268 for (const id of this._order) {
269 const entry = this._entries.get(id)
270 if (entry) {
271 const tag = ['e', entry.eventId.hex]
272 if (entry.pubkey) {
273 tag.push(entry.pubkey.hex)
274 if (entry.relayHint) {
275 tag.push(entry.relayHint)
276 }
277 } else if (entry.relayHint) {
278 tag.push('', entry.relayHint)
279 }
280 tags.push(tag)
281 }
282 }
283
284 return tags
285 }
286
287 /**
288 * Convert to a draft event for publishing
289 */
290 toDraftEvent(): { kind: number; content: string; created_at: number; tags: string[][] } {
291 return {
292 kind: kinds.Pinlist,
293 content: this._content,
294 created_at: Timestamp.now().unix,
295 tags: this.toTags()
296 }
297 }
298 }
299