920 lines
27 KiB
JavaScript
920 lines
27 KiB
JavaScript
import Service, { service } from '@ember/service';
|
|
import { action } from '@ember/object';
|
|
import { tracked } from '@glimmer/tracking';
|
|
import { EMPTY, from, timeout, filter, connect, take, takeUntil } from 'rxjs';
|
|
import { EventStore } from 'applesauce-core/event-store';
|
|
import { ProfileModel } from 'applesauce-core/models/profile';
|
|
import { MailboxesModel } from 'applesauce-core/models/mailboxes';
|
|
import { ContactsModel } from 'applesauce-core/models/contacts';
|
|
import { isEventPointer, npubEncode } from 'applesauce-core/helpers/pointers';
|
|
import { persistEventsToCache } from 'applesauce-core/helpers/event-cache';
|
|
import { createEventLoaderForStore } from 'applesauce-loaders/loaders';
|
|
import { RelayGroup } from 'applesauce-relay';
|
|
import { NostrIDB, openDB } from 'nostr-idb';
|
|
import {
|
|
excludeRequiredRelays,
|
|
mergeRequiredRelays,
|
|
normalizeRelayUrl,
|
|
relayUrlMatches,
|
|
uniqNormalizedRelays,
|
|
} from '../utils/nostr';
|
|
import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage';
|
|
import { DEFAULT_BLOSSOM_SERVER } from './blossom';
|
|
|
|
const DIRECTORY_RELAYS = [
|
|
'wss://relay.primal.net',
|
|
'wss://nos.lol',
|
|
'wss://relay.damus.io',
|
|
];
|
|
|
|
const DEFAULT_READ_RELAYS = ['wss://nostr.kosmos.org'];
|
|
const DEFAULT_WRITE_RELAYS = []; // TODO nostr.kosmos.org/marco
|
|
|
|
// Content event kinds subject to relay-trust filtering. Adding `30360`
|
|
// (place reviews) here is the only change needed to extend the feature.
|
|
const TRUSTED_CONTENT_KINDS = [360];
|
|
|
|
// localforage store name for persisted relay provenance.
|
|
const PROVENANCE_STORE = 'event-relay-provenance';
|
|
|
|
// Replicates applesauce-relay's `completeWhen`: completes the source when the
|
|
// `operator` emits a truthy value. `completeWhen` is used internally by
|
|
// RelayGroup but not re-exported, so we recreate it from rxjs primitives.
|
|
function completeWhen(operator) {
|
|
return connect((shared$) => {
|
|
const complete$ = shared$.pipe(operator, filter(Boolean), take(1));
|
|
return shared$.pipe(takeUntil(complete$));
|
|
});
|
|
}
|
|
|
|
export default class NostrDataService extends Service {
|
|
@service nostrRelay;
|
|
@service nostrAuth;
|
|
@service settings;
|
|
@service localForage;
|
|
|
|
store = new EventStore();
|
|
|
|
@tracked profile = null;
|
|
@tracked mailboxes = null;
|
|
@tracked contacts = null;
|
|
@tracked blossomServers = [];
|
|
@tracked placePhotos = [];
|
|
@tracked myContributionEvents = [];
|
|
@tracked profiles = {};
|
|
@tracked zapReceipts = {};
|
|
|
|
// Relay-provenance trust state. `_eventRelays` maps eventId -> Set<relayUrl>
|
|
// and accumulates every relay an event was seen on (unconditionally, so an
|
|
// event first seen on an untrusted relay can be upgraded to trusted when it
|
|
// later arrives from a trusted one). Hydrated from IDB at init so cached
|
|
// events render with correct trust state instantly.
|
|
_eventRelays = new Map();
|
|
_provenanceReady = null;
|
|
|
|
// Set of pubkeys from the user's follow list (kind 3 contacts) for O(1)
|
|
// trust lookups. Rebuilt whenever contacts change.
|
|
_contactPubkeys = null;
|
|
|
|
// Session-only reveal toggle for untrusted content. Not persisted.
|
|
@tracked showUntrustedContent = false;
|
|
// Count of currently-hidden (untrusted) place photos for the selected place.
|
|
@tracked untrustedContentCount = 0;
|
|
_allPlacePhotos = [];
|
|
|
|
_profileSub = null;
|
|
_mailboxesSub = null;
|
|
_contactsSub = null;
|
|
_blossomSub = null;
|
|
_photosSub = null;
|
|
_contributionsSub = null;
|
|
_deletionsSub = null;
|
|
_profileModelSubs = new Map();
|
|
|
|
_zapReceiptsSub = null;
|
|
_zapReceiptsNetworkSub = null;
|
|
_zapRefreshTimer = null;
|
|
_lastPhotoIds = new Set();
|
|
_incomingZapsNetworkSub = null;
|
|
|
|
_requestSub = null;
|
|
_cachePromise = null;
|
|
_currentPlaceEntityId = null;
|
|
loadedGeohashPrefixes = new Set();
|
|
|
|
constructor() {
|
|
super(...arguments);
|
|
|
|
// Set up the event loader synchronously so that any subscription
|
|
// (e.g. loadProfiles from a route's afterModel) can auto-fetch even
|
|
// before the IndexedDB cache has finished opening. The cacheRequest
|
|
// is lazy — it returns EMPTY until `this.cache` is available, so the
|
|
// loader falls through to relay hints → lookup relays in the meantime.
|
|
createEventLoaderForStore(this.store, this.nostrRelay.pool, {
|
|
cacheRequest: (filters) => {
|
|
if (!this.cache) return EMPTY;
|
|
return from(this.cache.query(filters));
|
|
},
|
|
lookupRelays: DIRECTORY_RELAYS,
|
|
});
|
|
|
|
// 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, contacts, blossom servers, place photos, and deletions
|
|
const toCache = events.filter(
|
|
(e) =>
|
|
e.kind === 0 ||
|
|
e.kind === 3 ||
|
|
e.kind === 5 ||
|
|
e.kind === 10002 ||
|
|
e.kind === 10063 ||
|
|
e.kind === 360 ||
|
|
e.kind === 9735
|
|
);
|
|
|
|
if (toCache.length > 0) {
|
|
await Promise.all(toCache.map((event) => this.cache.add(event)));
|
|
}
|
|
},
|
|
{
|
|
batchTime: 1000, // Batch writes every 1 second
|
|
maxBatchSize: 100,
|
|
}
|
|
);
|
|
});
|
|
|
|
// Hydrate relay provenance from IDB so cached events can be trust-checked
|
|
// instantly without waiting for relay connections.
|
|
this._provenanceReady = this._hydrateProvenance();
|
|
|
|
// Centralized kind-5 deletion handling: the store routes kind-5 events to
|
|
// its DeleteManager (never into the event database), so listen to the
|
|
// deletion stream rather than a timeline. Drop provenance entries for
|
|
// deleted events so the trust map and IDB don't accumulate dead entries.
|
|
this._deletionsSub = this.store.deletes.deleted$.subscribe(
|
|
({ pointer }) => {
|
|
if (isEventPointer(pointer)) {
|
|
this._removeProvenance(pointer.id);
|
|
}
|
|
}
|
|
);
|
|
|
|
// 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 requiredReadRelays() {
|
|
return DEFAULT_READ_RELAYS;
|
|
}
|
|
|
|
get requiredWriteRelays() {
|
|
return DEFAULT_WRITE_RELAYS;
|
|
}
|
|
|
|
get mailboxReadRelays() {
|
|
return (this.mailboxes?.inboxes || [])
|
|
.map(normalizeRelayUrl)
|
|
.filter(Boolean);
|
|
}
|
|
|
|
get mailboxWriteRelays() {
|
|
return (this.mailboxes?.outboxes || [])
|
|
.map(normalizeRelayUrl)
|
|
.filter(Boolean);
|
|
}
|
|
|
|
get configuredReadRelays() {
|
|
const configured = uniqNormalizedRelays([
|
|
...this.mailboxReadRelays,
|
|
...(this.settings.nostrReadRelays || []),
|
|
]);
|
|
|
|
return excludeRequiredRelays(
|
|
configured,
|
|
this.settings.nostrReadRelayExclusions || []
|
|
);
|
|
}
|
|
|
|
get configuredWriteRelays() {
|
|
const configured = uniqNormalizedRelays([
|
|
...this.mailboxWriteRelays,
|
|
...(this.settings.nostrWriteRelays || []),
|
|
]);
|
|
|
|
return excludeRequiredRelays(
|
|
configured,
|
|
this.settings.nostrWriteRelayExclusions || []
|
|
);
|
|
}
|
|
|
|
get activeReadRelays() {
|
|
return mergeRequiredRelays(
|
|
this.requiredReadRelays,
|
|
this.configuredReadRelays
|
|
);
|
|
}
|
|
|
|
get activeWriteRelays() {
|
|
return mergeRequiredRelays(
|
|
this.requiredWriteRelays,
|
|
this.configuredWriteRelays
|
|
);
|
|
}
|
|
|
|
// Relays treated as "moderated" for content-trust filtering. Always includes
|
|
// the required read relays (e.g. nostr.kosmos.org); `nostrTrustedRelays`
|
|
// adds user-marked custom relays on top. Default (null) = required relays
|
|
// only, so the app only surfaces content verified by those relays.
|
|
get trustedRelays() {
|
|
const configured = this.settings.nostrTrustedRelays || [];
|
|
return uniqNormalizedRelays([...this.requiredReadRelays, ...configured]);
|
|
}
|
|
|
|
/**
|
|
* Returns true if an event should be considered trusted:
|
|
* - authored by the connected user (own uploads), OR
|
|
* - authored by a pubkey the user follows (kind 3 contacts), OR
|
|
* - seen on at least one trusted (moderated) relay.
|
|
*/
|
|
isTrustedEvent(event) {
|
|
if (!event) return false;
|
|
const myPubkey = this.nostrAuth?.pubkey;
|
|
if (myPubkey && event.pubkey === myPubkey) return true;
|
|
|
|
if (this._contactPubkeys?.has(event.pubkey)) return true;
|
|
|
|
const relays = this._eventRelays.get(event.id);
|
|
if (!relays || relays.size === 0) return false;
|
|
const trusted = this.trustedRelays;
|
|
for (const url of relays) {
|
|
if (relayUrlMatches(url, trusted)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Splits content events into trusted / untrusted buckets. Non-content kinds
|
|
* (e.g. kind 5 deletions) are always treated as trusted so they pass through.
|
|
*/
|
|
partitionByTrust(events) {
|
|
const trusted = [];
|
|
const untrusted = [];
|
|
for (const event of events) {
|
|
if (
|
|
!TRUSTED_CONTENT_KINDS.includes(event.kind) ||
|
|
this.isTrustedEvent(event)
|
|
) {
|
|
trusted.push(event);
|
|
} else {
|
|
untrusted.push(event);
|
|
}
|
|
}
|
|
return { trusted, untrusted };
|
|
}
|
|
|
|
// Hydrate the in-memory provenance map from the IDB store.
|
|
async _hydrateProvenance() {
|
|
try {
|
|
await this.localForage.iterate(PROVENANCE_STORE, (value, key) => {
|
|
this._eventRelays.set(key, new Set(value || []));
|
|
});
|
|
} catch (e) {
|
|
console.debug('[nostr-data] Failed to hydrate relay provenance', e);
|
|
}
|
|
}
|
|
|
|
// Record that `eventId` was seen on `relayUrl`. Merges into the existing
|
|
// set (accumulates across sightings) and persists fire-and-forget.
|
|
_recordProvenance(eventId, relayUrl) {
|
|
if (!eventId || !relayUrl) return;
|
|
let set = this._eventRelays.get(eventId);
|
|
if (!set) {
|
|
set = new Set();
|
|
this._eventRelays.set(eventId, set);
|
|
}
|
|
if (set.has(relayUrl)) return;
|
|
set.add(relayUrl);
|
|
const urls = Array.from(set);
|
|
// Fire-and-forget persistence; never blocks the render path.
|
|
this.localForage.set(PROVENANCE_STORE, eventId, urls).catch((e) => {
|
|
console.debug('[nostr-data] Failed to persist relay provenance', e);
|
|
});
|
|
}
|
|
|
|
// Remove provenance for a deleted event (called on kind-5 processing).
|
|
_removeProvenance(eventId) {
|
|
if (!eventId) return;
|
|
if (!this._eventRelays.delete(eventId)) return;
|
|
this.localForage.remove(PROVENANCE_STORE, eventId).catch((e) => {
|
|
console.debug('[nostr-data] Failed to remove relay provenance', e);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Request content events from relays while capturing full provenance.
|
|
*
|
|
* This mirrors `RelayGroup.request()` completion semantics (wait for the
|
|
* first relay EOSE + 5s grace period, or all relays EOSE; 30s hard
|
|
* timeout) but uses `pool.req()` so each `EVENT` message retains its
|
|
* `from` relay URL. Every sighting is recorded in the provenance map
|
|
* (unconditionally, including duplicates) so an event first seen on an
|
|
* untrusted relay can be upgraded to trusted when it later arrives from
|
|
* a trusted one. Events are always added to the store regardless of trust
|
|
* status; filtering happens at presentation time.
|
|
*/
|
|
_requestContentWithProvenance(relays, filters, errorLabel) {
|
|
const complete = RelayGroup.completeOnAny(
|
|
RelayGroup.completeAfterFirstRelay(5_000),
|
|
RelayGroup.completeOnAllEose()
|
|
);
|
|
return this.nostrRelay.pool
|
|
.req(relays, filters)
|
|
.pipe(
|
|
completeWhen(complete),
|
|
timeout({ first: 30_000 }),
|
|
// Only EVENT messages carry content; ignore OPEN/EOSE/CLOSED/ERROR.
|
|
// We deliberately do NOT dedupe so we see every relay's copy and can
|
|
// accumulate full provenance across relays.
|
|
filter((message) => message.type === 'EVENT')
|
|
)
|
|
.subscribe({
|
|
next: (message) => {
|
|
this._recordProvenance(message.event.id, message.from);
|
|
this.store.add(message.event);
|
|
},
|
|
error: (err) => {
|
|
console.error(errorLabel, err);
|
|
},
|
|
});
|
|
}
|
|
|
|
async loadPlacesInBounds(bbox) {
|
|
const requiredPrefixes = getGeohashPrefixesInBbox(bbox);
|
|
|
|
const missingPrefixes = requiredPrefixes.filter(
|
|
(p) => !this.loadedGeohashPrefixes.has(p)
|
|
);
|
|
|
|
if (missingPrefixes.length === 0) {
|
|
return;
|
|
}
|
|
|
|
console.debug(
|
|
'[nostr-data] Loading place photos for prefixes:',
|
|
missingPrefixes
|
|
);
|
|
|
|
try {
|
|
await this._cachePromise;
|
|
|
|
const cachedEvents = await this.cache.query([
|
|
{
|
|
kinds: [360],
|
|
'#g': missingPrefixes,
|
|
},
|
|
]);
|
|
|
|
if (cachedEvents && cachedEvents.length > 0) {
|
|
for (const event of cachedEvents) {
|
|
this.store.add(event);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn(
|
|
'[nostr-data] Failed to read photos from local Nostr IDB cache',
|
|
e
|
|
);
|
|
}
|
|
|
|
// Fire network request for new prefixes (captures relay provenance for
|
|
// trust filtering; events are added to the store regardless of trust).
|
|
this._requestContentWithProvenance(
|
|
this.activeReadRelays,
|
|
[{ kinds: [360], '#g': missingPrefixes }],
|
|
'[nostr-data] Error fetching place photos by geohash:'
|
|
);
|
|
|
|
for (const p of missingPrefixes) {
|
|
this.loadedGeohashPrefixes.add(p);
|
|
}
|
|
}
|
|
|
|
async loadPhotosForPlace(place) {
|
|
const entityId =
|
|
place && place.osmId && place.osmType
|
|
? `osm:${place.osmType}:${place.osmId}`
|
|
: null;
|
|
|
|
// Skip the full reset if we're loading the same place again (e.g. from
|
|
// checkUpdates calling selectPlace a second time). This prevents tearing
|
|
// down timeline and profile subscriptions that are still in-flight.
|
|
if (entityId && entityId === this._currentPlaceEntityId) {
|
|
return;
|
|
}
|
|
|
|
if (this._photosSub) {
|
|
this._photosSub.unsubscribe();
|
|
this._photosSub = null;
|
|
}
|
|
|
|
this._cleanupZapReceiptSubs();
|
|
this._clearZapRefreshTimer();
|
|
|
|
this.placePhotos = [];
|
|
this.zapReceipts = {};
|
|
this._lastPhotoIds = new Set();
|
|
this._clearProfileSubs();
|
|
this._currentPlaceEntityId = entityId;
|
|
|
|
if (!entityId) {
|
|
return;
|
|
}
|
|
|
|
// Setup reactive store query
|
|
this._photosSub = this.store
|
|
.timeline([
|
|
{
|
|
kinds: [360],
|
|
'#i': [entityId],
|
|
},
|
|
])
|
|
.subscribe((events) => {
|
|
this._allPlacePhotos = events;
|
|
this._updatePlacePhotos();
|
|
const pubkeys = [...new Set(events.map((e) => e.pubkey))];
|
|
this.loadProfiles(pubkeys);
|
|
this._scheduleZapReceiptRefresh(events);
|
|
});
|
|
|
|
try {
|
|
await this._cachePromise;
|
|
|
|
const cachedEvents = await this.cache.query([
|
|
{
|
|
kinds: [360, 5],
|
|
'#i': [entityId],
|
|
},
|
|
]);
|
|
|
|
if (cachedEvents && cachedEvents.length > 0) {
|
|
for (const event of cachedEvents) {
|
|
this.store.add(event);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn(
|
|
'[nostr-data] Failed to read photos for place from local Nostr IDB cache',
|
|
e
|
|
);
|
|
}
|
|
|
|
// Fire network request specifically for this place (captures provenance).
|
|
this._requestContentWithProvenance(
|
|
this.activeReadRelays,
|
|
[{ kinds: [360, 5], '#i': [entityId] }],
|
|
'[nostr-data] Error fetching place photos for place:'
|
|
);
|
|
}
|
|
|
|
// Recompute `placePhotos` from `_allPlacePhotos` applying the trust filter
|
|
// and the session-only reveal toggle. Called whenever the timeline emits or
|
|
// the user flips `showUntrustedContent`.
|
|
_updatePlacePhotos() {
|
|
const events = this._allPlacePhotos;
|
|
const { trusted, untrusted } = this.partitionByTrust(events);
|
|
this.untrustedContentCount = untrusted.length;
|
|
this.placePhotos = this.showUntrustedContent ? events : trusted;
|
|
}
|
|
|
|
@action
|
|
toggleShowUntrustedContent() {
|
|
this.showUntrustedContent = !this.showUntrustedContent;
|
|
this._updatePlacePhotos();
|
|
}
|
|
|
|
async loadMyContributions(pubkey) {
|
|
if (!pubkey) return;
|
|
|
|
// Reset state and unsubscribe from any previous subscription
|
|
this.myContributionEvents = [];
|
|
if (this._contributionsSub) {
|
|
this._contributionsSub.unsubscribe();
|
|
this._contributionsSub = null;
|
|
}
|
|
|
|
const filters = [{ kinds: [360, 5], authors: [pubkey] }];
|
|
|
|
// 1. Set up reactive store subscription so the timeline updates as events arrive
|
|
this._contributionsSub = this.store
|
|
.timeline(filters)
|
|
.subscribe((events) => {
|
|
this.myContributionEvents = events;
|
|
});
|
|
|
|
// 2. Populate the store from the local Nostr IDB cache (instant)
|
|
try {
|
|
await this._cachePromise;
|
|
|
|
const cachedEvents = await this.cache.query(filters);
|
|
|
|
if (cachedEvents && cachedEvents.length > 0) {
|
|
for (const event of cachedEvents) {
|
|
this.store.add(event);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn(
|
|
'[nostr-data] Failed to read my contributions from local Nostr IDB cache',
|
|
e
|
|
);
|
|
}
|
|
|
|
// 3. Request fresh events from the network in the background (captures
|
|
// provenance; own-author events bypass trust filtering anyway).
|
|
this._requestContentWithProvenance(
|
|
this.activeReadRelays,
|
|
filters,
|
|
'[nostr-data] Error fetching my contribution events:'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Hydrates the store with incoming zap receipts (kind 9735) where the user is
|
|
* the recipient (`#p` filter). Loads from the IDB cache first (instant), then
|
|
* fires a network request. Called by the `activity` service.
|
|
*
|
|
* @param {string} pubkey The user's Nostr pubkey
|
|
*/
|
|
async loadIncomingZaps(pubkey) {
|
|
if (!pubkey) return;
|
|
|
|
const filters = [{ kinds: [9735], '#p': [pubkey] }];
|
|
|
|
console.debug('[nostr-data] Requesting incoming zap receipts from relays', {
|
|
filters,
|
|
relays: this.activeReadRelays,
|
|
});
|
|
|
|
if (this._incomingZapsNetworkSub) {
|
|
this._incomingZapsNetworkSub.unsubscribe();
|
|
this._incomingZapsNetworkSub = null;
|
|
}
|
|
|
|
// 1. Populate the store from the local Nostr IDB cache (instant)
|
|
try {
|
|
await this._cachePromise;
|
|
|
|
const cachedEvents = await this.cache.query(filters);
|
|
|
|
if (cachedEvents && cachedEvents.length > 0) {
|
|
for (const event of cachedEvents) {
|
|
this.store.add(event);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn(
|
|
'[nostr-data] Failed to read incoming zap receipts from local Nostr IDB cache',
|
|
e
|
|
);
|
|
}
|
|
|
|
// 2. Request fresh events from the network in the background
|
|
this._incomingZapsNetworkSub = this._requestContentWithProvenance(
|
|
this.activeReadRelays,
|
|
filters,
|
|
'[nostr-data] Error fetching incoming zap receipts:'
|
|
);
|
|
}
|
|
|
|
loadProfiles(pubkeys) {
|
|
const newPubkeys = pubkeys.filter(
|
|
(pk) => pk && !this._profileModelSubs.has(pk)
|
|
);
|
|
|
|
for (const pubkey of newPubkeys) {
|
|
const sub = this.store
|
|
.model(ProfileModel, pubkey)
|
|
.subscribe((profileContent) => {
|
|
this.profiles = { ...this.profiles, [pubkey]: profileContent };
|
|
});
|
|
this._profileModelSubs.set(pubkey, sub);
|
|
}
|
|
}
|
|
|
|
getProfile(pubkey) {
|
|
return this.profiles[pubkey];
|
|
}
|
|
|
|
_clearProfileSubs() {
|
|
for (const sub of this._profileModelSubs.values()) {
|
|
sub.unsubscribe();
|
|
}
|
|
this._profileModelSubs.clear();
|
|
this.profiles = {};
|
|
}
|
|
|
|
async loadProfile(pubkey) {
|
|
if (!pubkey) return;
|
|
|
|
// Reset state
|
|
this.profile = null;
|
|
this.mailboxes = null;
|
|
this.contacts = null;
|
|
this._contactPubkeys = 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._contactsSub = this.store
|
|
.model(ContactsModel, pubkey)
|
|
.subscribe((contacts) => {
|
|
this.contacts = contacts;
|
|
this._contactPubkeys = new Set(contacts.map((c) => c.pubkey));
|
|
this._updatePlacePhotos();
|
|
});
|
|
|
|
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 = [];
|
|
}
|
|
|
|
if (this.blossomServers.length > 0) {
|
|
const current = this.settings.nostrMediaServer;
|
|
if (
|
|
current === DEFAULT_BLOSSOM_SERVER ||
|
|
!this.blossomServers.includes(current)
|
|
) {
|
|
this.settings.update('nostrMediaServer', this.blossomServers[0]);
|
|
}
|
|
}
|
|
});
|
|
|
|
// 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, 3, 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, 3, 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';
|
|
}
|
|
|
|
async clearCache() {
|
|
await this._cachePromise;
|
|
if (this.cache) {
|
|
await this.cache.deleteAllEvents();
|
|
}
|
|
}
|
|
|
|
_scheduleZapReceiptRefresh(events) {
|
|
const newIds = new Set(events.map((e) => e.id));
|
|
|
|
let changed = false;
|
|
if (newIds.size !== this._lastPhotoIds.size) {
|
|
changed = true;
|
|
} else {
|
|
for (const id of newIds) {
|
|
if (!this._lastPhotoIds.has(id)) {
|
|
changed = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!changed) return;
|
|
this._lastPhotoIds = newIds;
|
|
|
|
this._clearZapRefreshTimer();
|
|
this._zapRefreshTimer = setTimeout(() => {
|
|
this._zapRefreshTimer = null;
|
|
this._refreshZapReceiptSubscription([...newIds]);
|
|
}, 100);
|
|
}
|
|
|
|
_clearZapRefreshTimer() {
|
|
if (this._zapRefreshTimer) {
|
|
clearTimeout(this._zapRefreshTimer);
|
|
this._zapRefreshTimer = null;
|
|
}
|
|
}
|
|
|
|
_refreshZapReceiptSubscription(photoIds) {
|
|
this._cleanupZapReceiptSubs();
|
|
|
|
if (!photoIds || photoIds.length === 0) return;
|
|
|
|
// Batch IDs into filters of <=100 to stay under relay REQ limits
|
|
const BATCH_SIZE = 100;
|
|
const filters = [];
|
|
for (let i = 0; i < photoIds.length; i += BATCH_SIZE) {
|
|
filters.push({
|
|
kinds: [9735],
|
|
'#e': photoIds.slice(i, i + BATCH_SIZE),
|
|
});
|
|
}
|
|
|
|
// Reactive local query — emits whenever matching events are in the store
|
|
this._zapReceiptsSub = this.store.timeline(filters).subscribe((events) => {
|
|
this._updateZapReceipts(events);
|
|
});
|
|
|
|
// Load from IDB cache (fire-and-forget — store.add triggers timeline)
|
|
this._cachePromise
|
|
.then(() => this.cache.query(filters))
|
|
.then((cachedEvents) => {
|
|
if (cachedEvents && cachedEvents.length > 0) {
|
|
for (const event of cachedEvents) {
|
|
this.store.add(event);
|
|
}
|
|
}
|
|
})
|
|
.catch((e) => {
|
|
console.warn('[nostr-data] Failed to read zap receipts from cache', e);
|
|
});
|
|
|
|
// Network request (fire-and-forget)
|
|
this._zapReceiptsNetworkSub = this.nostrRelay.pool
|
|
.request(this.activeReadRelays, filters)
|
|
.subscribe({
|
|
next: (event) => {
|
|
this.store.add(event);
|
|
},
|
|
error: (err) => {
|
|
console.error('[nostr-data] Error fetching zap receipts:', err);
|
|
},
|
|
});
|
|
}
|
|
|
|
_updateZapReceipts(events) {
|
|
const grouped = {};
|
|
for (const receipt of events) {
|
|
for (const tag of receipt.tags) {
|
|
if (tag[0] === 'e' && tag[1]) {
|
|
if (!grouped[tag[1]]) grouped[tag[1]] = [];
|
|
grouped[tag[1]].push(receipt);
|
|
}
|
|
}
|
|
}
|
|
this.zapReceipts = { ...this.zapReceipts, ...grouped };
|
|
}
|
|
|
|
_cleanupZapReceiptSubs() {
|
|
if (this._zapReceiptsSub) {
|
|
this._zapReceiptsSub.unsubscribe();
|
|
this._zapReceiptsSub = null;
|
|
}
|
|
if (this._zapReceiptsNetworkSub) {
|
|
this._zapReceiptsNetworkSub.unsubscribe();
|
|
this._zapReceiptsNetworkSub = null;
|
|
}
|
|
}
|
|
|
|
_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._contactsSub) {
|
|
this._contactsSub.unsubscribe();
|
|
this._contactsSub = null;
|
|
this._contactPubkeys = null;
|
|
}
|
|
if (this._blossomSub) {
|
|
this._blossomSub.unsubscribe();
|
|
this._blossomSub = null;
|
|
}
|
|
if (this._photosSub) {
|
|
this._photosSub.unsubscribe();
|
|
this._photosSub = null;
|
|
}
|
|
if (this._contributionsSub) {
|
|
this._contributionsSub.unsubscribe();
|
|
this._contributionsSub = null;
|
|
}
|
|
this._cleanupZapReceiptSubs();
|
|
this._clearZapRefreshTimer();
|
|
if (this._incomingZapsNetworkSub) {
|
|
this._incomingZapsNetworkSub.unsubscribe();
|
|
this._incomingZapsNetworkSub = null;
|
|
}
|
|
}
|
|
|
|
willDestroy() {
|
|
super.willDestroy(...arguments);
|
|
this._cleanupSubscriptions();
|
|
this._clearProfileSubs();
|
|
|
|
if (this._deletionsSub) {
|
|
this._deletionsSub.unsubscribe();
|
|
this._deletionsSub = null;
|
|
}
|
|
|
|
if (this._stopPersisting) {
|
|
this._stopPersisting();
|
|
}
|
|
|
|
if (this.cache) {
|
|
this.cache.stop();
|
|
}
|
|
}
|
|
}
|