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