From 35aa3c950fdad0cfd0de1b802332c1b258b20db6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Wed, 2 Sep 2026 18:36:58 -0600 Subject: [PATCH] Auto-load older activities Both initially (if not enough recent ones are available), and when scrolling to the end of an activity timeline --- app/components/activity-photo-item.gjs | 10 +- app/components/activity-timeline.gjs | 8 + app/components/activity-zap-item.gjs | 8 +- app/controllers/activity.js | 13 + app/modifiers/observe-intersection.js | 31 ++ app/routes/activity.js | 3 +- app/services/activity.js | 290 ++++++++++++------ app/services/nostr-data.js | 198 +++++++++++- app/styles/app.css | 27 ++ app/templates/activity.gjs | 4 +- app/utils/activity.js | 2 +- tests/acceptance/activity-test.js | 6 +- .../components/activity-timeline-test.gjs | 107 ++++++- .../components/activity-zap-item-test.gjs | 11 +- tests/unit/services/activity-test.js | 271 +++++++++++++++- tests/unit/services/nostr-data-test.js | 238 +++++++++++++- 16 files changed, 1092 insertions(+), 135 deletions(-) create mode 100644 app/modifiers/observe-intersection.js diff --git a/app/components/activity-photo-item.gjs b/app/components/activity-photo-item.gjs index dc96ae6..44579f1 100644 --- a/app/components/activity-photo-item.gjs +++ b/app/components/activity-photo-item.gjs @@ -77,7 +77,7 @@ export default class ActivityPhotoItem extends Component { {{this.senderDisplayName}} {{! template-lint-disable no-whitespace-for-layout }} - added {{this.photoCountText}} + shared {{this.photoCountText}} {{#if this.placeName}} - {{this.placeName}} - {{else if this.placeNameLoading}} - Loading… - {{else}} - Unnamed place + {{this.placeName}} + {{else if this.placeNameLoading}}{{else}} + Unnamed place {{/if}} {{formatRelativeDate diff --git a/app/components/activity-timeline.gjs b/app/components/activity-timeline.gjs index 2d14491..cc0497b 100644 --- a/app/components/activity-timeline.gjs +++ b/app/components/activity-timeline.gjs @@ -12,6 +12,7 @@ import not from 'ember-truth-helpers/helpers/not'; import eq from 'ember-truth-helpers/helpers/eq'; import and from 'ember-truth-helpers/helpers/and'; import restoreScroll from '../modifiers/restore-scroll'; +import observeIntersection from '../modifiers/observe-intersection'; export default class ActivityTimelineComponent extends Component { @tracked isNostrConnectModalOpen = false; @@ -87,6 +88,13 @@ export default class ActivityTimelineComponent extends Component { {{/if}} {{/each}} +
  • + {{#if @isLoadingMore}} +
    + +
    + {{/if}} +
  • {{/if}} diff --git a/app/components/activity-zap-item.gjs b/app/components/activity-zap-item.gjs index d58c8ae..410471d 100644 --- a/app/components/activity-zap-item.gjs +++ b/app/components/activity-zap-item.gjs @@ -85,11 +85,9 @@ export default class ActivityZapItem extends Component {
    {{#if this.placeName}} - {{this.placeName}} - {{else if this.placeNameLoading}} - Loading… - {{else}} - Unnamed place + {{this.placeName}} + {{else if this.placeNameLoading}}{{else}} + Unnamed place {{/if}} {{formatRelativeDate diff --git a/app/controllers/activity.js b/app/controllers/activity.js index 86661fc..3d44623 100644 --- a/app/controllers/activity.js +++ b/app/controllers/activity.js @@ -21,6 +21,10 @@ export default class ActivityController extends Controller { return this.activity.items; } + get isLoading() { + return this.activity.isLoading; + } + get isConnected() { return this.nostrAuth.isConnected; } @@ -29,11 +33,20 @@ export default class ActivityController extends Controller { return this.activity.sourceMode; } + get isLoadingMore() { + return this.activity.isLoadingMore; + } + @action setSourceMode(mode) { this.activity.setSourceMode(mode); } + @action + loadMore() { + this.activity.loadMore(); + } + @action selectItem(item) { if (!item || !item.placeIdentifier) return; diff --git a/app/modifiers/observe-intersection.js b/app/modifiers/observe-intersection.js new file mode 100644 index 0000000..b080647 --- /dev/null +++ b/app/modifiers/observe-intersection.js @@ -0,0 +1,31 @@ +import { modifier } from 'ember-modifier'; + +export default modifier((element, [callback, disabled]) => { + if (disabled) return; + + let observer; + let cancelled = false; + + observer = new IntersectionObserver( + (entries) => { + if (cancelled) return; + if (entries[0]?.isIntersecting) { + callback(); + } + }, + { + root: null, + rootMargin: '200px', + threshold: 0, + } + ); + + observer.observe(element); + + return () => { + cancelled = true; + if (observer) { + observer.disconnect(); + } + }; +}); diff --git a/app/routes/activity.js b/app/routes/activity.js index 2b1db64..6a60525 100644 --- a/app/routes/activity.js +++ b/app/routes/activity.js @@ -18,6 +18,7 @@ export default class ActivityRoute extends Route { } deactivate() { - this.activity.stop(); + // Don't call activity.stop() — keep state alive when navigating to place + // details and back. The service's willDestroy() handles final cleanup. } } diff --git a/app/services/activity.js b/app/services/activity.js index 3d7011d..1996532 100644 --- a/app/services/activity.js +++ b/app/services/activity.js @@ -9,6 +9,8 @@ import { } from '../utils/activity'; const SINCE_WINDOW = 30 * 24 * 60 * 60; // 30 days in seconds +const MIN_SINCE = Math.floor(new Date('2026-04-20').getTime() / 1000); +const MAX_WINDOW = 960 * 24 * 60 * 60; // 960 days in seconds /** * Orchestrates loading the user's incoming social activity (zaps received on @@ -16,8 +18,7 @@ const SINCE_WINDOW = 30 * 24 * 60 * 60; // 30 days in seconds * timeline. * * The flow is: - * 1. Subscribe to `nostrData.store.timeline(...)` for kind 9735 zap receipts - * where the user is the recipient (`#p` filter). + * 1. Fetch kind 9735 zap receipts where the user is the recipient. * 2. Parse each receipt into an `ActivityEntry`, filtering to zaps on the * user's own kind 360 photos. * 3. Resolve sender profiles asynchronously via a per-sender `ProfileModel` @@ -32,9 +33,9 @@ export default class ActivityService extends Service { @tracked items = []; @tracked sourceMode = 'home'; + @tracked isLoading = false; + @tracked isLoadingMore = false; - _sub = null; - _socialSub = null; _profileSubs = new Map(); _userPubkey = null; _zapItems = []; @@ -42,86 +43,96 @@ export default class ActivityService extends Service { _lastSocialEvents = []; _since = null; _sourceMode = 'home'; + _isLoadingMore = false; /** - * Loads the user's incoming zap receipts and social photo activity, then - * subscribes to live updates. + * Loads the user's incoming zap receipts and social photo activity. * * When a Nostr account is connected, zaps received on the user's photos are * loaded and the 'home' mode shows photos from followed contacts. When no * account is connected, zaps and 'home' mode are skipped, but 'explore' * mode still loads photos from trusted relays. * + * If the initial 30-day window returns no events, the window expands + * exponentially (up to 960 days) until events are found or the minimum + * timestamp (2026-04-20) is reached. + * * @param {string|null} pubkey The user's Nostr pubkey, or null */ async load(pubkey) { + if (this.items.length > 0 && pubkey) return; this._userPubkey = pubkey || null; this._since = Math.floor(Date.now() / 1000) - SINCE_WINDOW; + this.isLoading = true; - if (pubkey) { - const zapFilters = [{ kinds: [9735], '#p': [pubkey] }]; + try { + if (pubkey) { + await this.nostrData.loadMyContributions(pubkey); + await this.nostrData.loadProfile(pubkey); + await this.nostrData.whenContactsLoaded(); + } - console.debug('[activity] Subscribing to zap receipts', { - filters: zapFilters, - pubkey, - activeReadRelays: this.nostrData.activeReadRelays, - }); + this._socialItems = []; + this._zapItems = []; + this._lastSocialEvents = []; + this._mergeItems(); - // Subscribe to zap receipts (kind 9735 where #p = user) - this._sub = this.nostrData.store - .timeline(zapFilters) - .subscribe((events) => { - this._updateZapItems(events, pubkey); - }); + const now = Math.floor(Date.now() / 1000); + await this._fetchWithBackoff(this._since, now); + } finally { + this.isLoading = false; } - - // Subscribe to all kind 360 photos in the time window. The callback - // filters by source mode (e.g. followed contacts only) so we only - // show photos from the relevant source. - const photoFilters = [{ kinds: [360], since: this._since }]; - this._socialSub = this.nostrData.store - .timeline(photoFilters) - .subscribe((events) => { - this._lastSocialEvents = events; - this._updateSocialItems(events); - }); - - if (pubkey) { - // Ensure the user's kind 360 photo events are in the store first so - // enrichWithPhoto can look them up when zap receipts arrive. - await this.nostrData.loadMyContributions(pubkey); - - // Then load zap receipts — adding them to the store triggers the - // timeline subscription, and by now the photo events are available. - await this.nostrData.loadIncomingZaps(pubkey); - } - - // Load social photos (contacts dependency handled internally — if - // contacts haven't loaded yet, loadActivityPhotos returns early and - // the ContactsModel callback re-triggers it when they arrive). - await this.nostrData.loadActivityPhotos(this._since, this._sourceMode); } /** * Switches the activity source mode (e.g. 'home' for followee photos, - * 'explore' for all photos from trusted relays). Re-filters existing - * events and re-fetches with the new mode. + * 'explore' for all photos from trusted relays). Resets to a fresh + * 30-day window and re-fetches both zaps and photos with the new mode. * * @param {string} mode The new source mode */ - setSourceMode(mode) { + async setSourceMode(mode) { if (mode === this._sourceMode) return; this._sourceMode = mode; this.sourceMode = mode; - // Re-filter existing events with the new mode - if (this._lastSocialEvents.length > 0) { - this._updateSocialItems(this._lastSocialEvents); - } + this._since = Math.floor(Date.now() / 1000) - SINCE_WINDOW; + this._socialItems = []; + this._zapItems = []; + this._lastSocialEvents = []; + this._mergeItems(); - // Re-fetch with the new mode - if (this._since !== null) { - this.nostrData.loadActivityPhotos(this._since, mode); + this.isLoading = true; + + try { + const now = Math.floor(Date.now() / 1000); + await this._fetchWithBackoff(this._since, now); + } finally { + this.isLoading = false; + } + } + + /** + * Loads older events by extending the time window further back. + * Only processes newly fetched events — existing items are not re-processed. + * + * If an empty window is returned (no photos AND no zaps), the window size + * doubles (up to 960 days) and the fetch is retried automatically until + * events are found or the minimum timestamp (2026-04-20) is reached. + * + * @returns {Promise} + */ + async loadMore() { + if (this._isLoadingMore || !this._since || !this.items.length) return; + this._isLoadingMore = true; + this.isLoadingMore = true; + + try { + const startSince = this._since - SINCE_WINDOW; + await this._fetchWithBackoff(startSince, this._since); + } finally { + this._isLoadingMore = false; + this.isLoadingMore = false; } } @@ -130,14 +141,6 @@ export default class ActivityService extends Service { * activity route. */ stop() { - if (this._sub) { - this._sub.unsubscribe(); - this._sub = null; - } - if (this._socialSub) { - this._socialSub.unsubscribe(); - this._socialSub = null; - } this._cleanupProfileSubs(); this._zapItems = []; this._socialItems = []; @@ -145,6 +148,9 @@ export default class ActivityService extends Service { this.items = []; this._userPubkey = null; this._since = null; + this._isLoadingMore = false; + this.isLoadingMore = false; + this.isLoading = false; this.placeNameResolver.reset(); } @@ -153,35 +159,141 @@ export default class ActivityService extends Service { super.willDestroy(...arguments); } - _updateZapItems(receipts, pubkey) { + /** + * Fetches photos and zaps for a single time window, processes them, and + * appends to the respective item arrays. + * + * @param {number} batchSince Start of the window (seconds) + * @param {number} batchUntil End of the window (seconds) + * @returns {Promise} True if any events were found + */ + async _fetchAndProcessWindow(batchSince, batchUntil) { + const newEvents = await this.nostrData.fetchActivityPhotos( + batchSince, + batchUntil, + this._sourceMode + ); + + let newZapEvents = []; + if (this._sourceMode === 'home' && this._userPubkey) { + newZapEvents = await this.nostrData.fetchIncomingZaps( + this._userPubkey, + batchSince, + batchUntil + ); + } + + if (newEvents.length === 0 && newZapEvents.length === 0) { + return false; + } + + let added = false; + + if (newEvents.length > 0) { + this._lastSocialEvents = [...this._lastSocialEvents, ...newEvents]; + + const filtered = newEvents.filter((e) => this._matchesSourceMode(e)); + if (filtered.length > 0) { + const newEntries = groupSocialPhotos(filtered); + + for (const entry of newEntries) { + this._resolveSender(entry); + } + + for (const entry of newEntries) { + if (entry.osmId) { + const bookmarkName = this.placeNameResolver.resolveBookmark( + entry.osmId + ); + if (bookmarkName) { + entry.placeName = bookmarkName; + entry.placeNameLoading = false; + } + } + } + + this._socialItems = [...this._socialItems, ...newEntries]; + this._mergeItems(); + + void this.placeNameResolver + .resolveInBackground(newEntries) + .then(() => this._mergeItems()); + + added = true; + } + } + + if (newZapEvents.length > 0) { + const newZapEntries = this._processZapEvents( + newZapEvents, + this._userPubkey + ); + if (newZapEntries.length > 0) { + this._zapItems = [...this._zapItems, ...newZapEntries]; + this._mergeItems(); + + void this.placeNameResolver + .resolveInBackground(newZapEntries) + .then(() => this._mergeItems()); + + added = true; + } + } + + return added; + } + + /** + * Fetches events with exponential backoff. Starts with a 30-day window + * and doubles the window size on each empty result (up to 960 days), + * until events are found or MIN_SINCE is reached. + * + * @param {number} startSince Start of the first window (seconds) + * @param {number} startUntil End of the first window (seconds) + * @returns {Promise} + */ + async _fetchWithBackoff(startSince, startUntil) { + let windowSize = SINCE_WINDOW; + let batchSince = startSince; + let batchUntil = startUntil; + + while (true) { + if (this._since === null) return; // stopped + + // Clamp to MIN_SINCE — this is the final iteration if clamped + const isFinal = batchSince < MIN_SINCE; + if (isFinal) { + batchSince = MIN_SINCE; + } + + const found = await this._fetchAndProcessWindow(batchSince, batchUntil); + this._since = batchSince; + + if (found || isFinal) break; + + windowSize = Math.min(windowSize * 2, MAX_WINDOW); + batchUntil = batchSince; + batchSince = batchSince - windowSize; + } + } + + _processZapEvents(receipts, pubkey) { const entries = []; for (const receipt of receipts) { const entry = parseZapReceipt(receipt, pubkey); if (!entry) continue; - // Only keep zaps for the user's own kind 360 photos if (!enrichWithPhoto(entry, this.nostrData.store, pubkey)) { continue; } - // Resolve sender profile (async, fire-and-forget) this._resolveSender(entry); - entries.push(entry); } - // Sort newest-first by created_at entries.sort((a, b) => b.createdAt - a.createdAt); - console.debug('[activity] Zap receipts received', { - total: receipts.length, - matched: entries.length, - pubkey, - }); - - // Synchronous bookmark lookup — resolve those immediately so the - // first render shows bookmarked place names without a "Loading…" flicker. for (const entry of entries) { if (entry.osmId) { const bookmarkName = this.placeNameResolver.resolveBookmark( @@ -194,27 +306,28 @@ export default class ActivityService extends Service { } } + return entries; + } + + _updateZapItems(receipts, pubkey) { + const entries = this._processZapEvents(receipts, pubkey); + this._zapItems = entries; this._mergeItems(); - // Async-resolve remaining entries from the IndexedDB name cache and OSM - // cache, then fall through to the network batch for the rest. void this.placeNameResolver.resolveInBackground(entries).then(() => { this._mergeItems(); }); } _updateSocialItems(events) { - // Filter by source mode (e.g. only followed contacts, excluding own photos) const filtered = events.filter((e) => this._matchesSourceMode(e)); const entries = groupSocialPhotos(filtered); - // Resolve sender profiles (async, fire-and-forget per sender) for (const entry of entries) { this._resolveSender(entry); } - // Synchronous bookmark lookup for instant first render for (const entry of entries) { if (entry.osmId) { const bookmarkName = this.placeNameResolver.resolveBookmark( @@ -230,15 +343,11 @@ export default class ActivityService extends Service { this._socialItems = entries; this._mergeItems(); - // Async-resolve remaining place names from caches → network batch void this.placeNameResolver.resolveInBackground(entries).then(() => { this._mergeItems(); }); } - /** - * Merges `_zapItems` and `_socialItems` into `items`, sorted newest-first. - */ _mergeItems() { const all = this._sourceMode === 'explore' @@ -282,10 +391,6 @@ export default class ActivityService extends Service { const pubkey = entry.senderPubkey; if (!pubkey) return; - // Set up a ProfileModel subscription for this sender so the entry's - // tracked fields update when the profile arrives from cache or network. - // The event loader (configured in nostrData) auto-fetches missing kind 0 - // events from the IDB cache and relays. if (!this._profileSubs.has(pubkey)) { const sub = this.nostrData.store .model(ProfileModel, pubkey) @@ -295,17 +400,12 @@ export default class ActivityService extends Service { this._profileSubs.set(pubkey, sub); } - // Read immediately in case the profile is already cached this._applySenderProfile(entry, pubkey); } _applySenderProfile(entry, pubkey) { - // Try nostrData's profiles dict first (populated by other parts of the app) let profile = this.nostrData.getProfile(pubkey); - // Fall back to reading the kind 0 event directly from the store. This - // handles the re-open case where the event is already in the store from - // a previous load but nostrData.profiles wasn't populated by this service. if (!profile) { const event = this.nostrData.store.getReplaceable(0, pubkey); if (event) { @@ -321,7 +421,6 @@ export default class ActivityService extends Service { } _applyProfileToPubkey(pubkey, profileContent) { - // Update all entries matching this sender let changed = false; for (const entry of this.items) { if (entry.senderPubkey === pubkey) { @@ -334,7 +433,6 @@ export default class ActivityService extends Service { } if (changed) { - // Trigger re-render this.items = [...this.items]; } } diff --git a/app/services/nostr-data.js b/app/services/nostr-data.js index 02bb3b0..895ae12 100644 --- a/app/services/nostr-data.js +++ b/app/services/nostr-data.js @@ -76,6 +76,11 @@ export default class NostrDataService extends Service { // trust lookups. Rebuilt whenever contacts change. _contactPubkeys = null; + // Deferred that resolves when contacts are first loaded. Created in + // loadProfile, resolved in the ContactsModel callback. + _contactsResolver = null; + _contactsPromise = null; + // Session-only reveal toggle for untrusted content. Not persisted. @tracked showUntrustedContent = false; // Count of currently-hidden (untrusted) place photos for the selected place. @@ -722,6 +727,166 @@ export default class NostrDataService extends Service { ); } + /** + * Fetches events from the network as a Promise that resolves on EOSE. + * Records provenance for each event and adds them to the store. + * + * @param {object[]} filters Nostr filters + * @param {string} errorLabel Log label for errors + * @returns {Promise} Deduplicated events received from relays + */ + async _fetchEventsWithProvenance(filters, errorLabel) { + const complete = RelayGroup.completeOnAny( + RelayGroup.completeAfterFirstRelay(5_000), + RelayGroup.completeOnAllEose() + ); + + const seen = new Set(); + const events = []; + + return new Promise((resolve) => { + this.nostrRelay.pool + .req(this.activeReadRelays, filters) + .pipe( + completeWhen(complete), + timeout({ first: 30_000 }), + filter((message) => message.type === 'EVENT') + ) + .subscribe({ + next: (message) => { + this._recordProvenance(message.event.id, message.from); + this.store.add(message.event); + if (!seen.has(message.event.id)) { + seen.add(message.event.id); + events.push(message.event); + } + }, + error: (err) => { + console.error(errorLabel, err); + resolve(events); + }, + complete: () => resolve(events), + }); + }); + } + + /** + * Fetches kind 360 (Place Photo) events for the activity feed as a Promise + * that resolves on EOSE. Loads from the IDB cache first (instant), then from + * the network with provenance tracking for trust filtering. + * + * - `'home'` mode: fetches photos authored by the user's followed contacts + * (followees), batched by author pubkey. + * - `'explore'` mode: fetches all kind 360 photos in the time window from + * trusted relays (no authors filter). + * + * @param {number} since Unix timestamp (seconds) for the start of the window + * @param {number} [until] Unix timestamp (seconds) for the end of the window + * @param {string} [mode='home'] Source mode + * @returns {Promise} Deduplicated events + */ + async fetchActivityPhotos(since, until, mode = 'home') { + let filters; + if (mode === 'home') { + const pubkeys = this._contactPubkeys + ? Array.from(this._contactPubkeys) + : []; + if (pubkeys.length === 0) return []; + filters = this._batchAuthorFilters(pubkeys, [360], since, until); + } else if (mode === 'explore') { + const filter = { kinds: [360], since }; + if (until !== undefined) filter.until = until; + filters = [filter]; + } else { + return []; + } + + // 1. Populate the store from the local Nostr IDB cache (instant) + const cacheEvents = await this._cachePromise + .then(() => this.cache.query(filters)) + .catch(() => []); + + const seen = new Set(); + const all = []; + + if (cacheEvents) { + for (const event of cacheEvents) { + if (!seen.has(event.id)) { + seen.add(event.id); + all.push(event); + this.store.add(event); + } + } + } + + // 2. Request fresh events from the network (resolves on EOSE) + const networkEvents = await this._fetchEventsWithProvenance( + filters, + '[nostr-data] Error fetching activity photos:' + ); + + for (const event of networkEvents) { + if (!seen.has(event.id)) { + seen.add(event.id); + all.push(event); + } + } + + return all; + } + + /** + * Fetches incoming zap receipts (kind 9735) where the user is the recipient, + * as a Promise that resolves on EOSE. Loads from the IDB cache first, then + * from the network. + * + * @param {string} pubkey The user's Nostr pubkey + * @param {number} [since] Unix timestamp (seconds) for the start of the window + * @param {number} [until] Unix timestamp (seconds) for the end of the window + * @returns {Promise} Deduplicated zap receipt events + */ + async fetchIncomingZaps(pubkey, since, until) { + if (!pubkey) return []; + + const filter = { kinds: [9735], '#p': [pubkey] }; + if (since !== undefined) filter.since = since; + if (until !== undefined) filter.until = until; + const filters = [filter]; + + const seen = new Set(); + const all = []; + + // 1. IDB cache + const cacheEvents = await this._cachePromise + .then(() => this.cache.query(filters)) + .catch(() => []); + + if (cacheEvents) { + for (const event of cacheEvents) { + if (!seen.has(event.id)) { + seen.add(event.id); + all.push(event); + this.store.add(event); + } + } + } + + // 2. Network (resolves on EOSE) + const networkEvents = await this._fetchEventsWithProvenance( + filters, + '[nostr-data] Error fetching incoming zap receipts:' + ); + + for (const event of networkEvents) { + if (!seen.has(event.id)) { + seen.add(event.id); + all.push(event); + } + } + + return all; + } + /** * Builds an array of Nostr filters, each with ≤ `BATCH_SIZE` author pubkeys. * @@ -730,15 +895,17 @@ export default class NostrDataService extends Service { * @param {number} since Unix timestamp (seconds) for the start of the window * @returns {object[]} Array of filter objects */ - _batchAuthorFilters(pubkeys, kinds, since) { + _batchAuthorFilters(pubkeys, kinds, since, until) { const BATCH_SIZE = 100; const filters = []; for (let i = 0; i < pubkeys.length; i += BATCH_SIZE) { - filters.push({ + const filter = { kinds, authors: pubkeys.slice(i, i + BATCH_SIZE), since, - }); + }; + if (until !== undefined) filter.until = until; + filters.push(filter); } return filters; } @@ -780,6 +947,11 @@ export default class NostrDataService extends Service { this._contactPubkeys = null; this.blossomServers = []; + // Create a deferred that resolves when contacts are first loaded + this._contactsPromise = new Promise((resolve) => { + this._contactsResolver = resolve; + }); + this._cleanupSubscriptions(); // Setup models to track state reactively FIRST @@ -801,6 +973,9 @@ export default class NostrDataService extends Service { .subscribe((contacts) => { this.contacts = contacts; this._contactPubkeys = new Set(contacts.map((c) => c.pubkey)); + // Resolve the deferred so callers awaiting whenContactsLoaded proceed. + this._contactsResolver?.(); + this._contactsResolver = null; this._updatePlacePhotos(); // Re-trigger activity photo load now that contacts are available. // If no activity load has been requested yet, _activityPhotosSince @@ -876,6 +1051,23 @@ export default class NostrDataService extends Service { }); } + /** + * Returns a promise that resolves when contacts are first loaded, or + * immediately if already loaded. Times out after 5s so callers don't hang + * forever if contacts never arrive. + * + * @param {number} [timeout=5000] Timeout in milliseconds + * @returns {Promise} + */ + whenContactsLoaded(timeout = 5000) { + if (this._contactPubkeys) return Promise.resolve(); + if (!this._contactsPromise) return Promise.resolve(); + return Promise.race([ + this._contactsPromise, + new Promise((resolve) => setTimeout(resolve, timeout)), + ]); + } + get userDisplayName() { if (this.profile) { if (this.profile.nip05) { diff --git a/app/styles/app.css b/app/styles/app.css index 2409f0f..e69d4ed 100644 --- a/app/styles/app.css +++ b/app/styles/app.css @@ -2700,6 +2700,19 @@ button.create-place { margin: -1rem -1rem 0; } +.activity-load-more { + list-style: none; + text-align: center; + padding: 2rem 1rem 1rem; + min-height: 48px; +} + +.activity-load-more-spinner { + display: flex; + justify-content: center; + align-items: center; +} + .activity-item { width: 100%; text-align: left; @@ -2853,6 +2866,10 @@ button.create-place { white-space: nowrap; flex: 1 1 auto; min-width: 0; + + & .place-name-text { + animation: place-name-fade-in 0.15s ease-out; + } } & .activity-date { @@ -2862,3 +2879,13 @@ button.create-place { } } } + +@keyframes place-name-fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} diff --git a/app/templates/activity.gjs b/app/templates/activity.gjs index 6726728..b46abad 100644 --- a/app/templates/activity.gjs +++ b/app/templates/activity.gjs @@ -4,10 +4,12 @@ import ActivityTimeline from '#components/activity-timeline'; {{#if @controller.mapUi.isSidebarVisible}} {}; + this.isLoadingMore = false; + this.onLoadMore = () => {}; }); test('it renders a loading state', async function (assert) { @@ -30,6 +32,8 @@ module('Integration | Component | activity-timeline', function (hooks) { @onBack={{this.noop}} @onClose={{this.noop}} @onNostrConnected={{this.noop}} + @isLoadingMore={{this.isLoadingMore}} + @onLoadMore={{this.onLoadMore}} /> ); @@ -51,6 +55,8 @@ module('Integration | Component | activity-timeline', function (hooks) { @onBack={{this.noop}} @onClose={{this.noop}} @onNostrConnected={{this.noop}} + @isLoadingMore={{this.isLoadingMore}} + @onLoadMore={{this.onLoadMore}} /> ); @@ -72,6 +78,8 @@ module('Integration | Component | activity-timeline', function (hooks) { @onBack={{this.noop}} @onClose={{this.noop}} @onNostrConnected={{this.noop}} + @isLoadingMore={{this.isLoadingMore}} + @onLoadMore={{this.onLoadMore}} /> ); @@ -96,6 +104,8 @@ module('Integration | Component | activity-timeline', function (hooks) { @onBack={{this.noop}} @onClose={{this.noop}} @onNostrConnected={{this.noop}} + @isLoadingMore={{this.isLoadingMore}} + @onLoadMore={{this.onLoadMore}} /> ); @@ -151,6 +161,8 @@ module('Integration | Component | activity-timeline', function (hooks) { @onBack={{this.noop}} @onClose={{this.noop}} @onNostrConnected={{this.noop}} + @isLoadingMore={{this.isLoadingMore}} + @onLoadMore={{this.onLoadMore}} /> ); @@ -180,6 +192,8 @@ module('Integration | Component | activity-timeline', function (hooks) { @onBack={{this.handleBack}} @onClose={{this.noop}} @onNostrConnected={{this.noop}} + @isLoadingMore={{this.isLoadingMore}} + @onLoadMore={{this.onLoadMore}} /> ); @@ -201,6 +215,8 @@ module('Integration | Component | activity-timeline', function (hooks) { @onBack={{this.noop}} @onClose={{this.noop}} @onNostrConnected={{this.noop}} + @isLoadingMore={{this.isLoadingMore}} + @onLoadMore={{this.onLoadMore}} /> ); @@ -230,6 +246,8 @@ module('Integration | Component | activity-timeline', function (hooks) { @onBack={{this.noop}} @onClose={{this.noop}} @onNostrConnected={{this.noop}} + @isLoadingMore={{this.isLoadingMore}} + @onLoadMore={{this.onLoadMore}} /> ); @@ -275,12 +293,14 @@ module('Integration | Component | activity-timeline', function (hooks) { @onBack={{this.noop}} @onClose={{this.noop}} @onNostrConnected={{this.noop}} + @isLoadingMore={{this.isLoadingMore}} + @onLoadMore={{this.onLoadMore}} /> ); assert.dom('.activity-list').exists('items render in explore mode'); - assert.dom('.activity-list li').exists({ count: 1 }); + assert.dom('.activity-list .activity-item').exists({ count: 1 }); assert.dom('.empty-state').doesNotExist('no connect prompt in explore'); }); @@ -299,6 +319,8 @@ module('Integration | Component | activity-timeline', function (hooks) { @onBack={{this.noop}} @onClose={{this.noop}} @onNostrConnected={{this.noop}} + @isLoadingMore={{this.isLoadingMore}} + @onLoadMore={{this.onLoadMore}} /> ); @@ -306,4 +328,87 @@ module('Integration | Component | activity-timeline', function (hooks) { assert.dom('.empty-state').includesText('No activity yet.'); assert.dom('.empty-state').doesNotIncludeText('Connect'); }); + + test('load-more sentinel renders when items exist', async function (assert) { + this.items = [ + { + type: 'zap', + photoEventId: 'photo-1', + photo: { + url: 'https://x.com/photo.jpg', + thumbUrl: 'https://x.com/thumb.jpg', + }, + placeIdentifier: 'osm:node:111', + senderPubkey: 'b'.repeat(64), + amountSats: 21, + message: 'Nice!', + createdAt: 2000, + senderName: 'Alice', + senderAvatar: 'https://x.com/avatar.jpg', + senderProfileLoading: false, + }, + ]; + + await render( + + ); + + assert.dom('.activity-load-more').exists('sentinel renders'); + }); + + test('load-more spinner shows when isLoadingMore is true', async function (assert) { + this.items = [ + { + type: 'zap', + photoEventId: 'photo-1', + photo: { + url: 'https://x.com/photo.jpg', + thumbUrl: 'https://x.com/thumb.jpg', + }, + placeIdentifier: 'osm:node:111', + senderPubkey: 'b'.repeat(64), + amountSats: 21, + message: 'Nice!', + createdAt: 2000, + senderName: 'Alice', + senderAvatar: 'https://x.com/avatar.jpg', + senderProfileLoading: false, + }, + ]; + this.isLoadingMore = true; + + await render( + + ); + + assert.dom('.activity-load-more-spinner').exists('spinner shows'); + }); }); diff --git a/tests/integration/components/activity-zap-item-test.gjs b/tests/integration/components/activity-zap-item-test.gjs index e5254e3..09ff345 100644 --- a/tests/integration/components/activity-zap-item-test.gjs +++ b/tests/integration/components/activity-zap-item-test.gjs @@ -175,7 +175,7 @@ module('Integration | Component | activity-zap-item', function (hooks) { .doesNotExist('No loading/fallback text shown'); }); - test('it shows "Loading…" when placeNameLoading is true', async function (assert) { + test('it shows no text when placeNameLoading is true', async function (assert) { this.item = new ActivityEntry({ photoEventId: 'photo-1', photo: null, @@ -197,8 +197,11 @@ module('Integration | Component | activity-zap-item', function (hooks) { ); assert - .dom('.context-text .contribution-name-loading') - .hasText('Loading…', 'Loading state is displayed'); + .dom('.context-text .activity-place-name') + .hasText('', 'No text shown while loading'); + assert + .dom('.context-text .place-name-text') + .doesNotExist('No place-name-text shown while loading'); }); test('it shows "Unnamed place" when not loading and no placeName', async function (assert) { @@ -223,7 +226,7 @@ module('Integration | Component | activity-zap-item', function (hooks) { ); assert - .dom('.context-text .contribution-name-loading') + .dom('.context-text .place-name-text') .hasText('Unnamed place', 'Fallback name is displayed'); }); diff --git a/tests/unit/services/activity-test.js b/tests/unit/services/activity-test.js index 74d3d75..79c796b 100644 --- a/tests/unit/services/activity-test.js +++ b/tests/unit/services/activity-test.js @@ -124,6 +124,19 @@ class MockNostrDataService extends Service { loadProfiles() {} loadActivityPhotos() {} + loadMyContributions() {} + loadProfile() {} + whenContactsLoaded() { + return Promise.resolve(); + } + + async fetchActivityPhotos() { + return []; + } + + async fetchIncomingZaps() { + return []; + } getProfile(pubkey) { return this.profiles[pubkey]; @@ -481,12 +494,11 @@ module('Unit | Service | activity', function (hooks) { ); }); - test('setSourceMode re-filters existing events', function (assert) { + test('setSourceMode resets and re-filters with fresh 30-day window', async function (assert) { const service = this.owner.lookup('service:activity'); service._userPubkey = USER_PUBKEY; service._sourceMode = 'explore'; service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]); - // In explore mode, isTrustedEvent returns false (no provenance in mock) service.nostrData.isTrustedEvent = () => false; const followedPhoto = makePhotoEvent({ @@ -495,22 +507,21 @@ module('Unit | Service | activity', function (hooks) { placeIdentifier: 'osm:node:100', created_at: 1000, }); - service._lastSocialEvents = [followedPhoto]; - // In 'explore' mode, _matchesSourceMode returns false (isTrustedEvent is false) + service.nostrData.fetchActivityPhotos = async (_since, _until, mode) => { + if (mode === 'home') return [followedPhoto]; + return []; + }; + service._updateSocialItems([followedPhoto]); - assert.strictEqual( - service.items.length, - 0, - 'no social items in explore mode with untrusted events' - ); + assert.strictEqual(service.items.length, 0, 'no items in explore mode'); - // Switch to 'home' mode — now the followed photo should appear - service.setSourceMode('home'); - assert.strictEqual( - service.items.length, - 1, - 're-filtered shows followed contact in home mode' + await service.setSourceMode('home'); + + assert.strictEqual(service._sourceMode, 'home', 'mode switched'); + assert.ok( + service.items.length > 0, + 'items loaded after switching to home mode' ); }); @@ -534,4 +545,234 @@ module('Unit | Service | activity', function (hooks) { assert.strictEqual(service._socialItems.length, 0, 'social items cleared'); assert.strictEqual(service._zapItems.length, 0, 'zap items cleared'); }); + + test('loadMore fetches older events with until set to old since', async function (assert) { + const service = this.owner.lookup('service:activity'); + service._userPubkey = USER_PUBKEY; + service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]); + + const NOW = Math.floor(Date.now() / 1000); + service._since = NOW; + + const recentPhoto = makePhotoEvent({ + id: 'r1'.padEnd(64, '0'), + author: SENDER_PUBKEY, + placeIdentifier: 'osm:node:100', + created_at: NOW - 100, + }); + service._updateSocialItems([recentPhoto]); + assert.ok(service.items.length > 0, 'has initial items'); + + const oldPhoto = makePhotoEvent({ + id: 'old1'.padEnd(64, '0'), + author: SENDER_PUBKEY, + placeIdentifier: 'osm:node:200', + created_at: NOW - 5000, + }); + service.nostrData.fetchActivityPhotos = async (since, until) => { + assert.strictEqual(since, NOW - 30 * 24 * 60 * 60, 'since extended'); + assert.strictEqual(until, NOW, 'until set to old since'); + return [oldPhoto]; + }; + + await service.loadMore(); + + assert.strictEqual( + service._since, + NOW - 30 * 24 * 60 * 60, + '_since extended' + ); + assert.false(service.isLoadingMore, 'isLoadingMore reset'); + assert.false(service._isLoadingMore, '_isLoadingMore reset'); + assert.ok( + service.items.length >= 2, + 'new items appended without re-processing' + ); + }); + + test('loadMore fetches zaps alongside photos in home mode', async function (assert) { + const service = this.owner.lookup('service:activity'); + service._userPubkey = USER_PUBKEY; + service._sourceMode = 'home'; + service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]); + + const NOW = Math.floor(Date.now() / 1000); + service._since = NOW; + + // Seed initial photo so loadMore doesn't bail + const recentPhoto = makePhotoEvent({ + id: 'r5'.padEnd(64, '0'), + author: SENDER_PUBKEY, + placeIdentifier: 'osm:node:100', + created_at: NOW - 100, + }); + service._updateSocialItems([recentPhoto]); + + // Also seed a photo for the zap to reference + const zapPhoto = makePhotoEvent({ + id: PHOTO_EVENT_ID_1, + author: USER_PUBKEY, + placeIdentifier: 'osm:node:50', + created_at: NOW - 200, + }); + service.nostrData.store.add(zapPhoto); + + const oldZapReceipt = makeZapReceiptEvent({ + zappedEventId: PHOTO_EVENT_ID_1, + created_at: NOW - 5000, + }); + + let zapCallCount = 0; + service.nostrData.fetchActivityPhotos = async () => []; + service.nostrData.fetchIncomingZaps = async (_pubkey, since, until) => { + zapCallCount++; + assert.strictEqual(since, NOW - 30 * 24 * 60 * 60, 'zap since matches'); + assert.strictEqual(until, NOW, 'zap until matches'); + return [oldZapReceipt]; + }; + + await service.loadMore(); + + assert.strictEqual(zapCallCount, 1, 'fetchIncomingZaps called once'); + assert.ok(service.items.length >= 2, 'zap and photo items both present'); + }); + + test('loadMore exponentially expands window on empty results', async function (assert) { + const service = this.owner.lookup('service:activity'); + service._userPubkey = USER_PUBKEY; + service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]); + + const NOW = Math.floor(Date.now() / 1000); + service._since = NOW; + + const recentPhoto = makePhotoEvent({ + id: 'r2'.padEnd(64, '0'), + author: SENDER_PUBKEY, + placeIdentifier: 'osm:node:100', + created_at: NOW - 100, + }); + service._updateSocialItems([recentPhoto]); + + const callArgs = []; + let callCount = 0; + service.nostrData.fetchActivityPhotos = async (since, until) => { + callArgs.push({ since, until }); + callCount++; + if (callCount < 2) return []; + return [ + makePhotoEvent({ + id: 'old2'.padEnd(64, '0'), + author: SENDER_PUBKEY, + placeIdentifier: 'osm:node:300', + created_at: NOW - 100000, + }), + ]; + }; + + await service.loadMore(); + + assert.strictEqual(callCount, 2, 'retried with expanding window'); + assert.strictEqual(callArgs[0].since, NOW - 30 * 24 * 60 * 60); + assert.strictEqual( + callArgs[1].since, + NOW - 30 * 24 * 60 * 60 - 60 * 24 * 60 * 60 + ); + assert.ok(service.items.length >= 2, 'events from 2nd call appended'); + }); + + test('loadMore stops at MIN_SINCE floor with clamped final fetch', async function (assert) { + const service = this.owner.lookup('service:activity'); + service._userPubkey = USER_PUBKEY; + service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]); + + const MIN_SINCE = Math.floor(new Date('2026-04-20').getTime() / 1000); + service._since = MIN_SINCE + 60 * 24 * 60 * 60; + + const recentPhoto = makePhotoEvent({ + id: 'r3'.padEnd(64, '0'), + author: SENDER_PUBKEY, + placeIdentifier: 'osm:node:100', + created_at: service._since - 100, + }); + service._updateSocialItems([recentPhoto]); + + let callCount = 0; + service.nostrData.fetchActivityPhotos = async () => { + callCount++; + return []; + }; + + await service.loadMore(); + + // First 30-day window: batchSince = MIN_SINCE + 30d (>= MIN_SINCE, executes) + // Second 60-day window: batchSince = MIN_SINCE - 30d (< MIN_SINCE, clamped to MIN_SINCE for final fetch) + assert.strictEqual(callCount, 2, 'one normal + one clamped final fetch'); + assert.false(service.isLoadingMore, 'isLoadingMore reset after exhaustion'); + }); + + test('loadMore guard prevents concurrent calls', async function (assert) { + const service = this.owner.lookup('service:activity'); + service._userPubkey = USER_PUBKEY; + service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]); + + const NOW = Math.floor(Date.now() / 1000); + service._since = NOW; + + const recentPhoto = makePhotoEvent({ + id: 'r4'.padEnd(64, '0'), + author: SENDER_PUBKEY, + placeIdentifier: 'osm:node:100', + created_at: NOW - 100, + }); + service._updateSocialItems([recentPhoto]); + + let callCount = 0; + service.nostrData.fetchActivityPhotos = async () => { + callCount++; + return [ + makePhotoEvent({ + id: 'old3'.padEnd(64, '0'), + author: SENDER_PUBKEY, + placeIdentifier: 'osm:node:200', + created_at: NOW - 5000, + }), + ]; + }; + + const promise1 = service.loadMore(); + await service.loadMore(); + await promise1; + + assert.strictEqual(callCount, 1, 'only one loadMore sequence'); + }); + + test('loadMore does nothing when no items', async function (assert) { + const service = this.owner.lookup('service:activity'); + service._userPubkey = USER_PUBKEY; + service._since = Math.floor(Date.now() / 1000); + service.items = []; + + let called = false; + service.nostrData.fetchActivityPhotos = async () => { + called = true; + return []; + }; + + await service.loadMore(); + + assert.false(called, 'no fetch when items is empty'); + }); + + test('stop resets isLoadingMore and isLoading', function (assert) { + const service = this.owner.lookup('service:activity'); + service._isLoadingMore = true; + service.isLoadingMore = true; + service.isLoading = true; + + service.stop(); + + assert.false(service.isLoadingMore, 'isLoadingMore reset'); + assert.false(service._isLoadingMore, '_isLoadingMore reset'); + assert.false(service.isLoading, 'isLoading reset'); + }); }); diff --git a/tests/unit/services/nostr-data-test.js b/tests/unit/services/nostr-data-test.js index 6c5c8ae..0480df9 100644 --- a/tests/unit/services/nostr-data-test.js +++ b/tests/unit/services/nostr-data-test.js @@ -1,6 +1,6 @@ import { module, test } from 'qunit'; import { setupTest } from 'marco/tests/helpers'; -import { Subject, EMPTY } from 'rxjs'; +import { Subject, EMPTY, of } from 'rxjs'; import Service from '@ember/service'; import NostrDataService from 'marco/services/nostr-data'; import { getGeohashPrefixesInBbox } from 'marco/utils/geohash-coverage'; @@ -205,6 +205,58 @@ module('Unit | Service | nostr-data | contacts', function (hooks) { ); }); + test('whenContactsLoaded resolves after contacts are loaded', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + const pubkey = makePubkey(1); + const contactA = makePubkey(2); + + // Start loadProfile (sets up the deferred + subscriptions) + await service.loadProfile(pubkey); + + // Contacts not loaded yet — whenContactsLoaded should not resolve yet + // (it will resolve after timeout, but we'll add the event first) + + // Add a contacts event to the store — the ContactsModel subscription fires + service.store.add(makeContactsEvent(pubkey, [contactA])); + + // Give the model a tick to process + await new Promise((r) => setTimeout(r, 50)); + + await service.whenContactsLoaded(); + + assert.ok(service._contactPubkeys, 'contacts loaded'); + assert.true( + service._contactPubkeys.has(contactA), + 'contact pubkey present' + ); + }); + + test('whenContactsLoaded resolves immediately when contacts already loaded', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + const pubkey = makePubkey(1); + const contactA = makePubkey(2); + + service.store.add(makeContactsEvent(pubkey, [contactA])); + await service.loadProfile(pubkey); + await new Promise((r) => setTimeout(r, 50)); + + // Contacts are already loaded — whenContactsLoaded should resolve immediately + await service.whenContactsLoaded(); + + assert.true(service._contactPubkeys.has(contactA), 'contacts available'); + }); + + test('whenContactsLoaded resolves immediately when no profile loaded', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + // No loadProfile called — should resolve immediately + await service.whenContactsLoaded(); + + assert.true(true, 'resolved without hanging'); + }); + test('kind 3 events are persisted to IDB cache', async function (assert) { const service = this.owner.lookup('service:nostr-data'); @@ -977,6 +1029,190 @@ module('Unit | Service | nostr-data | activity photos', function (hooks) { assert.strictEqual(filters[2].authors.length, 50); assert.deepEqual(filters[0].kinds, [360]); assert.strictEqual(filters[0].since, 1000); + assert.notOk(filters[0].until, 'no until when not provided'); + }); + + test('_batchAuthorFilters includes until when provided', function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + const pubkeys = Array.from({ length: 5 }, (_, i) => makePubkey(i)); + const filters = service._batchAuthorFilters(pubkeys, [360], 1000, 2000); + + assert.strictEqual(filters.length, 1); + assert.strictEqual(filters[0].since, 1000); + assert.strictEqual(filters[0].until, 2000); + }); +}); + +module('Unit | Service | nostr-data | fetchActivityPhotos', function (hooks) { + setupNostrDataService(hooks); + + test('fetchActivityPhotos in home mode returns events from network', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + const userPubkey = makePubkey(1); + const contactPubkey = makePubkey(2); + const photo = makePhotoEvent(contactPubkey, 'osm:node:100', { + id: makeEventId(100), + }); + + service.store.add(makeContactsEvent(userPubkey, [contactPubkey])); + await service.loadProfile(userPubkey); + + service.nostrRelay.pool.req = (_relays, filters) => { + this.requestedFilters.push(...filters); + return of({ + type: 'EVENT', + event: photo, + from: 'wss://relay.example', + }); + }; + + const events = await service.fetchActivityPhotos(1000, undefined, 'home'); + + assert.ok( + events.some((e) => e.id === photo.id), + 'photo event from network returned' + ); + }); + + test('fetchActivityPhotos includes until in filters', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + const userPubkey = makePubkey(1); + const contactPubkey = makePubkey(2); + + service.store.add(makeContactsEvent(userPubkey, [contactPubkey])); + await service.loadProfile(userPubkey); + + service.nostrRelay.pool.req = (_relays, filters) => { + this.requestedFilters.push(...filters); + return EMPTY; + }; + + await service.fetchActivityPhotos(1000, 2000, 'home'); + + const photoFilter = this.requestedFilters.find( + (f) => f.kinds?.includes(360) && f.authors + ); + assert.ok(photoFilter, 'photo filter found'); + assert.strictEqual(photoFilter.since, 1000, 'since value matches'); + assert.strictEqual(photoFilter.until, 2000, 'until value matches'); + }); + + test('fetchActivityPhotos in explore mode does not include authors filter', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + service.nostrRelay.pool.req = (_relays, filters) => { + this.requestedFilters.push(...filters); + return EMPTY; + }; + + await service.fetchActivityPhotos(9999, undefined, 'explore'); + + const photoFilter = this.requestedFilters.find( + (f) => f.kinds?.includes(360) && !f.authors + ); + assert.ok(photoFilter, 'photo filter without authors found'); + assert.strictEqual(photoFilter.since, 9999); + assert.notOk(photoFilter.until, 'no until when not provided'); + }); + + test('fetchActivityPhotos in explore mode includes until', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + service.nostrRelay.pool.req = (_relays, filters) => { + this.requestedFilters.push(...filters); + return EMPTY; + }; + + await service.fetchActivityPhotos(1000, 2000, 'explore'); + + const photoFilter = this.requestedFilters.find( + (f) => f.kinds?.includes(360) && !f.authors + ); + assert.ok(photoFilter, 'photo filter found'); + assert.strictEqual(photoFilter.since, 1000); + assert.strictEqual(photoFilter.until, 2000); + }); + + test('fetchActivityPhotos returns empty array when no contacts in home mode', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + service.nostrRelay.pool.req = () => EMPTY; + + const events = await service.fetchActivityPhotos(1000, undefined, 'home'); + + assert.strictEqual(events.length, 0, 'empty array when no contacts'); + }); + + test('fetchActivityPhotos deduplicates cache and network events', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + const userPubkey = makePubkey(1); + const contactPubkey = makePubkey(2); + const photo = makePhotoEvent(contactPubkey, 'osm:node:100', { + id: makeEventId(100), + }); + + service.store.add(makeContactsEvent(userPubkey, [contactPubkey])); + await service.loadProfile(userPubkey); + + // Add to IDB cache + await service.cache.add(photo); + + service.nostrRelay.pool.req = (_relays, filters) => { + this.requestedFilters.push(...filters); + return of({ + type: 'EVENT', + event: photo, + from: 'wss://relay.example', + }); + }; + + const events = await service.fetchActivityPhotos(1000, undefined, 'home'); + + assert.strictEqual( + events.filter((e) => e.id === photo.id).length, + 1, + 'photo appears only once despite being in both cache and network' + ); + }); + + test('fetchIncomingZaps includes since and until in filter', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + service.nostrRelay.pool.req = (_relays, filters) => { + this.requestedFilters.push(...filters); + return EMPTY; + }; + + await service.fetchIncomingZaps(makePubkey(1), 1000, 2000); + + const zapFilter = this.requestedFilters.find((f) => + f.kinds?.includes(9735) + ); + assert.ok(zapFilter, 'zap filter found'); + assert.strictEqual(zapFilter.since, 1000, 'since value matches'); + assert.strictEqual(zapFilter.until, 2000, 'until value matches'); + }); + + test('fetchIncomingZaps without since/until does not include them', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + service.nostrRelay.pool.req = (_relays, filters) => { + this.requestedFilters.push(...filters); + return EMPTY; + }; + + await service.fetchIncomingZaps(makePubkey(1)); + + const zapFilter = this.requestedFilters.find((f) => + f.kinds?.includes(9735) + ); + assert.ok(zapFilter, 'zap filter found'); + assert.notOk('since' in zapFilter, 'no since when not provided'); + assert.notOk('until' in zapFilter, 'no until when not provided'); }); });