FollowList.ts raw
1 import { Event, kinds } from 'nostr-tools'
2 import { Pubkey, Timestamp } from '../shared'
3 import { CannotFollowSelfError } from './errors'
4
5 /**
6 * Represents a petname entry with relay hint
7 */
8 export type FollowEntry = {
9 pubkey: Pubkey
10 relayHint?: string
11 petname?: string
12 }
13
14 /**
15 * Result of a follow/unfollow operation
16 */
17 export type FollowListChange =
18 | { type: 'added'; pubkey: Pubkey }
19 | { type: 'removed'; pubkey: Pubkey }
20 | { type: 'no_change' }
21
22 /**
23 * FollowList Aggregate
24 *
25 * Represents a user's contact list (kind 3 event in Nostr).
26 * Encapsulates all business rules for following/unfollowing users.
27 *
28 * Invariants:
29 * - Cannot follow self
30 * - No duplicate entries
31 * - Pubkeys must be valid
32 */
33 export class FollowList {
34 private readonly _entries: Map<string, FollowEntry>
35 private readonly _content: string
36
37 private constructor(
38 private readonly _owner: Pubkey,
39 entries: FollowEntry[],
40 content: string = ''
41 ) {
42 this._entries = new Map()
43 for (const entry of entries) {
44 this._entries.set(entry.pubkey.hex, entry)
45 }
46 this._content = content
47 }
48
49 /**
50 * Create an empty FollowList for a user
51 */
52 static empty(owner: Pubkey): FollowList {
53 return new FollowList(owner, [])
54 }
55
56 /**
57 * Reconstruct a FollowList from a Nostr kind 3 event
58 */
59 static fromEvent(event: Event): FollowList {
60 if (event.kind !== kinds.Contacts) {
61 throw new Error(`Expected kind ${kinds.Contacts}, got ${event.kind}`)
62 }
63
64 const owner = Pubkey.fromHex(event.pubkey)
65 const entries: FollowEntry[] = []
66
67 for (const tag of event.tags) {
68 if (tag[0] === 'p' && tag[1]) {
69 const pubkey = Pubkey.tryFromString(tag[1])
70 if (pubkey) {
71 entries.push({
72 pubkey,
73 relayHint: tag[2] || undefined,
74 petname: tag[3] || undefined
75 })
76 }
77 }
78 }
79
80 return new FollowList(owner, entries, event.content)
81 }
82
83 /**
84 * The owner of this follow list
85 */
86 get owner(): Pubkey {
87 return this._owner
88 }
89
90 /**
91 * Number of users being followed
92 */
93 get count(): number {
94 return this._entries.size
95 }
96
97 /**
98 * The raw content field (may contain relay preferences in legacy format)
99 */
100 get content(): string {
101 return this._content
102 }
103
104 /**
105 * Get all followed pubkeys
106 */
107 getFollowing(): Pubkey[] {
108 return Array.from(this._entries.values()).map((e) => e.pubkey)
109 }
110
111 /**
112 * Get all follow entries with metadata
113 */
114 getEntries(): FollowEntry[] {
115 return Array.from(this._entries.values())
116 }
117
118 /**
119 * Check if a user is being followed
120 */
121 isFollowing(pubkey: Pubkey): boolean {
122 return this._entries.has(pubkey.hex)
123 }
124
125 /**
126 * Get the entry for a followed user
127 */
128 getEntry(pubkey: Pubkey): FollowEntry | undefined {
129 return this._entries.get(pubkey.hex)
130 }
131
132 /**
133 * Follow a user
134 *
135 * @throws CannotFollowSelfError if attempting to follow self
136 * @returns FollowListChange indicating what changed
137 */
138 follow(pubkey: Pubkey, relayHint?: string, petname?: string): FollowListChange {
139 if (pubkey.equals(this._owner)) {
140 throw new CannotFollowSelfError()
141 }
142
143 if (this._entries.has(pubkey.hex)) {
144 return { type: 'no_change' }
145 }
146
147 this._entries.set(pubkey.hex, { pubkey, relayHint, petname })
148 return { type: 'added', pubkey }
149 }
150
151 /**
152 * Unfollow a user
153 *
154 * @returns FollowListChange indicating what changed
155 */
156 unfollow(pubkey: Pubkey): FollowListChange {
157 if (!this._entries.has(pubkey.hex)) {
158 return { type: 'no_change' }
159 }
160
161 this._entries.delete(pubkey.hex)
162 return { type: 'removed', pubkey }
163 }
164
165 /**
166 * Update petname for a followed user
167 *
168 * @returns true if updated, false if user not found
169 */
170 setPetname(pubkey: Pubkey, petname: string | undefined): boolean {
171 const entry = this._entries.get(pubkey.hex)
172 if (!entry) {
173 return false
174 }
175
176 this._entries.set(pubkey.hex, { ...entry, petname })
177 return true
178 }
179
180 /**
181 * Convert to Nostr event tags format
182 */
183 toTags(): string[][] {
184 return Array.from(this._entries.values()).map((entry) => {
185 const tag = ['p', entry.pubkey.hex]
186 if (entry.relayHint) {
187 tag.push(entry.relayHint)
188 if (entry.petname) {
189 tag.push(entry.petname)
190 }
191 } else if (entry.petname) {
192 tag.push('', entry.petname)
193 }
194 return tag
195 })
196 }
197
198 /**
199 * Convert to a draft event for publishing
200 */
201 toDraftEvent(): { kind: number; content: string; created_at: number; tags: string[][] } {
202 return {
203 kind: kinds.Contacts,
204 content: this._content,
205 created_at: Timestamp.now().unix,
206 tags: this.toTags()
207 }
208 }
209
210 /**
211 * Create a new FollowList with the same entries but different owner
212 * Useful for importing someone else's follow list
213 */
214 cloneFor(newOwner: Pubkey): FollowList {
215 const entries = this.getEntries().filter((e) => !e.pubkey.equals(newOwner))
216 return new FollowList(newOwner, entries, this._content)
217 }
218
219 /**
220 * Merge another follow list into this one (union of both)
221 */
222 merge(other: FollowList): void {
223 for (const entry of other.getEntries()) {
224 if (!entry.pubkey.equals(this._owner) && !this._entries.has(entry.pubkey.hex)) {
225 this._entries.set(entry.pubkey.hex, entry)
226 }
227 }
228 }
229 }
230