diff --git a/app/components/map.gjs b/app/components/map.gjs index 4e48050..1dc567d 100644 --- a/app/components/map.gjs +++ b/app/components/map.gjs @@ -27,6 +27,7 @@ export default class MapComponent extends Component { @service mapUi; @service router; @service settings; + @service nostrData; mapInstance; bookmarkSource; @@ -1078,6 +1079,7 @@ export default class MapComponent extends Component { const bbox = { minLat, minLon, maxLat, maxLon }; this.mapUi.updateBounds(bbox); await this.storage.loadPlacesInBounds(bbox); + this.nostrData.loadPlacesInBounds(bbox); this.loadBookmarks(this.storage.placesInView); // Persist view to localStorage diff --git a/app/components/place-photo-upload.gjs b/app/components/place-photo-upload.gjs index 7d8b52b..fced83a 100644 --- a/app/components/place-photo-upload.gjs +++ b/app/components/place-photo-upload.gjs @@ -183,6 +183,7 @@ export default class PlacePhotoUpload extends Component { const event = await factory.sign(template); await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event); + this.nostrData.store.add(event); this.toast.show('Photos published successfully'); this.status = ''; diff --git a/app/services/map-ui.js b/app/services/map-ui.js index ca6e8e7..63e9603 100644 --- a/app/services/map-ui.js +++ b/app/services/map-ui.js @@ -1,7 +1,9 @@ -import Service from '@ember/service'; +import Service, { service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; export default class MapUiService extends Service { + @service nostrData; + @tracked selectedPlace = null; @tracked isSearching = false; @tracked isCreating = false; @@ -19,12 +21,14 @@ export default class MapUiService extends Service { selectPlace(place, options = {}) { this.selectedPlace = place; this.selectionOptions = options; + this.nostrData.loadPhotosForPlace(place); } clearSelection() { this.selectedPlace = null; this.selectionOptions = {}; this.preventNextZoom = false; + this.nostrData.loadPhotosForPlace(null); } setSearchResults(results) { diff --git a/app/services/nostr-data.js b/app/services/nostr-data.js index 5a1d257..a3df768 100644 --- a/app/services/nostr-data.js +++ b/app/services/nostr-data.js @@ -7,6 +7,7 @@ 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'; +import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage'; const DIRECTORY_RELAYS = [ 'wss://purplepag.es', @@ -27,13 +28,16 @@ export default class NostrDataService extends Service { @tracked profile = null; @tracked mailboxes = null; @tracked blossomServers = []; + @tracked placePhotos = []; _profileSub = null; _mailboxesSub = null; _blossomSub = null; + _photosSub = null; _requestSub = null; _cachePromise = null; + loadedGeohashPrefixes = new Set(); constructor() { super(...arguments); @@ -51,9 +55,13 @@ export default class NostrDataService extends Service { this._stopPersisting = persistEventsToCache( this.store, async (events) => { - // Only cache profiles, mailboxes, and blossom servers + // Only cache profiles, mailboxes, blossom servers, and place photos const toCache = events.filter( - (e) => e.kind === 0 || e.kind === 10002 || e.kind === 10063 + (e) => + e.kind === 0 || + e.kind === 10002 || + e.kind === 10063 || + e.kind === 360 ); if (toCache.length > 0) { @@ -113,6 +121,138 @@ export default class NostrDataService extends Service { return this.defaultWriteRelays; } + 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 + this.nostrRelay.pool + .request(this.activeReadRelays, [ + { + kinds: [360], + '#g': missingPrefixes, + }, + ]) + .subscribe({ + next: (event) => { + this.store.add(event); + }, + error: (err) => { + console.error( + '[nostr-data] Error fetching place photos by geohash:', + err + ); + }, + }); + + for (const p of missingPrefixes) { + this.loadedGeohashPrefixes.add(p); + } + } + + async loadPhotosForPlace(place) { + if (this._photosSub) { + this._photosSub.unsubscribe(); + this._photosSub = null; + } + + this.placePhotos = []; + + if (!place || !place.osmId || !place.osmType) { + return; + } + + const entityId = `osm:${place.osmType}:${place.osmId}`; + + // Setup reactive store query + this._photosSub = this.store + .timeline([ + { + kinds: [360], + '#i': [entityId], + }, + ]) + .subscribe((events) => { + this.placePhotos = events; + }); + + try { + await this._cachePromise; + + const cachedEvents = await this.cache.query([ + { + kinds: [360], + '#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 + this.nostrRelay.pool + .request(this.activeReadRelays, [ + { + kinds: [360], + '#i': [entityId], + }, + ]) + .subscribe({ + next: (event) => { + this.store.add(event); + }, + error: (err) => { + console.error( + '[nostr-data] Error fetching place photos for place:', + err + ); + }, + }); + } + async loadProfile(pubkey) { if (!pubkey) return; @@ -233,6 +373,10 @@ export default class NostrDataService extends Service { this._blossomSub.unsubscribe(); this._blossomSub = null; } + if (this._photosSub) { + this._photosSub.unsubscribe(); + this._photosSub = null; + } } willDestroy() {