import Service, { service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; import { EventStore } from 'applesauce-core/event-store'; import { ProfileModel } from 'applesauce-core/models/profile'; import { MailboxesModel } from 'applesauce-core/models/mailboxes'; import { npubEncode } from 'applesauce-core/helpers/pointers'; import { persistEventsToCache } from 'applesauce-core/helpers/event-cache'; import { NostrIDB, openDB } from 'nostr-idb'; const BOOTSTRAP_RELAYS = [ 'wss://purplepag.es', 'wss://relay.damus.io', 'wss://nos.lol', ]; export default class NostrDataService extends Service { @service nostrRelay; @service nostrAuth; store = new EventStore(); @tracked profile = null; @tracked mailboxes = null; @tracked blossomServers = []; _profileSub = null; _mailboxesSub = null; _blossomSub = null; _requestSub = null; _cachePromise = null; constructor() { super(...arguments); // Initialize the IndexedDB cache this._cachePromise = openDB('applesauce-events').then(async (db) => { this.cache = new NostrIDB(db, { cacheIndexes: 1000, maxEvents: 10000, }); await this.cache.start(); // Automatically persist new events to the cache this._stopPersisting = persistEventsToCache( this.store, async (events) => { // Only cache profiles, mailboxes, and blossom servers const toCache = events.filter( (e) => e.kind === 0 || e.kind === 10002 || e.kind === 10063 ); if (toCache.length > 0) { await Promise.all(toCache.map((event) => this.cache.add(event))); } }, { batchTime: 1000, // Batch writes every 1 second maxBatchSize: 100, } ); }); // Feed events from the relay pool into the event store this.nostrRelay.pool.relays$.subscribe(() => { // Setup relay subscription tracking if needed, or we just rely on request() // which returns an Observable }); } async loadProfile(pubkey) { if (!pubkey) return; // Reset state this.profile = null; this.mailboxes = null; this.blossomServers = []; this._cleanupSubscriptions(); const relays = new Set(BOOTSTRAP_RELAYS); // Try to get extension relays if (typeof window.nostr !== 'undefined' && window.nostr.getRelays) { try { const extRelays = await window.nostr.getRelays(); for (const url of Object.keys(extRelays)) { relays.add(url); } } catch { console.warn('Failed to get NIP-07 relays'); } } const relayList = Array.from(relays); // Setup models to track state reactively FIRST // This way, if cached events populate the store, the UI updates instantly. this._profileSub = this.store .model(ProfileModel, pubkey) .subscribe((profileContent) => { this.profile = profileContent; }); this._mailboxesSub = this.store .model(MailboxesModel, pubkey) .subscribe((mailboxesData) => { this.mailboxes = mailboxesData; }); this._blossomSub = this.store .replaceable(10063, pubkey) .subscribe((event) => { if (event && event.tags) { this.blossomServers = event.tags .filter((t) => t[0] === 'server' && t[1]) .map((t) => t[1]); } else { this.blossomServers = []; } }); // 1. Await cache initialization and populate the EventStore with local data try { await this._cachePromise; const cachedEvents = await this.cache.query([ { authors: [pubkey], kinds: [0, 10002, 10063], }, ]); if (cachedEvents && cachedEvents.length > 0) { for (const event of cachedEvents) { this.store.add(event); } } } catch (e) { console.warn('Failed to read from local Nostr IDB cache', e); } // 2. Request new events from the network in the background and dump them into the store this._requestSub = this.nostrRelay.pool .request(relayList, [ { authors: [pubkey], kinds: [0, 10002, 10063], }, ]) .subscribe({ next: (event) => { this.store.add(event); }, error: (err) => { console.error('Error fetching profile events:', err); }, }); } get userDisplayName() { if (this.profile) { if (this.profile.nip05) { return this.profile.nip05; } if (this.profile.displayName || this.profile.display_name) { return this.profile.displayName || this.profile.display_name; } if (this.profile.name) { return this.profile.name; } } // Fallback to npub if (this.nostrAuth.pubkey) { try { const npub = npubEncode(this.nostrAuth.pubkey); return `${npub.slice(0, 9)}...${npub.slice(-4)}`; } catch { return this.nostrAuth.pubkey; } } return 'Not connected'; } _cleanupSubscriptions() { if (this._requestSub) { this._requestSub.unsubscribe(); this._requestSub = null; } if (this._profileSub) { this._profileSub.unsubscribe(); this._profileSub = null; } if (this._mailboxesSub) { this._mailboxesSub.unsubscribe(); this._mailboxesSub = null; } if (this._blossomSub) { this._blossomSub.unsubscribe(); this._blossomSub = null; } } willDestroy() { super.willDestroy(...arguments); this._cleanupSubscriptions(); if (this._stopPersisting) { this._stopPersisting(); } if (this.cache) { this.cache.stop(); } } }