profile-metadata.service.ts raw
1 import { inject, Injectable } from '@angular/core';
2 import { SimplePool } from 'nostr-tools/pool';
3 import { FALLBACK_PROFILE_RELAYS } from '../../constants/fallback-relays';
4 import { ProfileMetadata, ProfileMetadataCache } from '../storage/types';
5 import { LoggerService } from '../logger/logger.service';
6
7 // eslint-disable-next-line @typescript-eslint/no-explicit-any
8 declare const chrome: any;
9
10 const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
11 const FETCH_TIMEOUT_MS = 10000; // 10 seconds
12 const STORAGE_KEY = 'profileMetadataCache';
13
14 @Injectable({
15 providedIn: 'root',
16 })
17 export class ProfileMetadataService {
18 readonly #logger = inject(LoggerService);
19 #cache: ProfileMetadataCache = {};
20 #pool: SimplePool | null = null;
21 #fetchPromises = new Map<string, Promise<ProfileMetadata | null>>();
22 #initialized = false;
23 #initPromise: Promise<void> | null = null;
24
25 /**
26 * Initialize the service by loading cache from session storage
27 */
28 async initialize(): Promise<void> {
29 if (this.#initialized) {
30 return;
31 }
32
33 if (this.#initPromise) {
34 return this.#initPromise;
35 }
36
37 this.#initPromise = this.#loadCacheFromStorage();
38 await this.#initPromise;
39 this.#initialized = true;
40 }
41
42 /**
43 * Load cache from browser session storage
44 */
45 async #loadCacheFromStorage(): Promise<void> {
46 try {
47 // Use chrome API (works in both Chrome and Firefox with polyfill)
48 if (typeof chrome !== 'undefined' && chrome.storage?.session) {
49 const result = await chrome.storage.session.get(STORAGE_KEY);
50 if (result[STORAGE_KEY]) {
51 this.#cache = result[STORAGE_KEY];
52 // Clean up stale entries
53 this.#pruneStaleCache();
54 }
55 }
56 } catch (error) {
57 const errorMsg = error instanceof Error ? error.message : 'Unknown error';
58 this.#logger.logStorageError('load profile cache', errorMsg);
59 }
60 }
61
62 /**
63 * Save cache to browser session storage
64 */
65 async #saveCacheToStorage(): Promise<void> {
66 try {
67 if (typeof chrome !== 'undefined' && chrome.storage?.session) {
68 await chrome.storage.session.set({ [STORAGE_KEY]: this.#cache });
69 }
70 } catch (error) {
71 const errorMsg = error instanceof Error ? error.message : 'Unknown error';
72 this.#logger.logStorageError('save profile cache', errorMsg);
73 }
74 }
75
76 /**
77 * Remove stale entries from cache
78 */
79 #pruneStaleCache(): void {
80 const now = Date.now();
81 for (const pubkey of Object.keys(this.#cache)) {
82 if (now - this.#cache[pubkey].fetchedAt > CACHE_TTL_MS) {
83 delete this.#cache[pubkey];
84 }
85 }
86 }
87
88 /**
89 * Get the SimplePool instance, creating it if necessary
90 */
91 #getPool(): SimplePool {
92 if (!this.#pool) {
93 this.#pool = new SimplePool();
94 }
95 return this.#pool;
96 }
97
98 /**
99 * Get cached profile metadata for a pubkey
100 */
101 getCachedProfile(pubkey: string): ProfileMetadata | null {
102 const cached = this.#cache[pubkey];
103 if (!cached) {
104 return null;
105 }
106
107 // Check if cache is still valid
108 if (Date.now() - cached.fetchedAt > CACHE_TTL_MS) {
109 delete this.#cache[pubkey];
110 return null;
111 }
112
113 return cached;
114 }
115
116 /**
117 * Fetch profile metadata for a single pubkey
118 */
119 async fetchProfile(pubkey: string): Promise<ProfileMetadata | null> {
120 // Ensure initialized
121 await this.initialize();
122
123 // Check cache first
124 const cached = this.getCachedProfile(pubkey);
125 if (cached) {
126 return cached;
127 }
128
129 // Check if already fetching
130 const existingPromise = this.#fetchPromises.get(pubkey);
131 if (existingPromise) {
132 return existingPromise;
133 }
134
135 // Start new fetch
136 const fetchPromise = this.#doFetchProfile(pubkey);
137 this.#fetchPromises.set(pubkey, fetchPromise);
138
139 try {
140 const result = await fetchPromise;
141 return result;
142 } finally {
143 this.#fetchPromises.delete(pubkey);
144 }
145 }
146
147 /**
148 * Fetch profiles for multiple pubkeys in parallel
149 */
150 async fetchProfiles(pubkeys: string[]): Promise<Map<string, ProfileMetadata | null>> {
151 // Ensure initialized
152 await this.initialize();
153
154 const results = new Map<string, ProfileMetadata | null>();
155
156 // Filter out pubkeys we already have cached
157 const uncachedPubkeys: string[] = [];
158 for (const pubkey of pubkeys) {
159 const cached = this.getCachedProfile(pubkey);
160 if (cached) {
161 results.set(pubkey, cached);
162 } else {
163 uncachedPubkeys.push(pubkey);
164 }
165 }
166
167 if (uncachedPubkeys.length === 0) {
168 return results;
169 }
170
171 // Fetch all uncached profiles
172 const pool = this.#getPool();
173
174 try {
175 const events = await this.#queryWithTimeout(
176 pool,
177 FALLBACK_PROFILE_RELAYS,
178 [{ kinds: [0], authors: uncachedPubkeys }],
179 FETCH_TIMEOUT_MS
180 );
181
182 // Process events - keep only the most recent event per pubkey
183 const latestEvents = new Map<string, { created_at: number; content: string }>();
184
185 for (const event of events) {
186 const existing = latestEvents.get(event.pubkey);
187 if (!existing || event.created_at > existing.created_at) {
188 latestEvents.set(event.pubkey, {
189 created_at: event.created_at,
190 content: event.content,
191 });
192 }
193 }
194
195 // Parse and cache the profiles
196 for (const [pubkey, eventData] of latestEvents) {
197 try {
198 const content = JSON.parse(eventData.content);
199 const profile: ProfileMetadata = {
200 pubkey,
201 name: content.name,
202 display_name: content.display_name,
203 displayName: content.displayName,
204 picture: content.picture,
205 banner: content.banner,
206 about: content.about,
207 website: content.website,
208 nip05: content.nip05,
209 lud06: content.lud06,
210 lud16: content.lud16,
211 fetchedAt: Date.now(),
212 };
213 this.#cache[pubkey] = profile;
214 results.set(pubkey, profile);
215 } catch {
216 this.#logger.logProfileParseError(pubkey);
217 results.set(pubkey, null);
218 }
219 }
220
221 // Set null for pubkeys we didn't find
222 for (const pubkey of uncachedPubkeys) {
223 if (!results.has(pubkey)) {
224 results.set(pubkey, null);
225 }
226 }
227
228 // Save updated cache to storage
229 await this.#saveCacheToStorage();
230
231 } catch (error) {
232 const errorMsg = error instanceof Error ? error.message : 'Unknown error';
233 this.#logger.logProfileFetchError('multiple', errorMsg);
234 // Set null for all unfetched pubkeys on error
235 for (const pubkey of uncachedPubkeys) {
236 if (!results.has(pubkey)) {
237 results.set(pubkey, null);
238 }
239 }
240 }
241
242 return results;
243 }
244
245 /**
246 * Internal method to fetch a single profile
247 */
248 async #doFetchProfile(pubkey: string): Promise<ProfileMetadata | null> {
249 const pool = this.#getPool();
250
251 try {
252 const events = await this.#queryWithTimeout(
253 pool,
254 FALLBACK_PROFILE_RELAYS,
255 [{ kinds: [0], authors: [pubkey] }],
256 FETCH_TIMEOUT_MS
257 );
258
259 if (events.length === 0) {
260 return null;
261 }
262
263 // Get the most recent event
264 const latestEvent = events.reduce((latest, event) =>
265 event.created_at > latest.created_at ? event : latest
266 );
267
268 try {
269 const content = JSON.parse(latestEvent.content);
270 const profile: ProfileMetadata = {
271 pubkey,
272 name: content.name,
273 display_name: content.display_name,
274 displayName: content.displayName,
275 picture: content.picture,
276 banner: content.banner,
277 about: content.about,
278 website: content.website,
279 nip05: content.nip05,
280 lud06: content.lud06,
281 lud16: content.lud16,
282 fetchedAt: Date.now(),
283 };
284 this.#cache[pubkey] = profile;
285
286 // Save updated cache to storage
287 await this.#saveCacheToStorage();
288
289 return profile;
290 } catch {
291 this.#logger.logProfileParseError(pubkey);
292 return null;
293 }
294 } catch (error) {
295 const errorMsg = error instanceof Error ? error.message : 'Unknown error';
296 this.#logger.logProfileFetchError(pubkey, errorMsg);
297 return null;
298 }
299 }
300
301 /**
302 * Query relays with a timeout
303 */
304 // eslint-disable-next-line @typescript-eslint/no-explicit-any
305 async #queryWithTimeout(pool: SimplePool, relays: string[], filters: any[], timeoutMs: number): Promise<any[]> {
306 return new Promise((resolve) => {
307 // eslint-disable-next-line @typescript-eslint/no-explicit-any
308 const events: any[] = [];
309 let settled = false;
310
311 const timeout = setTimeout(() => {
312 if (!settled) {
313 settled = true;
314 resolve(events);
315 }
316 }, timeoutMs);
317
318 const sub = pool.subscribeMany(relays, filters, {
319 onevent(event) {
320 events.push(event);
321 },
322 oneose() {
323 if (!settled) {
324 settled = true;
325 clearTimeout(timeout);
326 sub.close();
327 resolve(events);
328 }
329 },
330 });
331 });
332 }
333
334 /**
335 * Clear the cache
336 */
337 async clearCache(): Promise<void> {
338 this.#cache = {};
339 await this.#saveCacheToStorage();
340 }
341
342 /**
343 * Clear cache for a specific pubkey
344 */
345 async clearCacheForPubkey(pubkey: string): Promise<void> {
346 delete this.#cache[pubkey];
347 await this.#saveCacheToStorage();
348 }
349
350 /**
351 * Get the display name for a profile (prioritizes display_name over name)
352 */
353 getDisplayName(profile: ProfileMetadata | null): string | undefined {
354 if (!profile) return undefined;
355 return profile.display_name || profile.displayName || profile.name;
356 }
357
358 /**
359 * Get the username for a profile (prioritizes name over display_name)
360 */
361 getUsername(profile: ProfileMetadata | null): string | undefined {
362 if (!profile) return undefined;
363 return profile.name || profile.display_name || profile.displayName;
364 }
365 }
366