Files
marco/app/services/contributions.js
T
raucao b9e5213c27
CI / Lint (pull_request) Successful in 53s
CI / Test (pull_request) Successful in 1m6s
Add timeline/history for signed-in user's contributions
2026-08-18 16:21:31 -06:00

251 lines
7.8 KiB
JavaScript

import Service, { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { groupPhotoContributions } from '../utils/contributions';
const NAME_CACHE_KEY = 'marco:contributions:name_cache';
/**
* Orchestrates loading the user's own Nostr contributions, grouping them into
* timeline entries, and resolving place names from bookmarks or the OSM API.
*
* The flow is:
* 1. Subscribe to `nostrData.store.timeline(...)` for the user's kind 360 events.
* 2. Group events into contribution entries via `groupPhotoContributions`.
* 3. Resolve place names immediately from bookmarks / name cache / OSM cache.
* 4. For unresolved names, batch-fetch from the OSM API in the background.
* 5. Any items that can't be resolved get a fallback name so they don't stay
* stuck in a "Loading…" state forever.
* 6. Update `@tracked items` so the UI renders progressively.
*/
export default class ContributionsService extends Service {
@service nostrData;
@service nostrAuth;
@service storage;
@service osm;
@tracked items = [];
_sub = null;
_pendingBatchPromise = null;
_lastBatchSignature = '';
_nameCache = new Map();
_unresolvable = new Set();
constructor() {
super(...arguments);
this._loadNameCache();
}
_loadNameCache() {
if (typeof localStorage === 'undefined') return;
try {
const raw = localStorage.getItem(NAME_CACHE_KEY);
if (raw) {
const obj = JSON.parse(raw);
if (obj && typeof obj === 'object') {
this._nameCache = new Map(Object.entries(obj));
}
}
} catch {
// ignore malformed cache
}
}
_saveNameCache() {
if (typeof localStorage === 'undefined') return;
try {
const obj = Object.fromEntries(this._nameCache);
localStorage.setItem(NAME_CACHE_KEY, JSON.stringify(obj));
} catch (e) {
console.debug('[contributions] Failed to persist name cache', e);
}
}
/**
* Loads the user's contributions and subscribes to live updates.
*
* @param {string} pubkey The user's Nostr pubkey
*/
async load(pubkey) {
if (!pubkey) {
this.items = [];
return;
}
// Subscribe to the user's own kind 360 events. Each update triggers a re-group
// and place-name resolution. This mirrors the `loadPhotosForPlace` pattern.
this._sub = this.nostrData.store
.timeline([{ kinds: [360, 5], authors: [pubkey] }])
.subscribe((events) => {
this._updateItems(events);
});
// Also kick off the network fetch via nostrData. This populates the store and
// causes the subscription above to fire with fresh events.
await this.nostrData.loadMyContributions(pubkey);
}
/**
* Stops the subscription and clears the timeline. Called when leaving the
* contributions route.
*/
stop() {
if (this._sub) {
this._sub.unsubscribe();
this._sub = null;
}
this.items = [];
this._lastBatchSignature = '';
this._pendingBatchPromise = null;
this._unresolvable.clear();
}
willDestroy() {
this.stop();
super.willDestroy(...arguments);
}
_updateItems(events) {
// 1. Group events into contribution entries (newest-first)
const entries = groupPhotoContributions(events);
// 2. Resolve place names: preserve previously-resolved names, then check
// bookmarks, the name cache, and the OSM service cache.
for (const entry of entries) {
const cached = this._resolveCachedName(entry);
if (cached) {
entry.placeName = cached;
entry.placeNameLoading = false;
}
}
this.items = entries;
// 3. Trigger a background batch fetch for any still-unresolved names.
// De-duplicate so we don't re-fetch the same set while a fetch is in-flight.
this._maybeBatchFetchNames(entries);
}
_resolveCachedName(entry) {
// 1. Try bookmarks (instant)
const bookmark = this.storage.findPlaceById(entry.osmId);
if (bookmark?.title) return bookmark.title;
// 2. Try the persistent name cache (instant, survives across sessions)
if (this._nameCache.has(entry.placeIdentifier)) {
return this._nameCache.get(entry.placeIdentifier);
}
// 3. Try OSM localStorage cache (instant, from place detail visits)
const cached = this.osm.getCachedOsmObject(entry.osmType, entry.osmId);
return cached?.title || null;
}
_isUnresolvable(entry) {
return this._unresolvable.has(entry.placeIdentifier);
}
_maybeBatchFetchNames(entries) {
const unresolved = entries.filter(
(e) => e.placeNameLoading && !this._isUnresolvable(e)
);
// Apply fallbacks for items already marked unresolvable in this session
for (const entry of entries) {
if (entry.placeNameLoading && this._isUnresolvable(entry)) {
entry.placeName = this._fallbackName(entry);
entry.placeNameLoading = false;
}
}
if (unresolved.length === 0) {
if (this.items.some((i) => !i.placeNameLoading)) {
this.items = [...this.items];
}
return;
}
// Build a stable signature so we only fire one batch request per unique set
const signature = unresolved
.map((e) => e.placeIdentifier)
.sort()
.join('|');
if (signature === this._lastBatchSignature && this._pendingBatchPromise) {
return;
}
this._lastBatchSignature = signature;
this._pendingBatchPromise = this._batchResolveNames(unresolved)
.then((nameMap) => {
// Merge resolved names back into the current `items` and the name cache.
for (const item of this.items) {
if (!item.placeNameLoading) continue;
if (nameMap.has(item.placeIdentifier)) {
const name = nameMap.get(item.placeIdentifier);
item.placeName = name;
item.placeNameLoading = false;
} else {
// Could not be resolved (deleted object, network error for this item).
// Mark as unresolvable for this session and use a fallback.
this._unresolvable.add(item.placeIdentifier);
item.placeName = this._fallbackName(item);
item.placeNameLoading = false;
}
}
this._saveNameCache();
// Trigger a re-render
this.items = [...this.items];
})
.catch((e) => {
console.error('[contributions] Batch name resolution failed', e);
// On a total failure, mark all as unresolvable and apply fallbacks
for (const item of this.items) {
if (item.placeNameLoading) {
this._unresolvable.add(item.placeIdentifier);
item.placeName = this._fallbackName(item);
item.placeNameLoading = false;
}
}
this._saveNameCache();
this.items = [...this.items];
})
.finally(() => {
this._pendingBatchPromise = null;
});
}
_fallbackName(item) {
return `OSM ${item.osmType} ${item.osmId}`;
}
async _batchResolveNames(entries) {
const nameMap = new Map();
const toFetch = [];
// Re-check cache in case it was populated between the trigger and now
for (const entry of entries) {
const cached = this._resolveCachedName(entry);
if (cached) {
nameMap.set(entry.placeIdentifier, cached);
} else {
toFetch.push({ osmType: entry.osmType, osmId: entry.osmId });
}
}
if (toFetch.length === 0) return nameMap;
const places = await this.osm.fetchOsmObjectsBatch(toFetch);
for (const entry of entries) {
if (nameMap.has(entry.placeIdentifier)) continue;
const cacheKey = `${entry.osmType}:${entry.osmId}`;
const place = places.get(cacheKey);
if (place?.title) {
nameMap.set(entry.placeIdentifier, place.title);
this._nameCache.set(entry.placeIdentifier, place.title);
}
}
this._saveNameCache();
return nameMap;
}
}