diff --git a/app/components/activity-photo-item.gjs b/app/components/activity-photo-item.gjs new file mode 100644 index 0000000..44579f1 --- /dev/null +++ b/app/components/activity-photo-item.gjs @@ -0,0 +1,132 @@ +import Component from '@glimmer/component'; +import { on } from '@ember/modifier'; +import { fn } from '@ember/helper'; +import formatRelativeDate from '../helpers/format-relative-date'; +import { npubEncode } from 'applesauce-core/helpers/pointers'; +import Icon from './icon'; + +export default class ActivityPhotoItem extends Component { + get item() { + return this.args.item; + } + + get senderName() { + return this.item?.senderName; + } + + get senderDisplayName() { + const name = this.senderName; + if (name) return name; + const pubkey = this.item?.senderPubkey; + if (!pubkey) return 'Someone'; + try { + return `${npubEncode(pubkey).slice(0, 12)}…`; + } catch { + return `${pubkey.slice(0, 12)}…`; + } + } + + get senderAvatar() { + return this.item?.senderAvatar; + } + + get hasPhoto() { + return this.photos.length > 0; + } + + get photos() { + const photos = this.item?.photos; + if (photos && photos.length > 0) return photos; + const single = this.item?.photo; + return single ? [single] : []; + } + + get primaryPhoto() { + return this.photos[0]; + } + + get extraPhotoCount() { + return Math.max(0, this.photos.length - 1); + } + + get photoCountText() { + const count = this.photos.length; + return count === 1 ? 'a photo' : `${count} photos`; + } + + get photoThumbUrl() { + return this.primaryPhoto?.thumbUrl || this.primaryPhoto?.url; + } + + get placeName() { + return this.item?.placeName; + } + + get placeNameLoading() { + return this.item?.placeNameLoading; + } + + +} diff --git a/app/components/activity-timeline.gjs b/app/components/activity-timeline.gjs index 2cc6985..cc0497b 100644 --- a/app/components/activity-timeline.gjs +++ b/app/components/activity-timeline.gjs @@ -3,16 +3,25 @@ import { action } from '@ember/object'; import { tracked } from '@glimmer/tracking'; import { on } from '@ember/modifier'; import Icon from './icon'; +import TabNav from './tab-nav'; import ActivityZapItem from './activity-zap-item'; +import ActivityPhotoItem from './activity-photo-item'; import Modal from './modal'; import NostrConnect from './nostr-connect'; 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; + tabs = [ + { label: 'Home', value: 'home' }, + { label: 'Explore', value: 'explore' }, + ]; + @action openNostrConnectModal(event) { event.preventDefault(); @@ -47,12 +56,18 @@ export default class ActivityTimelineComponent extends Component { + + diff --git a/app/components/activity-zap-item.gjs b/app/components/activity-zap-item.gjs index e404bf5..410471d 100644 --- a/app/components/activity-zap-item.gjs +++ b/app/components/activity-zap-item.gjs @@ -50,54 +50,54 @@ export default class ActivityZapItem extends Component {
  • diff --git a/app/components/contributions-timeline.gjs b/app/components/contributions-timeline.gjs index 72e5ef0..b4af801 100644 --- a/app/components/contributions-timeline.gjs +++ b/app/components/contributions-timeline.gjs @@ -38,7 +38,7 @@ export default class ContributionsTimelineComponent extends Component { diff --git a/app/components/tab-nav.gjs b/app/components/tab-nav.gjs new file mode 100644 index 0000000..6254fa9 --- /dev/null +++ b/app/components/tab-nav.gjs @@ -0,0 +1,17 @@ +import { on } from '@ember/modifier'; +import { fn } from '@ember/helper'; +import eq from 'ember-truth-helpers/helpers/eq'; + + diff --git a/app/controllers/activity.js b/app/controllers/activity.js index 7a9e2ff..3d44623 100644 --- a/app/controllers/activity.js +++ b/app/controllers/activity.js @@ -10,7 +10,6 @@ export default class ActivityController extends Controller { @service activity; loadTask = task({ restartable: true }, async (pubkey) => { - if (!pubkey) return; await this.activity.load(pubkey); }); @@ -22,10 +21,32 @@ export default class ActivityController extends Controller { return this.activity.items; } + get isLoading() { + return this.activity.isLoading; + } + get isConnected() { return this.nostrAuth.isConnected; } + get sourceMode() { + 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 7d16d67..5e7b50f 100644 --- a/app/services/activity.js +++ b/app/services/activity.js @@ -2,7 +2,15 @@ import Service, { service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; import { ProfileModel } from 'applesauce-core/models/profile'; import { getProfileContent } from 'applesauce-core/helpers/profile'; -import { parseZapReceipt, enrichWithPhoto } from '../utils/activity'; +import { + parseZapReceipt, + enrichWithPhoto, + groupSocialPhotos, +} 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 @@ -10,8 +18,7 @@ import { parseZapReceipt, enrichWithPhoto } from '../utils/activity'; * 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` @@ -25,45 +32,117 @@ export default class ActivityService extends Service { @service placeNameResolver; @tracked items = []; + @tracked sourceMode = 'home'; + @tracked isLoading = false; + @tracked isLoadingMore = false; - _sub = null; _profileSubs = new Map(); _userPubkey = null; + _zapItems = []; + _socialItems = []; + _lastSocialEvents = []; + _since = null; + _sourceMode = 'home'; + _isLoadingMore = false; + _cacheReadyCallback = null; /** - * Loads the user's incoming zap receipts and subscribes to live updates. + * Loads the user's incoming zap receipts and social photo activity. * - * @param {string} pubkey The user's Nostr pubkey + * 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 (!pubkey) { - this.items = []; - return; + if (this.items.length > 0) return; + this._userPubkey = pubkey || null; + this._since = Math.floor(Date.now() / 1000) - SINCE_WINDOW; + this.isLoading = true; + this._cacheReadyCallback = () => { + this.isLoading = false; + }; + + try { + if (pubkey) { + await this.nostrData.loadMyContributions(pubkey); + await this.nostrData.loadProfile(pubkey); + await this.nostrData.whenContactsLoaded(); + } + + this._socialItems = []; + this._zapItems = []; + this._lastSocialEvents = []; + this._mergeItems(); + + const now = Math.floor(Date.now() / 1000); + await this._fetchWithBackoff(this._since, now); + } finally { + this.isLoading = false; + this._cacheReadyCallback = null; } + } - this._userPubkey = pubkey; + /** + * Switches the activity source mode (e.g. 'home' for followee photos, + * '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 + */ + async setSourceMode(mode) { + if (mode === this._sourceMode) return; + this._sourceMode = mode; + this.sourceMode = mode; - const filters = [{ kinds: [9735], '#p': [pubkey] }]; + this._since = Math.floor(Date.now() / 1000) - SINCE_WINDOW; + this._socialItems = []; + this._zapItems = []; + this._lastSocialEvents = []; + this._mergeItems(); - console.debug('[activity] Subscribing to zap receipts', { - filters, - pubkey, - activeReadRelays: this.nostrData.activeReadRelays, - }); + this.isLoading = true; + this._cacheReadyCallback = () => { + this.isLoading = false; + }; - // Subscribe to the store timeline so we get live updates as receipts - // arrive and are added to the store. - this._sub = this.nostrData.store.timeline(filters).subscribe((events) => { - this._updateItems(events, pubkey); - }); + try { + const now = Math.floor(Date.now() / 1000); + await this._fetchWithBackoff(this._since, now); + } finally { + this.isLoading = false; + this._cacheReadyCallback = null; + } + } - // 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); + /** + * 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; - // 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); + try { + const startSince = this._since - SINCE_WINDOW; + await this._fetchWithBackoff(startSince, this._since); + } finally { + this._isLoadingMore = false; + this.isLoadingMore = false; + } } /** @@ -71,13 +150,16 @@ export default class ActivityService extends Service { * activity route. */ stop() { - if (this._sub) { - this._sub.unsubscribe(); - this._sub = null; - } this._cleanupProfileSubs(); + this._zapItems = []; + this._socialItems = []; + this._lastSocialEvents = []; this.items = []; this._userPubkey = null; + this._since = null; + this._isLoadingMore = false; + this.isLoadingMore = false; + this.isLoading = false; this.placeNameResolver.reset(); } @@ -86,35 +168,182 @@ export default class ActivityService extends Service { super.willDestroy(...arguments); } - _updateItems(receipts, pubkey) { + /** + * Processes photo events into social items and appends them to the timeline. + * + * @param {object[]} events Photo events to process + * @returns {boolean} True if any items were added + */ + _processPhotoEvents(events) { + if (events.length === 0) return false; + + this._lastSocialEvents = [...this._lastSocialEvents, ...events]; + + const filtered = events.filter((e) => this._matchesSourceMode(e)); + if (filtered.length === 0) return false; + + 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()); + + return true; + } + + /** + * Processes zap receipt events and appends them to the timeline. + * + * @param {object[]} events Zap receipt events to process + * @returns {boolean} True if any items were added + */ + _processZapEventsIntoTimeline(events) { + if (events.length === 0) return false; + + const newZapEntries = this._processZapEvents(events, this._userPubkey); + if (newZapEntries.length === 0) return false; + + this._zapItems = [...this._zapItems, ...newZapEntries]; + this._mergeItems(); + + void this.placeNameResolver + .resolveInBackground(newZapEntries) + .then(() => this._mergeItems()); + + return true; + } + + /** + * Fetches photos and zaps for a single time window, processes them, and + * appends to the respective item arrays. Cache events are processed first + * (firing `_cacheReadyCallback` to dismiss the loading spinner), then + * network events are awaited and processed. + * + * @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 photoResult = await this.nostrData.fetchActivityPhotos( + batchSince, + batchUntil, + this._sourceMode + ); + + let zapResult = { + cacheEvents: [], + networkEvents: Promise.resolve([]), + }; + if (this._sourceMode === 'home' && this._userPubkey) { + zapResult = await this.nostrData.fetchIncomingZaps( + this._userPubkey, + batchSince, + batchUntil + ); + } + + // Phase 1: Process cache events immediately + let added = false; + + if (photoResult.cacheEvents.length > 0) { + added = this._processPhotoEvents(photoResult.cacheEvents) || added; + } + if (zapResult.cacheEvents.length > 0) { + added = + this._processZapEventsIntoTimeline(zapResult.cacheEvents) || added; + } + + // Signal that cached items are ready for display + if (added) { + this._cacheReadyCallback?.(); + } + + // Phase 2: Await network events and process them + const [networkPhotos, networkZaps] = await Promise.all([ + photoResult.networkEvents, + zapResult.networkEvents, + ]); + + if (networkPhotos.length > 0) { + added = this._processPhotoEvents(networkPhotos) || added; + } + if (networkZaps.length > 0) { + added = this._processZapEventsIntoTimeline(networkZaps) || added; + } + + 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, - }); - - // 2. Bookmark lookup is synchronous — 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( @@ -127,23 +356,91 @@ export default class ActivityService extends Service { } } - this.items = entries; + return entries; + } + + _updateZapItems(receipts, pubkey) { + const entries = this._processZapEvents(receipts, pubkey); + + this._zapItems = entries; + this._mergeItems(); - // 3. 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.items = [...this.items]; + this._mergeItems(); }); } + _updateSocialItems(events) { + const filtered = events.filter((e) => this._matchesSourceMode(e)); + const entries = groupSocialPhotos(filtered); + + for (const entry of entries) { + this._resolveSender(entry); + } + + for (const entry of entries) { + if (entry.osmId) { + const bookmarkName = this.placeNameResolver.resolveBookmark( + entry.osmId + ); + if (bookmarkName) { + entry.placeName = bookmarkName; + entry.placeNameLoading = false; + } + } + } + + this._socialItems = entries; + this._mergeItems(); + + void this.placeNameResolver.resolveInBackground(entries).then(() => { + this._mergeItems(); + }); + } + + _mergeItems() { + const all = + this._sourceMode === 'explore' + ? [...this._socialItems] + : [...this._zapItems, ...this._socialItems]; + all.sort((a, b) => b.createdAt - a.createdAt); + this.items = all; + } + + /** + * Returns true if an event should be included in the activity feed given + * the current source mode. + * + * - `'home'`: only events from followees (excluding own photos) + * - `'explore'`: events from non-followed authors trusted by relay provenance + * (excluding own photos). When no pubkey is connected, all trusted events + * pass (no own-photo or followee exclusion). + */ + _matchesSourceMode(event) { + if (this._sourceMode === 'home') { + return ( + this._userPubkey && + event.pubkey !== this._userPubkey && + this.nostrData._contactPubkeys?.has(event.pubkey) + ); + } + if (this._sourceMode === 'explore') { + if (!this._userPubkey) { + return this.nostrData.isTrustedEvent(event); + } + return ( + event.pubkey !== this._userPubkey && + !this.nostrData._contactPubkeys?.has(event.pubkey) && + this.nostrData.isTrustedEvent(event) + ); + } + return false; + } + _resolveSender(entry) { 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) @@ -153,17 +450,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) { @@ -179,7 +471,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) { @@ -192,7 +483,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 c20fbbd..a911d64 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. @@ -97,6 +102,12 @@ export default class NostrDataService extends Service { _lastPhotoIds = new Set(); _incomingZapsNetworkSub = null; + // Activity photos state: tracks the current time window and source mode + // so that the contacts callback can re-trigger when contacts arrive. + _activityPhotosSince = null; + _activityPhotosMode = 'home'; + _activityPhotosNetworkSub = null; + _requestSub = null; _cachePromise = null; _currentPlaceEntityId = null; @@ -600,6 +611,312 @@ export default class NostrDataService extends Service { ); } + /** + * Loads kind 360 (Place Photo) events for the activity feed, scoped to the + * given time window and source mode. + * + * - `'home'` mode: fetches photos authored by the user's followed contacts + * (followees). If contacts haven't loaded yet (`_contactPubkeys` is null or + * empty), this returns early and is re-triggered automatically by the + * `ContactsModel` subscription callback when contacts arrive. + * - `'explore'` mode: fetches all kind 360 photos in the time window from + * trusted relays (no authors filter). Provenance is captured so + * `isTrustedEvent` can filter at presentation time. + * + * @param {number} since Unix timestamp (seconds) for the start of the window + * @param {string} [mode='home'] Source mode + */ + async loadActivityPhotos(since, mode = 'home') { + this._activityPhotosSince = since; + this._activityPhotosMode = mode; + + if (this._activityPhotosNetworkSub) { + this._activityPhotosNetworkSub.unsubscribe(); + this._activityPhotosNetworkSub = null; + } + + if (mode === 'home') { + this._loadFolloweePhotos(since); + } else if (mode === 'explore') { + this._loadTrustedRelaysPhotos(since); + } + } + + /** + * Fetches kind 360 photos from the user's followees (followed contacts), + * batched by author pubkey (≤100 per filter to stay under relay REQ limits). + * Defers if contacts haven't loaded yet. + */ + _loadFolloweePhotos(since) { + const pubkeys = this._contactPubkeys + ? Array.from(this._contactPubkeys) + : []; + + if (pubkeys.length === 0) { + console.debug( + '[nostr-data] No contacts loaded yet, deferring followee photo load' + ); + return; + } + + const filters = this._batchAuthorFilters(pubkeys, [360], since); + + console.debug('[nostr-data] Loading followee photos', { + filterCount: filters.length, + pubkeyCount: pubkeys.length, + since, + }); + + // 1. Populate the store from the local Nostr IDB cache (instant) + 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 followee photos from local Nostr IDB cache', + e + ); + }); + + // 2. Request fresh events from the network (captures provenance for trust) + this._activityPhotosNetworkSub = this._requestContentWithProvenance( + this.activeReadRelays, + filters, + '[nostr-data] Error fetching followee photos:' + ); + } + + /** + * Fetches all kind 360 photos in the time window from trusted relays (no + * authors filter). Provenance is captured so `isTrustedEvent` can filter + * at presentation time. Fires immediately — no contacts dependency. + */ + _loadTrustedRelaysPhotos(since) { + const filters = [{ kinds: [360], since }]; + + console.debug('[nostr-data] Loading trusted relay photos', { since }); + + // 1. Populate the store from the local Nostr IDB cache (instant) + 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 trusted relay photos from local Nostr IDB cache', + e + ); + }); + + // 2. Request fresh events from the network (captures provenance for trust) + this._activityPhotosNetworkSub = this._requestContentWithProvenance( + this.activeReadRelays, + filters, + '[nostr-data] Error fetching trusted relay photos:' + ); + } + + /** + * 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. + * + * Returns `{ cacheEvents, networkEvents }` so the caller can show cached + * items instantly while the network request resolves on EOSE. + * + * - `'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<{cacheEvents: object[], networkEvents: Promise}>} + */ + async fetchActivityPhotos(since, until, mode = 'home') { + let filters; + if (mode === 'home') { + const pubkeys = this._contactPubkeys + ? Array.from(this._contactPubkeys) + : []; + if (pubkeys.length === 0) + return { cacheEvents: [], networkEvents: Promise.resolve([]) }; + 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 { cacheEvents: [], networkEvents: Promise.resolve([]) }; + } + + // 1. Populate the store from the local Nostr IDB cache (instant) + const seen = new Set(); + const cacheResult = []; + + 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); + cacheResult.push(event); + this.store.add(event); + } + } + } + + // 2. Request fresh events from the network (resolves on EOSE) + const networkEvents = this._fetchEventsWithProvenance( + filters, + '[nostr-data] Error fetching activity photos:' + ).then((events) => { + const result = []; + for (const event of events) { + if (!seen.has(event.id)) { + seen.add(event.id); + result.push(event); + } + } + return result; + }); + + return { cacheEvents: cacheResult, networkEvents }; + } + + /** + * Fetches incoming zap receipts (kind 9735) where the user is the recipient. + * + * Returns `{ cacheEvents, networkEvents }` so the caller can show cached + * items instantly while the network request resolves on EOSE. + * + * @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<{cacheEvents: object[], networkEvents: Promise}>} + */ + async fetchIncomingZaps(pubkey, since, until) { + if (!pubkey) return { cacheEvents: [], networkEvents: Promise.resolve([]) }; + + 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 cacheResult = []; + + // 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); + cacheResult.push(event); + this.store.add(event); + } + } + } + + // 2. Network (resolves on EOSE) + const networkEvents = this._fetchEventsWithProvenance( + filters, + '[nostr-data] Error fetching incoming zap receipts:' + ).then((events) => { + const result = []; + for (const event of events) { + if (!seen.has(event.id)) { + seen.add(event.id); + result.push(event); + } + } + return result; + }); + + return { cacheEvents: cacheResult, networkEvents }; + } + + /** + * Builds an array of Nostr filters, each with ≤ `BATCH_SIZE` author pubkeys. + * + * @param {string[]} pubkeys Author pubkeys to batch + * @param {number[]} kinds Event kinds to request + * @param {number} since Unix timestamp (seconds) for the start of the window + * @returns {object[]} Array of filter objects + */ + _batchAuthorFilters(pubkeys, kinds, since, until) { + const BATCH_SIZE = 100; + const filters = []; + for (let i = 0; i < pubkeys.length; i += BATCH_SIZE) { + const filter = { + kinds, + authors: pubkeys.slice(i, i + BATCH_SIZE), + since, + }; + if (until !== undefined) filter.until = until; + filters.push(filter); + } + return filters; + } + loadProfiles(pubkeys) { const newPubkeys = pubkeys.filter( (pk) => pk && !this._profileModelSubs.has(pk) @@ -637,6 +954,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 @@ -658,7 +980,19 @@ 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 + // is null and the call is a no-op. + if (this._activityPhotosSince !== null) { + this.loadActivityPhotos( + this._activityPhotosSince, + this._activityPhotosMode + ); + } }); this._blossomSub = this.store @@ -724,6 +1058,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) { @@ -896,6 +1247,10 @@ export default class NostrDataService extends Service { this._incomingZapsNetworkSub.unsubscribe(); this._incomingZapsNetworkSub = null; } + if (this._activityPhotosNetworkSub) { + this._activityPhotosNetworkSub.unsubscribe(); + this._activityPhotosNetworkSub = null; + } } willDestroy() { diff --git a/app/services/place-name-resolver.js b/app/services/place-name-resolver.js index b6dfe79..221e926 100644 --- a/app/services/place-name-resolver.js +++ b/app/services/place-name-resolver.js @@ -56,11 +56,9 @@ export default class PlaceNameResolverService extends Service { */ async resolveInBackground(entries) { const pending = entries.filter((e) => e.placeNameLoading); - if (pending.length === 0) { - this._maybeBatchFetchNames(entries); - return; - } + if (pending.length === 0) return; + // Phase 1: Cache lookup (localForage → OSM IDB cache) await Promise.all( pending.map(async (entry) => { const name = await this._resolveCachedName(entry); @@ -71,20 +69,6 @@ export default class PlaceNameResolverService extends Service { }) ); - // Trigger re-render via caller, then kick off the network batch for - // entries that are still loading. - this._maybeBatchFetchNames(entries); - } - - _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)) { @@ -93,52 +77,67 @@ export default class PlaceNameResolverService extends Service { } } - if (unresolved.length === 0) { - return; - } + const unresolved = entries.filter( + (e) => e.placeNameLoading && !this._isUnresolvable(e) + ); + if (unresolved.length === 0) return; - // Build a stable signature so we only fire one batch request per unique set + // Phase 2: Batch OSM fetch (awaited, deduplicated by signature) + try { + const nameMap = await this._getOrCreateBatch(unresolved); + for (const entry of entries) { + if (!entry.placeNameLoading) continue; + if (nameMap.has(entry.placeIdentifier)) { + entry.placeName = nameMap.get(entry.placeIdentifier); + entry.placeNameLoading = false; + } else { + this._unresolvable.add(entry.placeIdentifier); + entry.placeName = this._fallbackName(entry); + entry.placeNameLoading = false; + } + } + } catch (e) { + console.error('[place-name-resolver] Batch resolution failed', e); + for (const entry of entries) { + if (entry.placeNameLoading) { + this._unresolvable.add(entry.placeIdentifier); + entry.placeName = this._fallbackName(entry); + entry.placeNameLoading = false; + } + } + } + } + + _isUnresolvable(entry) { + return this._unresolvable.has(entry.placeIdentifier); + } + + /** + * Returns a promise for the batch OSM fetch, deduplicating calls that have + * the same set of place identifiers. If a batch with the same signature is + * already in flight, returns that promise instead of firing a new one. + * + * @param {Array} unresolved Entries that still need a network fetch + * @returns {Promise>} Map of placeIdentifier → name + */ + _getOrCreateBatch(unresolved) { 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 entries - for (const entry of entries) { - if (!entry.placeNameLoading) continue; - if (nameMap.has(entry.placeIdentifier)) { - const name = nameMap.get(entry.placeIdentifier); - entry.placeName = name; - entry.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(entry.placeIdentifier); - entry.placeName = this._fallbackName(entry); - entry.placeNameLoading = false; - } - } - }) - .catch((e) => { - console.error('[place-name-resolver] Batch name resolution failed', e); - // On a total failure, mark all as unresolvable and apply fallbacks - for (const entry of entries) { - if (entry.placeNameLoading) { - this._unresolvable.add(entry.placeIdentifier); - entry.placeName = this._fallbackName(entry); - entry.placeNameLoading = false; - } - } - }) - .finally(() => { + if (signature === this._lastBatchSignature && this._pendingBatchPromise) { + return this._pendingBatchPromise; + } + + this._lastBatchSignature = signature; + this._pendingBatchPromise = this._batchResolveNames(unresolved).finally( + () => { this._pendingBatchPromise = null; - }); + } + ); + + return this._pendingBatchPromise; } _fallbackName(entry) { diff --git a/app/styles/app.css b/app/styles/app.css index 654a673..e69d4ed 100644 --- a/app/styles/app.css +++ b/app/styles/app.css @@ -2417,6 +2417,38 @@ button.create-place { padding: 4rem 1rem; } +/* Tab Navigation */ +.tab-nav { + display: flex; + border-bottom: 1px solid var(--divider-color); + height: 48px; + flex-shrink: 0; +} + +.tab-nav-button { + flex: 1; + border: none; + background: none; + font-size: 0.9rem; + cursor: pointer; + color: var(--secondary-text-color); + font-family: inherit; + border-bottom: 2px solid transparent; + transition: + border-color 0.15s, + color 0.15s; +} + +.tab-nav-button:hover { + color: var(--body-text-color); +} + +.tab-nav-button.is-active { + border-bottom-color: var(--link-color); + font-weight: 600; + color: var(--body-text-color); +} + /* Contributions Timeline */ .contributions-list { list-style: none; @@ -2661,14 +2693,27 @@ button.create-place { } } -/* Activity Timeline — zap activity list rendered in the sidebar */ +/* Activity Timeline — activity list rendered in the sidebar */ .activity-list { list-style: none; padding: 0; margin: -1rem -1rem 0; } -.activity-zap-item { +.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; border: none; @@ -2687,7 +2732,7 @@ button.create-place { background: var(--hover-bg); } - & .zap-sender-avatar { + & .sender-avatar { flex-shrink: 0; width: 32px; height: 32px; @@ -2696,7 +2741,7 @@ button.create-place { background: #f0f0f0; } - & .zap-sender-avatar-placeholder { + & .sender-avatar-placeholder { flex-shrink: 0; width: 32px; height: 32px; @@ -2708,39 +2753,42 @@ button.create-place { color: #999; } - & .zap-header { + & .header { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; } - & .zap-sender-line { + & .sender-line { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.95rem; - & .zap-sender-name { + & .sender-name { font-weight: bold; } - & .zap-action { + & .action { color: #666; font-weight: normal; } } - & .zap-amount { + & .amount { flex-shrink: 0; font-weight: bold; font-size: 0.95rem; color: var(--body-text-color); white-space: nowrap; + align-self: center; + display: flex; + align-items: center; } - & .zap-message { + & .message { color: var(--body-text-color); font-size: 0.85rem; font-style: italic; @@ -2762,7 +2810,7 @@ button.create-place { } } - & .zap-context { + & .context { display: flex; align-items: center; gap: 6px; @@ -2770,19 +2818,20 @@ button.create-place { font-size: 0.8rem; margin-top: 8px; - & .zap-context-images { + & .context-images { display: flex; align-items: center; gap: 0.5rem; } - & .zap-context-thumb { + & .context-thumb { flex-shrink: 0; width: 32px; height: 32px; border-radius: 4px; overflow: hidden; background: #f0f0f0; + position: relative; & img { width: 100%; @@ -2790,27 +2839,53 @@ button.create-place { object-fit: cover; display: block; } + + & .context-thumb-badge { + position: absolute; + bottom: 2px; + right: 2px; + background: rgb(0 0 0 / 65%); + color: #fff; + font-size: 0.7rem; + font-weight: bold; + padding: 1px 4px; + border-radius: 4px; + } } - & .zap-context-text { + & .context-text { display: flex; align-items: baseline; gap: 6px; flex: 1 1 auto; min-width: 0; - & .zap-place-name { + & .activity-place-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1 1 auto; min-width: 0; + + & .place-name-text { + animation: place-name-fade-in 0.15s ease-out; + } } - & .zap-date { + & .activity-date { flex-shrink: 0; white-space: nowrap; } } } } + +@keyframes place-name-fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} diff --git a/app/templates/activity.gjs b/app/templates/activity.gjs index 4b9342b..b46abad 100644 --- a/app/templates/activity.gjs +++ b/app/templates/activity.gjs @@ -4,8 +4,12 @@ import ActivityTimeline from '#components/activity-timeline'; {{#if @controller.mapUi.isSidebarVisible}} a.createdAt - b.createdAt); + + const createdAt = events.reduce( + (max, e) => (e.created_at > max ? e.created_at : max), + 0 + ); + + const placeIdentifier = + events[0].tags?.find((t) => t[0] === 'i')?.[1] || null; + + let osmType; + let osmId; + if (placeIdentifier) { + const parts = placeIdentifier.split(':'); + osmType = parts[1]; + osmId = parts[2]; + } + + const entry = new ActivityEntry({ + photo: photos[0] || null, + photos, + placeIdentifier, + osmType, + osmId, + senderPubkey: events[0].pubkey, + createdAt, + }); + entry.type = 'photo'; + return entry; +} + +/** + * Groups kind 360 (Place Photo) events from followed contacts into activity + * entries, keyed by (author + OSM entity + time proximity). + * + * This mirrors the time-proximity grouping logic from `groupPhotoContributions` + * in `utils/contributions.js` but adds author as a grouping dimension so that + * "Alice shared 3 photos to Central Park Café" is one entry. + * + * @param {Array} events Mixed kind 360 / kind 5 events from followed contacts + * @param {number} [thresholdHours=3] Max gap in hours within a sub-group + * @returns {Array} Sorted `ActivityEntry` entries (newest first) + */ +export function groupSocialPhotos(events, thresholdHours = 3) { + if (!events || events.length === 0) return []; + + const photoEvents = applyDeletions(events); + if (photoEvents.length === 0) return []; + + // Group by (author pubkey + OSM entity identifier) + const byAuthorEntity = new Map(); + for (const event of photoEvents) { + const entityTag = (event.tags || []).find((t) => t[0] === 'i'); + const entityId = entityTag?.[1]; + if (!entityId) continue; + + const key = `${event.pubkey}:${entityId}`; + if (!byAuthorEntity.has(key)) byAuthorEntity.set(key, []); + byAuthorEntity.get(key).push(event); + } + + const thresholdSeconds = thresholdHours * HOUR_IN_SECONDS; + const entries = []; + + for (const [, groupEvents] of byAuthorEntity) { + // Sort newest-first within the group + const sorted = [...groupEvents].sort((a, b) => b.created_at - a.created_at); + + // Walk newest -> oldest, starting a new sub-group when the gap to the + // previous event exceeds the threshold. + let currentGroup = []; + let prevTime = null; + + for (const event of sorted) { + if (prevTime !== null && prevTime - event.created_at > thresholdSeconds) { + entries.push(buildSocialEntry(currentGroup)); + currentGroup = []; + } + currentGroup.push(event); + prevTime = event.created_at; + } + if (currentGroup.length > 0) { + entries.push(buildSocialEntry(currentGroup)); + } + } + + // Sort entries by their newest event's created_at, descending. Entries with + // no usable photos (e.g. malformed imeta) are filtered out. + return entries + .filter((e) => e.photos.length > 0) + .sort((a, b) => b.createdAt - a.createdAt); +} diff --git a/app/utils/contributions.js b/app/utils/contributions.js index 7abb674..128da33 100644 --- a/app/utils/contributions.js +++ b/app/utils/contributions.js @@ -120,7 +120,7 @@ export function parsePhotoFromEvent(event) { * @param {Array} events Mixed kind 360 and kind 5 events * @returns {Array} Surviving kind 360 events */ -function applyDeletions(events) { +export function applyDeletions(events) { const deletedIds = new Set(); for (const event of events) { if (event.kind === 5) { diff --git a/tests/acceptance/activity-test.js b/tests/acceptance/activity-test.js index 4500822..0c39f8e 100644 --- a/tests/acceptance/activity-test.js +++ b/tests/acceptance/activity-test.js @@ -23,9 +23,16 @@ class MockActivityService extends Service { senderProfileLoading: false, }, ]; + @tracked sourceMode = 'home'; + @tracked isLoadingMore = false; + @tracked isLoading = false; async load() {} + async setSourceMode() {} + + loadMore() {} + stop() { this.items = []; } @@ -97,12 +104,12 @@ module('Acceptance | activity', function (hooks) { test('activity items are rendered as zap rows', async function (assert) { await visit('/activity'); - await waitFor('.activity-zap-item'); - assert.dom('.activity-zap-item').exists({ count: 1 }); - assert.dom('.zap-sender-name').hasText('Alice'); - assert.dom('.zap-action').includesText('zapped your photo'); - assert.dom('.zap-amount').includesText('21 ⚡'); - assert.dom('.zap-message').includesText('Great photo!'); + await waitFor('.activity-item'); + assert.dom('.activity-item').exists({ count: 1 }); + assert.dom('.sender-name').hasText('Alice'); + assert.dom('.action').includesText('zapped your photo'); + assert.dom('.amount').includesText('21 ⚡'); + assert.dom('.message').includesText('Great photo!'); }); test('closing the sidebar returns to index', async function (assert) { @@ -124,9 +131,9 @@ module('Acceptance | activity', function (hooks) { const mapUi = this.owner.lookup('service:map-ui'); await visit('/activity'); - await waitFor('.activity-zap-item'); + await waitFor('.activity-item'); - await click('.activity-zap-item'); + await click('.activity-item'); assert.ok( currentURL().includes('/place/osm:node:123'), diff --git a/tests/integration/components/activity-timeline-test.gjs b/tests/integration/components/activity-timeline-test.gjs index 3b47ed3..ff0dace 100644 --- a/tests/integration/components/activity-timeline-test.gjs +++ b/tests/integration/components/activity-timeline-test.gjs @@ -13,6 +13,10 @@ module('Integration | Component | activity-timeline', function (hooks) { hooks.beforeEach(function () { this.noop = noop; this.emptyItems = []; + this.sourceMode = 'home'; + this.onSetSourceMode = () => {}; + this.isLoadingMore = false; + this.onLoadMore = () => {}; }); test('it renders a loading state', async function (assert) { @@ -22,10 +26,14 @@ module('Integration | Component | activity-timeline', function (hooks) { @items={{this.emptyItems}} @isLoading={{true}} @isConnected={{true}} + @sourceMode={{this.sourceMode}} + @onSetSourceMode={{this.onSetSourceMode}} @onSelect={{this.noop}} @onBack={{this.noop}} @onClose={{this.noop}} @onNostrConnected={{this.noop}} + @isLoadingMore={{this.isLoadingMore}} + @onLoadMore={{this.onLoadMore}} /> ); @@ -41,10 +49,14 @@ module('Integration | Component | activity-timeline', function (hooks) { @items={{this.emptyItems}} @isLoading={{false}} @isConnected={{false}} + @sourceMode={{this.sourceMode}} + @onSetSourceMode={{this.onSetSourceMode}} @onSelect={{this.noop}} @onBack={{this.noop}} @onClose={{this.noop}} @onNostrConnected={{this.noop}} + @isLoadingMore={{this.isLoadingMore}} + @onLoadMore={{this.onLoadMore}} /> ); @@ -60,10 +72,14 @@ module('Integration | Component | activity-timeline', function (hooks) { @items={{this.emptyItems}} @isLoading={{false}} @isConnected={{false}} + @sourceMode={{this.sourceMode}} + @onSetSourceMode={{this.onSetSourceMode}} @onSelect={{this.noop}} @onBack={{this.noop}} @onClose={{this.noop}} @onNostrConnected={{this.noop}} + @isLoadingMore={{this.isLoadingMore}} + @onLoadMore={{this.onLoadMore}} /> ); @@ -82,10 +98,14 @@ module('Integration | Component | activity-timeline', function (hooks) { @items={{this.emptyItems}} @isLoading={{false}} @isConnected={{true}} + @sourceMode={{this.sourceMode}} + @onSetSourceMode={{this.onSetSourceMode}} @onSelect={{this.noop}} @onBack={{this.noop}} @onClose={{this.noop}} @onNostrConnected={{this.noop}} + @isLoadingMore={{this.isLoadingMore}} + @onLoadMore={{this.onLoadMore}} /> ); @@ -135,15 +155,19 @@ module('Integration | Component | activity-timeline', function (hooks) { @items={{this.items}} @isLoading={{false}} @isConnected={{true}} + @sourceMode={{this.sourceMode}} + @onSetSourceMode={{this.onSetSourceMode}} @onSelect={{this.noop}} @onBack={{this.noop}} @onClose={{this.noop}} @onNostrConnected={{this.noop}} + @isLoadingMore={{this.isLoadingMore}} + @onLoadMore={{this.onLoadMore}} /> ); - assert.dom('.activity-zap-item').exists({ count: 2 }); + assert.dom('.activity-item').exists({ count: 2 }); assert.dom(this.element).includesText('Alice'); assert.dom(this.element).includesText('21 ⚡'); assert.dom(this.element).includesText('Bob'); @@ -162,10 +186,14 @@ module('Integration | Component | activity-timeline', function (hooks) { @items={{this.emptyItems}} @isLoading={{false}} @isConnected={{true}} + @sourceMode={{this.sourceMode}} + @onSetSourceMode={{this.onSetSourceMode}} @onSelect={{this.noop}} @onBack={{this.handleBack}} @onClose={{this.noop}} @onNostrConnected={{this.noop}} + @isLoadingMore={{this.isLoadingMore}} + @onLoadMore={{this.onLoadMore}} /> ); @@ -173,4 +201,214 @@ module('Integration | Component | activity-timeline', function (hooks) { await click('.sidebar-header .back-btn'); assert.true(backClicked); }); + + test('it renders tab nav with Home and Explore tabs', async function (assert) { + await render( + + ); + + assert.dom('.tab-nav').exists(); + assert.dom('.tab-nav-button').exists({ count: 2 }); + const buttons = this.element.querySelectorAll('.tab-nav-button'); + assert.strictEqual(buttons[0].textContent.trim(), 'Home'); + assert.strictEqual(buttons[1].textContent.trim(), 'Explore'); + }); + + test('clicking a tab fires @onSetSourceMode with the tab value', async function (assert) { + let selectedMode = null; + this.handleSetSourceMode = (mode) => { + selectedMode = mode; + }; + + await render( + + ); + + const buttons = this.element.querySelectorAll('.tab-nav-button'); + await click(buttons[1]); + + assert.strictEqual( + selectedMode, + 'explore', + 'onSetSourceMode called with explore' + ); + }); + + test('explore mode shows items when not connected', async function (assert) { + this.items = [ + { + type: 'photo', + photoEventId: 'photo-1', + photo: { + url: 'https://x.com/photo.jpg', + thumbUrl: 'https://x.com/thumb.jpg', + }, + placeIdentifier: 'osm:node:111', + authorPubkey: 'b'.repeat(64), + createdAt: 2000, + authorName: 'Alice', + authorAvatar: 'https://x.com/avatar.jpg', + authorProfileLoading: false, + }, + ]; + this.sourceMode = 'explore'; + + await render( + + ); + + assert.dom('.activity-list').exists('items render in explore mode'); + assert.dom('.activity-list .activity-item').exists({ count: 1 }); + assert.dom('.empty-state').doesNotExist('no connect prompt in explore'); + }); + + test('explore mode shows empty state when not connected and no items', async function (assert) { + this.sourceMode = 'explore'; + + await render( + + ); + + 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 bf42b0d..09ff345 100644 --- a/tests/integration/components/activity-zap-item-test.gjs +++ b/tests/integration/components/activity-zap-item-test.gjs @@ -36,15 +36,15 @@ module('Integration | Component | activity-zap-item', function (hooks) { ); - assert.dom('.zap-sender-name').hasText('Alice'); - assert.dom('.zap-action').includesText('zapped your photo'); - assert.dom('.zap-amount').hasText('21 ⚡'); - assert.dom('.zap-message').includesText('Great shot!'); + assert.dom('.sender-name').hasText('Alice'); + assert.dom('.action').includesText('zapped your photo'); + assert.dom('.amount').hasText('21 ⚡'); + assert.dom('.message').includesText('Great shot!'); assert - .dom('.zap-sender-avatar') + .dom('.sender-avatar') .hasAttribute('src', 'https://x.com/avatar.jpg'); assert - .dom('.zap-context-thumb img') + .dom('.context-thumb img') .hasAttribute('src', 'https://x.com/thumb.jpg'); }); @@ -68,8 +68,8 @@ module('Integration | Component | activity-zap-item', function (hooks) { ); - assert.dom('.zap-sender-avatar-placeholder').exists(); - assert.dom('.zap-sender-avatar').doesNotExist(); + assert.dom('.sender-avatar-placeholder').exists(); + assert.dom('.sender-avatar').doesNotExist(); }); test('it omits the message line when there is no message', async function (assert) { @@ -91,7 +91,7 @@ module('Integration | Component | activity-zap-item', function (hooks) { ); - assert.dom('.zap-message').doesNotExist(); + assert.dom('.message').doesNotExist(); }); test('it omits the context thumbnail when there is no photo', async function (assert) { @@ -113,8 +113,8 @@ module('Integration | Component | activity-zap-item', function (hooks) { ); - assert.dom('.zap-context-thumb').doesNotExist(); - assert.dom('.zap-context-text').exists(); + assert.dom('.context-thumb').doesNotExist(); + assert.dom('.context-text').exists(); }); test('clicking the item fires @onSelect with the item', async function (assert) { @@ -141,7 +141,7 @@ module('Integration | Component | activity-zap-item', function (hooks) { ); - await click('.activity-zap-item'); + await click('.activity-item'); assert.strictEqual(selected, this.item); }); @@ -168,14 +168,14 @@ module('Integration | Component | activity-zap-item', function (hooks) { ); assert - .dom('.zap-context-text') + .dom('.context-text') .includesText('Café Central', 'Resolved place name is displayed'); assert .dom('.contribution-name-loading') .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('.zap-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,11 +226,11 @@ module('Integration | Component | activity-zap-item', function (hooks) { ); assert - .dom('.zap-context-text .contribution-name-loading') + .dom('.context-text .place-name-text') .hasText('Unnamed place', 'Fallback name is displayed'); }); - test('it displays place name in zap-place-name and date in zap-date', async function (assert) { + test('it displays place name in activity-place-name and date in activity-date', async function (assert) { this.item = new ActivityEntry({ photoEventId: 'photo-1', photo: null, @@ -248,14 +251,14 @@ module('Integration | Component | activity-zap-item', function (hooks) { ); - const placeNameEl = this.element.querySelector('.zap-place-name'); - const dateEl = this.element.querySelector('.zap-date'); + const placeNameEl = this.element.querySelector('.activity-place-name'); + const dateEl = this.element.querySelector('.activity-date'); - assert.ok(placeNameEl, 'zap-place-name element exists'); - assert.ok(dateEl, 'zap-date element exists'); + assert.ok(placeNameEl, 'activity-place-name element exists'); + assert.ok(dateEl, 'activity-date element exists'); assert.ok( placeNameEl.textContent.includes('Café Central'), - 'Place name is in zap-place-name' + 'Place name is in activity-place-name' ); assert.ok(dateEl.textContent.includes('hr ago'), 'Date contains hr ago'); assert.notOk( @@ -290,7 +293,9 @@ module('Integration | Component | activity-zap-item', function (hooks) { ); - assert.dom('.zap-place-name').exists('zap-place-name element exists'); - assert.dom('.zap-date').hasText('1 hr ago', 'Date is still visible'); + assert + .dom('.activity-place-name') + .exists('activity-place-name element exists'); + assert.dom('.activity-date').hasText('1 hr ago', 'Date is still visible'); }); }); diff --git a/tests/integration/components/search-box-test.gjs b/tests/integration/components/search-box-test.gjs index fb24b5e..c9a0cc7 100644 --- a/tests/integration/components/search-box-test.gjs +++ b/tests/integration/components/search-box-test.gjs @@ -394,6 +394,7 @@ module('Integration | Component | search-box', function (hooks) { ); // Type "aw" (2 characters) + await focus('.search-input'); await fillIn('.search-input', 'aw'); await waitFor('.search-results-popover', { timeout: 2000 }); diff --git a/tests/integration/components/tab-nav-test.gjs b/tests/integration/components/tab-nav-test.gjs new file mode 100644 index 0000000..0970f71 --- /dev/null +++ b/tests/integration/components/tab-nav-test.gjs @@ -0,0 +1,87 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'marco/tests/helpers'; +import { render, click } from '@ember/test-helpers'; +import TabNav from 'marco/components/tab-nav'; + +module('Integration | Component | tab-nav', function (hooks) { + setupRenderingTest(hooks); + + test('it renders all tab buttons with labels', async function (assert) { + this.tabs = [ + { label: 'Home', value: 'home' }, + { label: 'Explore', value: 'explore' }, + ]; + this.active = 'home'; + this.onChange = () => {}; + + await render( + + ); + + assert.dom('.tab-nav-button').exists({ count: 2 }); + const buttons = this.element.querySelectorAll('.tab-nav-button'); + assert.strictEqual(buttons[0].textContent.trim(), 'Home'); + assert.strictEqual(buttons[1].textContent.trim(), 'Explore'); + }); + + test('it applies is-active class to the active tab', async function (assert) { + this.tabs = [ + { label: 'Home', value: 'home' }, + { label: 'Explore', value: 'explore' }, + ]; + this.active = 'explore'; + this.onChange = () => {}; + + await render( + + ); + + const buttons = this.element.querySelectorAll('.tab-nav-button'); + assert.false( + buttons[0].classList.contains('is-active'), + 'first tab is not active' + ); + assert.true( + buttons[1].classList.contains('is-active'), + 'second tab is active' + ); + }); + + test('clicking a tab fires onChange with the tab value', async function (assert) { + this.tabs = [ + { label: 'Home', value: 'home' }, + { label: 'Explore', value: 'explore' }, + ]; + this.active = 'home'; + this.handleChange = (value) => { + this.active = value; + }; + + await render( + + ); + + const buttons = this.element.querySelectorAll('.tab-nav-button'); + await click(buttons[1]); + + assert.strictEqual(this.active, 'explore', 'onChange called with explore'); + }); +}); diff --git a/tests/unit/services/activity-test.js b/tests/unit/services/activity-test.js index b3f47cd..cf4e40d 100644 --- a/tests/unit/services/activity-test.js +++ b/tests/unit/services/activity-test.js @@ -10,6 +10,16 @@ import { setVerifyWrappedEventMethod(fakeVerifyEvent); +class MockPlaceNameResolver extends Service { + resolveBookmark() { + return null; + } + resolveInBackground() { + return new Promise(() => {}); + } + reset() {} +} + const USER_PUBKEY = 'a'.repeat(64); const SENDER_PUBKEY = 'b'.repeat(64); @@ -64,7 +74,7 @@ function makePhotoEvent(opts = {}) { id: opts.id || PHOTO_EVENT_ID_1, pubkey: opts.author || USER_PUBKEY, kind: 360, - created_at: 5000, + created_at: opts.created_at || 5000, tags: [ ['i', opts.placeIdentifier || 'osm:node:123'], ['imeta', `url ${opts.url || 'https://x.com/photo.jpg'}`, 'dim 800x600'], @@ -76,6 +86,7 @@ function makePhotoEvent(opts = {}) { class MockNostrDataService extends Service { @tracked profiles = {}; + _contactPubkeys = new Set(); store = { events: new Map(), @@ -112,6 +123,20 @@ class MockNostrDataService extends Service { }; loadProfiles() {} + loadActivityPhotos() {} + loadMyContributions() {} + loadProfile() {} + whenContactsLoaded() { + return Promise.resolve(); + } + + async fetchActivityPhotos() { + return { cacheEvents: [], networkEvents: Promise.resolve([]) }; + } + + async fetchIncomingZaps() { + return { cacheEvents: [], networkEvents: Promise.resolve([]) }; + } getProfile(pubkey) { return this.profiles[pubkey]; @@ -125,9 +150,10 @@ module('Unit | Service | activity', function (hooks) { hooks.beforeEach(function () { this.owner.register('service:nostrData', MockNostrDataService); + this.owner.register('service:placeNameResolver', MockPlaceNameResolver); }); - test('_updateItems parses receipts and enriches with photos from the store', function (assert) { + test('_updateZapItems parses receipts and enriches with photos from the store', function (assert) { const service = this.owner.lookup('service:activity'); const photoEvent = makePhotoEvent({ id: PHOTO_EVENT_ID_1, @@ -140,7 +166,7 @@ module('Unit | Service | activity', function (hooks) { message: 'Love it!', }); - service._updateItems([receipt], USER_PUBKEY); + service._updateZapItems([receipt], USER_PUBKEY); assert.strictEqual(service.items.length, 1); assert.strictEqual(service.items[0].type, 'zap'); @@ -151,7 +177,7 @@ module('Unit | Service | activity', function (hooks) { assert.ok(service.items[0].photo, 'photo is populated'); }); - test('_updateItems filters out zaps for non-photo events', function (assert) { + test('_updateZapItems filters out zaps for non-photo events', function (assert) { const service = this.owner.lookup('service:activity'); service.nostrData.store.add({ id: TEXT_EVENT_ID, @@ -163,12 +189,12 @@ module('Unit | Service | activity', function (hooks) { const receipt = makeZapReceiptEvent({ zappedEventId: TEXT_EVENT_ID }); - service._updateItems([receipt], USER_PUBKEY); + service._updateZapItems([receipt], USER_PUBKEY); assert.strictEqual(service.items.length, 0, 'non-photo zap filtered out'); }); - test('_updateItems filters out zaps for photos not authored by the user', function (assert) { + test('_updateZapItems filters out zaps for photos not authored by the user', function (assert) { const service = this.owner.lookup('service:activity'); const otherUserPhoto = makePhotoEvent({ id: PHOTO_EVENT_ID_OTHER, @@ -180,7 +206,7 @@ module('Unit | Service | activity', function (hooks) { zappedEventId: PHOTO_EVENT_ID_OTHER, }); - service._updateItems([receipt], USER_PUBKEY); + service._updateZapItems([receipt], USER_PUBKEY); assert.strictEqual( service.items.length, @@ -189,7 +215,7 @@ module('Unit | Service | activity', function (hooks) { ); }); - test('_updateItems filters out zaps not directed at the user', function (assert) { + test('_updateZapItems filters out zaps not directed at the user', function (assert) { const service = this.owner.lookup('service:activity'); const photoEvent = makePhotoEvent({ id: PHOTO_EVENT_ID_1 }); service.nostrData.store.add(photoEvent); @@ -199,7 +225,7 @@ module('Unit | Service | activity', function (hooks) { zappedEventId: PHOTO_EVENT_ID_1, }); - service._updateItems([receipt], USER_PUBKEY); + service._updateZapItems([receipt], USER_PUBKEY); assert.strictEqual( service.items.length, @@ -208,7 +234,7 @@ module('Unit | Service | activity', function (hooks) { ); }); - test('_updateItems sorts entries newest-first', function (assert) { + test('_updateZapItems sorts entries newest-first', function (assert) { const service = this.owner.lookup('service:activity'); service.nostrData.store.add(makePhotoEvent({ id: PHOTO_EVENT_ID_1 })); service.nostrData.store.add(makePhotoEvent({ id: PHOTO_EVENT_ID_2 })); @@ -224,7 +250,7 @@ module('Unit | Service | activity', function (hooks) { created_at: 9000, }); - service._updateItems([oldReceipt, newReceipt], USER_PUBKEY); + service._updateZapItems([oldReceipt, newReceipt], USER_PUBKEY); assert.strictEqual(service.items.length, 2); assert.strictEqual(service.items[0].createdAt, 9000, 'newest first'); @@ -234,7 +260,7 @@ module('Unit | Service | activity', function (hooks) { test('stop clears items and resets state', function (assert) { const service = this.owner.lookup('service:activity'); service.nostrData.store.add(makePhotoEvent({ id: PHOTO_EVENT_ID_1 })); - service._updateItems( + service._updateZapItems( [makeZapReceiptEvent({ zappedEventId: PHOTO_EVENT_ID_1 })], USER_PUBKEY ); @@ -247,13 +273,17 @@ module('Unit | Service | activity', function (hooks) { assert.strictEqual(service._userPubkey, null); }); - test('load with no pubkey clears items', async function (assert) { + test('load with no pubkey preserves existing items', async function (assert) { const service = this.owner.lookup('service:activity'); service.items = [{ fake: true }]; await service.load(null); - assert.strictEqual(service.items.length, 0); + assert.strictEqual( + service.items.length, + 1, + 'items preserved when already loaded' + ); }); test('_resolveSender applies profile when available', function (assert) { @@ -270,4 +300,503 @@ module('Unit | Service | activity', function (hooks) { assert.strictEqual(entry.senderAvatar, 'https://x.com/avatar.jpg'); assert.false(entry.senderProfileLoading); }); + + test('_updateSocialItems groups photos by author + place', function (assert) { + const service = this.owner.lookup('service:activity'); + service._userPubkey = USER_PUBKEY; + service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]); + + const photo1 = makePhotoEvent({ + id: 'p1'.padEnd(64, '0'), + author: SENDER_PUBKEY, + placeIdentifier: 'osm:node:100', + created_at: 1000, + }); + const photo2 = makePhotoEvent({ + id: 'p2'.padEnd(64, '0'), + author: SENDER_PUBKEY, + placeIdentifier: 'osm:node:100', + created_at: 2000, + }); + + service._updateSocialItems([photo1, photo2]); + + assert.strictEqual( + service.items.length, + 1, + 'one entry for same author + place' + ); + assert.strictEqual(service.items[0].type, 'photo'); + assert.strictEqual(service.items[0].photos.length, 2); + assert.strictEqual(service.items[0].senderPubkey, SENDER_PUBKEY); + }); + + test('_matchesSourceMode filters by contact pubkeys in home mode', function (assert) { + const service = this.owner.lookup('service:activity'); + service._userPubkey = USER_PUBKEY; + service._sourceMode = 'home'; + service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]); + + const followedPhoto = makePhotoEvent({ author: SENDER_PUBKEY }); + const unfollowedPhoto = makePhotoEvent({ author: 'z'.repeat(64) }); + const ownPhoto = makePhotoEvent({ author: USER_PUBKEY }); + + assert.true( + service._matchesSourceMode(followedPhoto), + 'followed contact passes' + ); + assert.false( + service._matchesSourceMode(unfollowedPhoto), + 'unfollowed pubkey rejected' + ); + assert.false(service._matchesSourceMode(ownPhoto), 'own photo excluded'); + }); + + test('_matchesSourceMode in explore mode includes trusted strangers, excludes own photos and followees', function (assert) { + const service = this.owner.lookup('service:activity'); + service._userPubkey = USER_PUBKEY; + service._sourceMode = 'explore'; + service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]); + + const STRANGER_PUBKEY = 'c'.repeat(64); + + // Mock isTrustedEvent to return true for SENDER_PUBKEY and STRANGER_PUBKEY + service.nostrData.isTrustedEvent = (event) => + event.pubkey === SENDER_PUBKEY || event.pubkey === STRANGER_PUBKEY; + + const trustedStrangerPhoto = makePhotoEvent({ author: STRANGER_PUBKEY }); + const trustedFolloweePhoto = makePhotoEvent({ author: SENDER_PUBKEY }); + const untrustedPhoto = makePhotoEvent({ author: 'z'.repeat(64) }); + const ownPhoto = makePhotoEvent({ author: USER_PUBKEY }); + + assert.true( + service._matchesSourceMode(trustedStrangerPhoto), + 'trusted stranger passes in explore mode' + ); + assert.false( + service._matchesSourceMode(trustedFolloweePhoto), + 'trusted followee excluded in explore mode' + ); + assert.false( + service._matchesSourceMode(untrustedPhoto), + 'untrusted event rejected in explore mode' + ); + assert.false( + service._matchesSourceMode(ownPhoto), + 'own photo excluded in explore mode' + ); + }); + + test('_matchesSourceMode in explore mode with no pubkey includes all trusted events', function (assert) { + const service = this.owner.lookup('service:activity'); + service._userPubkey = null; + service._sourceMode = 'explore'; + service.nostrData._contactPubkeys = null; + + const STRANGER_PUBKEY = 'c'.repeat(64); + + service.nostrData.isTrustedEvent = (event) => + event.pubkey === SENDER_PUBKEY || event.pubkey === STRANGER_PUBKEY; + + const trustedPhoto = makePhotoEvent({ author: SENDER_PUBKEY }); + const trustedStrangerPhoto = makePhotoEvent({ author: STRANGER_PUBKEY }); + const untrustedPhoto = makePhotoEvent({ author: 'z'.repeat(64) }); + + assert.true( + service._matchesSourceMode(trustedPhoto), + 'trusted event passes with no pubkey' + ); + assert.true( + service._matchesSourceMode(trustedStrangerPhoto), + 'trusted stranger passes with no pubkey' + ); + assert.false( + service._matchesSourceMode(untrustedPhoto), + 'untrusted event rejected with no pubkey' + ); + }); + + test('_updateSocialItems merges with zap items sorted by createdAt', function (assert) { + const service = this.owner.lookup('service:activity'); + service._userPubkey = USER_PUBKEY; + service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]); + + // Seed a zap item + service.nostrData.store.add( + makePhotoEvent({ id: PHOTO_EVENT_ID_1, placeIdentifier: 'osm:node:50' }) + ); + service._updateZapItems( + [ + makeZapReceiptEvent({ + zappedEventId: PHOTO_EVENT_ID_1, + created_at: 5000, + }), + ], + USER_PUBKEY + ); + + // Add a social photo that is newer + const socialPhoto = makePhotoEvent({ + id: 'sp1'.padEnd(64, '0'), + author: SENDER_PUBKEY, + placeIdentifier: 'osm:node:100', + created_at: 9000, + }); + service._updateSocialItems([socialPhoto]); + + assert.strictEqual(service.items.length, 2); + assert.strictEqual( + service.items[0].createdAt, + 9000, + 'newer social photo first' + ); + assert.strictEqual(service.items[1].createdAt, 5000, 'older zap second'); + }); + + test('explore mode excludes zap items from merged results', function (assert) { + const service = this.owner.lookup('service:activity'); + service._userPubkey = USER_PUBKEY; + service._sourceMode = 'explore'; + service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]); + + // Seed a zap item + service.nostrData.store.add( + makePhotoEvent({ id: PHOTO_EVENT_ID_1, placeIdentifier: 'osm:node:50' }) + ); + service._updateZapItems( + [ + makeZapReceiptEvent({ + zappedEventId: PHOTO_EVENT_ID_1, + created_at: 5000, + }), + ], + USER_PUBKEY + ); + + // Add a trusted stranger social photo + const STRANGER_PUBKEY = 'c'.repeat(64); + service.nostrData.isTrustedEvent = (event) => + event.pubkey === STRANGER_PUBKEY; + + const socialPhoto = makePhotoEvent({ + id: 'sp1'.padEnd(64, '0'), + author: STRANGER_PUBKEY, + placeIdentifier: 'osm:node:100', + created_at: 9000, + }); + service._updateSocialItems([socialPhoto]); + + assert.strictEqual( + service.items.length, + 1, + 'zap excluded in explore mode, only social photo shown' + ); + assert.strictEqual( + service.items[0].createdAt, + 9000, + 'only the social photo is present' + ); + }); + + 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]); + service.nostrData.isTrustedEvent = () => false; + + const followedPhoto = makePhotoEvent({ + id: 'fp1'.padEnd(64, '0'), + author: SENDER_PUBKEY, + placeIdentifier: 'osm:node:100', + created_at: 1000, + }); + + service.nostrData.fetchActivityPhotos = async (_since, _until, mode) => { + if (mode === 'home') + return { + cacheEvents: [followedPhoto], + networkEvents: Promise.resolve([]), + }; + return { cacheEvents: [], networkEvents: Promise.resolve([]) }; + }; + + service._updateSocialItems([followedPhoto]); + assert.strictEqual(service.items.length, 0, 'no items in explore 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' + ); + }); + + test('stop clears social items and resets state', function (assert) { + const service = this.owner.lookup('service:activity'); + service._userPubkey = USER_PUBKEY; + service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]); + + const photo = makePhotoEvent({ + id: 'sp2'.padEnd(64, '0'), + author: SENDER_PUBKEY, + placeIdentifier: 'osm:node:100', + }); + service._updateSocialItems([photo]); + + assert.strictEqual(service.items.length, 1); + + service.stop(); + + assert.strictEqual(service.items.length, 0, 'items cleared'); + 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 { + cacheEvents: [oldPhoto], + networkEvents: Promise.resolve([]), + }; + }; + + 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 () => ({ + cacheEvents: [], + networkEvents: Promise.resolve([]), + }); + 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 { + cacheEvents: [oldZapReceipt], + networkEvents: Promise.resolve([]), + }; + }; + + 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 { cacheEvents: [], networkEvents: Promise.resolve([]) }; + return { + cacheEvents: [ + makePhotoEvent({ + id: 'old2'.padEnd(64, '0'), + author: SENDER_PUBKEY, + placeIdentifier: 'osm:node:300', + created_at: NOW - 100000, + }), + ], + networkEvents: Promise.resolve([]), + }; + }; + + 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 { cacheEvents: [], networkEvents: Promise.resolve([]) }; + }; + + 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 { + cacheEvents: [ + makePhotoEvent({ + id: 'old3'.padEnd(64, '0'), + author: SENDER_PUBKEY, + placeIdentifier: 'osm:node:200', + created_at: NOW - 5000, + }), + ], + networkEvents: Promise.resolve([]), + }; + }; + + 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 { cacheEvents: [], networkEvents: Promise.resolve([]) }; + }; + + 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 d1e1786..04a458d 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'); @@ -856,6 +908,331 @@ module('Unit | Service | nostr-data | my contributions', function (hooks) { }); }); +module('Unit | Service | nostr-data | activity photos', function (hooks) { + setupNostrDataService(hooks); + + test('loadActivityPhotos defers when contacts are not loaded yet', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + // _contactPubkeys is null initially (no loadProfile called) + assert.strictEqual(service._contactPubkeys, null); + + await service.loadActivityPhotos(1000, 'home'); + + // No network request should have been made + const photoFilters = this.requestedFilters.filter( + (f) => f.kinds?.includes(360) && f.authors + ); + assert.strictEqual(photoFilters.length, 0, 'no request without contacts'); + }); + + test('loadActivityPhotos batches >100 pubkeys into multiple filters', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + const userPubkey = makePubkey(1); + // Create 250 contact pubkeys + const contactPubkeys = Array.from({ length: 250 }, (_, i) => + makePubkey(100 + i) + ); + + // Load contacts so _contactPubkeys is populated + service.store.add(makeContactsEvent(userPubkey, contactPubkeys)); + await service.loadProfile(userPubkey); + + await service.loadActivityPhotos(1000, 'home'); + + const photoFilters = this.requestedFilters.filter( + (f) => f.kinds?.includes(360) && f.authors && f.since !== undefined + ); + assert.strictEqual( + photoFilters.length, + 3, + '250 pubkeys → 3 filters (100+100+50)' + ); + assert.strictEqual(photoFilters[0].authors.length, 100); + assert.strictEqual(photoFilters[1].authors.length, 100); + assert.strictEqual(photoFilters[2].authors.length, 50); + }); + + test('loadActivityPhotos includes since in filter', 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); + + const since = 12345; + await service.loadActivityPhotos(since, 'home'); + + const photoFilter = this.requestedFilters.find( + (f) => f.kinds?.includes(360) && f.authors && f.since !== undefined + ); + assert.ok(photoFilter, 'photo filter with since found'); + assert.strictEqual(photoFilter.since, since, 'since value matches'); + assert.deepEqual(photoFilter.kinds, [360], 'requests kind 360'); + }); + + test('loadActivityPhotos re-triggers when contacts arrive', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + const userPubkey = makePubkey(1); + const contactPubkey = makePubkey(2); + + // Call loadActivityPhotos before contacts are loaded + await service.loadActivityPhotos(1000, 'home'); + + const beforeCount = this.requestedFilters.filter( + (f) => f.kinds?.includes(360) && f.authors && f.since !== undefined + ).length; + assert.strictEqual(beforeCount, 0, 'no request before contacts'); + + // Now load contacts — the ContactsModel callback should re-trigger + service.store.add(makeContactsEvent(userPubkey, [contactPubkey])); + await service.loadProfile(userPubkey); + + // Give the callback a tick to propagate + await new Promise((r) => setTimeout(r, 50)); + + const afterCount = this.requestedFilters.filter( + (f) => f.kinds?.includes(360) && f.authors && f.since !== undefined + ).length; + assert.ok(afterCount > 0, 'request made after contacts arrived'); + }); + + test("loadActivityPhotos in 'explore' mode fetches all photos without authors filter", async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + const since = 9999; + await service.loadActivityPhotos(since, 'explore'); + + // Should make a request immediately (no contacts dependency) + const photoFilter = this.requestedFilters.find( + (f) => f.kinds?.includes(360) && f.since !== undefined && !f.authors + ); + assert.ok(photoFilter, 'photo filter without authors found'); + assert.strictEqual(photoFilter.since, since, 'since value matches'); + assert.deepEqual(photoFilter.kinds, [360], 'requests kind 360'); + assert.notOk(photoFilter.authors, 'no authors filter in explore mode'); + }); + + test('_batchAuthorFilters produces correct batches', function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + const pubkeys = Array.from({ length: 250 }, (_, i) => makePubkey(i)); + const filters = service._batchAuthorFilters(pubkeys, [360], 1000); + + assert.strictEqual(filters.length, 3, '250 → 3 filters'); + assert.strictEqual(filters[0].authors.length, 100); + assert.strictEqual(filters[1].authors.length, 100); + 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 result = await service.fetchActivityPhotos(1000, undefined, 'home'); + const networkEvents = await result.networkEvents; + + assert.ok( + networkEvents.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 result = await service.fetchActivityPhotos(1000, undefined, 'home'); + + assert.strictEqual( + result.cacheEvents.length, + 0, + 'empty cache events when no contacts' + ); + const networkEvents = await result.networkEvents; + assert.strictEqual( + networkEvents.length, + 0, + 'empty network events 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 result = await service.fetchActivityPhotos(1000, undefined, 'home'); + const networkEvents = await result.networkEvents; + + assert.strictEqual( + result.cacheEvents.filter((e) => e.id === photo.id).length, + 1, + 'photo appears once in cache events' + ); + assert.strictEqual( + networkEvents.filter((e) => e.id === photo.id).length, + 0, + 'photo not in network events (deduplicated against cache)' + ); + }); + + 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'); + }); +}); + module('Unit | Service | nostr-data | zap receipts', function (hooks) { setupNostrDataService(hooks); diff --git a/tests/unit/services/place-name-resolver-test.js b/tests/unit/services/place-name-resolver-test.js index 1174a9f..d472d0a 100644 --- a/tests/unit/services/place-name-resolver-test.js +++ b/tests/unit/services/place-name-resolver-test.js @@ -207,4 +207,70 @@ module('Unit | Service | place-name-resolver', function (hooks) { assert.strictEqual(resolver._lastBatchSignature, ''); assert.strictEqual(resolver._pendingBatchPromise, null); }); + + test('deduplicated batch resolves entries from a second call with same place identifiers', async function (assert) { + const resolver = this.owner.lookup('service:place-name-resolver'); + const localForage = this.owner.lookup('service:localForage'); + + let fetchCount = 0; + resolver.osm.fetchOsmObjectsBatch = async () => { + fetchCount++; + const map = new Map(); + map.set('node:222', { title: 'Shared Place' }); + return map; + }; + + // First call with entry1 — triggers a batch fetch + const entry1 = makeEntry({ placeIdentifier: 'osm:node:222' }); + const promise1 = resolver.resolveInBackground([entry1]); + await flushPromises(); + await promise1; + + // Verify the name persisted so the second call's cache check finds it + assert.strictEqual(entry1.placeName, 'Shared Place'); + assert.strictEqual(fetchCount, 1, 'only one batch fetch fired'); + + // Simulate the real-world race: a second set of entries for the same + // place arrives before the cache is consulted. Clear the persistent cache + // to simulate the incognito scenario where the batch hasn't persisted yet. + await localForage.clear('place-name-cache'); + + // Second call with a fresh entry2 — should resolve from the batch result + // that was already persisted (or re-use the pending batch if still in flight) + const entry2 = makeEntry({ placeIdentifier: 'osm:node:222' }); + await resolver.resolveInBackground([entry2]); + + assert.strictEqual( + entry2.placeName, + 'Shared Place', + 'second entry resolves even when batch was from a prior call' + ); + }); + + test('pending batch is shared when second call fires before first completes', async function (assert) { + const resolver = this.owner.lookup('service:place-name-resolver'); + + let fetchCount = 0; + resolver.osm.fetchOsmObjectsBatch = async () => { + fetchCount++; + const map = new Map(); + map.set('node:333', { title: 'Concurrent Place' }); + return map; + }; + + // First call — don't await yet (simulates subscription firing) + const entry1 = makeEntry({ placeIdentifier: 'osm:node:333' }); + const promise1 = resolver.resolveInBackground([entry1]); + + // Second call with a different entry for the same place — should piggyback + const entry2 = makeEntry({ placeIdentifier: 'osm:node:333' }); + const promise2 = resolver.resolveInBackground([entry2]); + + await Promise.all([promise1, promise2]); + await flushPromises(); + + assert.strictEqual(fetchCount, 1, 'only one batch fetch fired'); + assert.strictEqual(entry1.placeName, 'Concurrent Place'); + assert.strictEqual(entry2.placeName, 'Concurrent Place'); + }); }); diff --git a/tests/unit/utils/activity-test.js b/tests/unit/utils/activity-test.js index b2226b1..2467047 100644 --- a/tests/unit/utils/activity-test.js +++ b/tests/unit/utils/activity-test.js @@ -3,6 +3,7 @@ import { ActivityEntry, parseZapReceipt, enrichWithPhoto, + groupSocialPhotos, } from 'marco/utils/activity'; import { setVerifyWrappedEventMethod, @@ -209,3 +210,252 @@ module('Unit | Utility | activity', function () { assert.false(result, 'event not in store'); }); }); + +const SOCIAL_PK_A = 'a'.repeat(64); +const SOCIAL_PK_B = 'b'.repeat(64); + +function makeSocialPhotoEvent(opts = {}) { + return { + id: opts.id || `e${(opts.idx ?? 0).toString().padStart(63, '0')}`, + pubkey: opts.author || SOCIAL_PK_A, + kind: 360, + created_at: opts.created_at || 5000, + tags: [ + ['i', opts.placeIdentifier || 'osm:node:123'], + ['imeta', `url ${opts.url || 'https://x.com/photo.jpg'}`, 'dim 800x600'], + ], + content: '', + sig: 'sig', + }; +} + +module('Unit | Utility | activity | groupSocialPhotos', function () { + test('returns empty for no events', function (assert) { + assert.deepEqual(groupSocialPhotos([]), []); + assert.deepEqual(groupSocialPhotos(null), []); + }); + + test('groups by author + place within time threshold', function (assert) { + const events = [ + makeSocialPhotoEvent({ + idx: 1, + author: SOCIAL_PK_A, + placeIdentifier: 'osm:node:1', + created_at: 1000, + }), + makeSocialPhotoEvent({ + idx: 2, + author: SOCIAL_PK_A, + placeIdentifier: 'osm:node:1', + created_at: 2000, + }), + makeSocialPhotoEvent({ + idx: 3, + author: SOCIAL_PK_A, + placeIdentifier: 'osm:node:1', + created_at: 3000, + }), + ]; + + const entries = groupSocialPhotos(events); + assert.strictEqual(entries.length, 1, 'one entry for same author + place'); + assert.strictEqual(entries[0].type, 'photo'); + assert.strictEqual(entries[0].photos.length, 3); + assert.strictEqual(entries[0].senderPubkey, SOCIAL_PK_A); + assert.strictEqual(entries[0].createdAt, 3000, 'newest created_at'); + assert.strictEqual(entries[0].placeIdentifier, 'osm:node:1'); + }); + + test('separates entries by author for the same place', function (assert) { + const events = [ + makeSocialPhotoEvent({ + idx: 1, + author: SOCIAL_PK_A, + placeIdentifier: 'osm:node:1', + created_at: 1000, + }), + makeSocialPhotoEvent({ + idx: 2, + author: SOCIAL_PK_B, + placeIdentifier: 'osm:node:1', + created_at: 2000, + }), + ]; + + const entries = groupSocialPhotos(events); + assert.strictEqual(entries.length, 2, 'two entries (one per author)'); + assert.strictEqual(entries[0].senderPubkey, SOCIAL_PK_B, 'newest first'); + assert.strictEqual(entries[1].senderPubkey, SOCIAL_PK_A); + }); + + test('separates entries by place for the same author', function (assert) { + const events = [ + makeSocialPhotoEvent({ + idx: 1, + author: SOCIAL_PK_A, + placeIdentifier: 'osm:node:1', + created_at: 1000, + }), + makeSocialPhotoEvent({ + idx: 2, + author: SOCIAL_PK_A, + placeIdentifier: 'osm:node:2', + created_at: 2000, + }), + ]; + + const entries = groupSocialPhotos(events); + assert.strictEqual(entries.length, 2, 'two entries (one per place)'); + assert.strictEqual( + entries[0].placeIdentifier, + 'osm:node:2', + 'newest first' + ); + assert.strictEqual(entries[1].placeIdentifier, 'osm:node:1'); + }); + + test('splits by time proximity within same author + place', function (assert) { + const events = [ + makeSocialPhotoEvent({ + idx: 1, + author: SOCIAL_PK_A, + placeIdentifier: 'osm:node:1', + created_at: 1000, + }), + makeSocialPhotoEvent({ + idx: 2, + author: SOCIAL_PK_A, + placeIdentifier: 'osm:node:1', + created_at: 2000, + }), + // 5-hour gap (> 3h threshold) + makeSocialPhotoEvent({ + idx: 3, + author: SOCIAL_PK_A, + placeIdentifier: 'osm:node:1', + created_at: 20000, + }), + ]; + + const entries = groupSocialPhotos(events); + assert.strictEqual(entries.length, 2, 'split by time gap'); + assert.strictEqual(entries[0].photos.length, 1, 'newer group has 1 photo'); + assert.strictEqual(entries[1].photos.length, 2, 'older group has 2 photos'); + }); + + test('sorts entries newest-first', function (assert) { + const events = [ + makeSocialPhotoEvent({ + idx: 1, + author: SOCIAL_PK_A, + placeIdentifier: 'osm:node:1', + created_at: 1000, + }), + makeSocialPhotoEvent({ + idx: 2, + author: SOCIAL_PK_B, + placeIdentifier: 'osm:node:2', + created_at: 5000, + }), + makeSocialPhotoEvent({ + idx: 3, + author: SOCIAL_PK_A, + placeIdentifier: 'osm:node:1', + created_at: 2000, + }), + ]; + + const entries = groupSocialPhotos(events); + assert.strictEqual(entries[0].createdAt, 5000, 'newest entry first'); + assert.strictEqual(entries[1].createdAt, 2000, 'second entry'); + }); + + test('sets osmType and osmId from placeIdentifier', function (assert) { + const events = [ + makeSocialPhotoEvent({ + idx: 1, + author: SOCIAL_PK_A, + placeIdentifier: 'osm:way:999', + created_at: 1000, + }), + ]; + + const entries = groupSocialPhotos(events); + assert.strictEqual(entries[0].osmType, 'way'); + assert.strictEqual(entries[0].osmId, '999'); + }); + + test('filters out events without an i tag', function (assert) { + const event = makeSocialPhotoEvent({ idx: 1, author: SOCIAL_PK_A }); + event.tags = [['imeta', 'url https://x.com/photo.jpg', 'dim 800x600']]; + + const entries = groupSocialPhotos([event]); + assert.strictEqual(entries.length, 0, 'no i tag → filtered out'); + }); + + test('applies kind 5 deletions', function (assert) { + const photoId = 'e'.padStart(64, '0'); + const events = [ + { + id: photoId, + pubkey: SOCIAL_PK_A, + kind: 360, + created_at: 1000, + tags: [ + ['i', 'osm:node:1'], + ['imeta', 'url https://x.com/photo.jpg', 'dim 800x600'], + ], + content: '', + sig: 'sig', + }, + { + id: 'd'.padStart(64, '0'), + pubkey: SOCIAL_PK_A, + kind: 5, + created_at: 5000, + tags: [['e', photoId]], + content: '', + sig: 'sig', + }, + ]; + + const entries = groupSocialPhotos(events); + assert.strictEqual(entries.length, 0, 'deleted photo is filtered out'); + }); + + test('filters out entries with no usable photos (malformed imeta)', function (assert) { + const event = makeSocialPhotoEvent({ idx: 1, author: SOCIAL_PK_A }); + event.tags = [ + ['i', 'osm:node:1'], + ['imeta', 'dim 800x600'], // no url + ]; + + const entries = groupSocialPhotos([event]); + assert.strictEqual(entries.length, 0, 'malformed imeta → filtered out'); + }); + + test('photo is set to first photo in the group', function (assert) { + const events = [ + makeSocialPhotoEvent({ + idx: 1, + author: SOCIAL_PK_A, + url: 'https://x.com/a.jpg', + created_at: 1000, + }), + makeSocialPhotoEvent({ + idx: 2, + author: SOCIAL_PK_A, + url: 'https://x.com/b.jpg', + created_at: 2000, + }), + ]; + + const entries = groupSocialPhotos(events); + assert.ok(entries[0].photo, 'photo is set'); + assert.strictEqual( + entries[0].photo.url, + 'https://x.com/a.jpg', + 'first photo (oldest) is the thumbnail' + ); + }); +});