Files
marco/app/services/nostr-data.js

251 lines
6.5 KiB
JavaScript

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';
import { normalizeRelayUrl } from '../utils/nostr';
const DIRECTORY_RELAYS = [
'wss://purplepag.es',
'wss://relay.damus.io',
'wss://nos.lol',
];
const DEFAULT_READ_RELAYS = ['wss://nostr.kosmos.org'];
const DEFAULT_WRITE_RELAYS = [];
export default class NostrDataService extends Service {
@service nostrRelay;
@service nostrAuth;
@service settings;
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<NostrEvent>
});
}
get defaultReadRelays() {
const mailboxes = (this.mailboxes?.inboxes || [])
.map(normalizeRelayUrl)
.filter(Boolean);
const defaults = DEFAULT_READ_RELAYS.map(normalizeRelayUrl).filter(Boolean);
return Array.from(new Set([...defaults, ...mailboxes]));
}
get defaultWriteRelays() {
const mailboxes = (this.mailboxes?.outboxes || [])
.map(normalizeRelayUrl)
.filter(Boolean);
const defaults =
DEFAULT_WRITE_RELAYS.map(normalizeRelayUrl).filter(Boolean);
return Array.from(new Set([...defaults, ...mailboxes]));
}
get activeReadRelays() {
if (this.settings.nostrReadRelays) {
return Array.from(
new Set(
this.settings.nostrReadRelays.map(normalizeRelayUrl).filter(Boolean)
)
);
}
return this.defaultReadRelays;
}
get activeWriteRelays() {
if (this.settings.nostrWriteRelays) {
return Array.from(
new Set(
this.settings.nostrWriteRelays.map(normalizeRelayUrl).filter(Boolean)
)
);
}
return this.defaultWriteRelays;
}
async loadProfile(pubkey) {
if (!pubkey) return;
// Reset state
this.profile = null;
this.mailboxes = null;
this.blossomServers = [];
this._cleanupSubscriptions();
// 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
const profileRelays = Array.from(
new Set([...DIRECTORY_RELAYS, ...this.activeWriteRelays])
);
this._requestSub = this.nostrRelay.pool
.request(profileRelays, [
{
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();
}
}
}