From 36989e1dc2731787d5aa3e8533c9d4ea781f1276 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Wed, 2 Sep 2026 10:22:15 -0600 Subject: [PATCH 1/8] WIP Add activity photo item Can show one or more photo additions in the activity timeline --- app/components/activity-photo-item.gjs | 134 ++++++++++++++++++ app/components/activity-timeline.gjs | 73 ++++++++++ app/components/activity-zap-item.gjs | 32 +++-- app/styles/app.css | 48 ++++--- app/utils/activity.js | 3 + tests/acceptance/activity-test.js | 16 +-- .../components/activity-timeline-test.gjs | 2 +- .../components/activity-zap-item-test.gjs | 48 ++++--- 8 files changed, 293 insertions(+), 63 deletions(-) create mode 100644 app/components/activity-photo-item.gjs diff --git a/app/components/activity-photo-item.gjs b/app/components/activity-photo-item.gjs new file mode 100644 index 0000000..dc96ae6 --- /dev/null +++ b/app/components/activity-photo-item.gjs @@ -0,0 +1,134 @@ +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..ebc23bc 100644 --- a/app/components/activity-timeline.gjs +++ b/app/components/activity-timeline.gjs @@ -4,15 +4,78 @@ import { tracked } from '@glimmer/tracking'; import { on } from '@ember/modifier'; import Icon from './icon'; 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 restoreScroll from '../modifiers/restore-scroll'; +import { ActivityEntry } from '../utils/activity'; export default class ActivityTimelineComponent extends Component { @tracked isNostrConnectModalOpen = false; + get dummyPhotoEntry() { + const entry = new ActivityEntry({ + photoEventId: 'dummy-photo-event-id', + photos: [ + { + thumbUrl: 'https://picsum.photos/seed/marco-photo-1/200/200', + url: 'https://picsum.photos/seed/marco-photo-1/800/800', + placeIdentifier: 'osm:node:123456789', + }, + ], + placeIdentifier: 'osm:node:123456789', + senderPubkey: 'dummy-sender-pubkey', + amountSats: 0, + message: null, + createdAt: Math.floor(Date.now() / 1000) - 3600, + }); + entry.type = 'photo'; + entry.senderName = 'Alice Mapper'; + entry.senderAvatar = + 'https://api.dicebear.com/7.x/avataaars/svg?seed=alice'; + entry.placeName = 'Central Park Café'; + entry.placeNameLoading = false; + entry.senderProfileLoading = false; + return entry; + } + + get dummyMultiPhotoEntry() { + const entry = new ActivityEntry({ + photoEventId: 'dummy-multi-photo-event-id', + photos: [ + { + thumbUrl: 'https://picsum.photos/seed/marco-multi-1/200/200', + url: 'https://picsum.photos/seed/marco-multi-1/800/800', + placeIdentifier: 'osm:node:987654321', + }, + { + thumbUrl: 'https://picsum.photos/seed/marco-multi-2/200/200', + url: 'https://picsum.photos/seed/marco-multi-2/800/800', + placeIdentifier: 'osm:node:987654321', + }, + { + thumbUrl: 'https://picsum.photos/seed/marco-multi-3/200/200', + url: 'https://picsum.photos/seed/marco-multi-3/800/800', + placeIdentifier: 'osm:node:987654321', + }, + ], + placeIdentifier: 'osm:node:987654321', + senderPubkey: 'dummy-multi-sender-pubkey', + amountSats: 0, + message: null, + createdAt: Math.floor(Date.now() / 1000) - 7200, + }); + entry.type = 'photo'; + entry.senderName = 'Bob Cartographer'; + entry.senderAvatar = 'https://api.dicebear.com/7.x/avataaars/svg?seed=bob'; + entry.placeName = 'Riverside Lookout'; + entry.placeNameLoading = false; + entry.senderProfileLoading = false; + return entry; + } + @action openNostrConnectModal(event) { event.preventDefault(); @@ -69,8 +132,18 @@ export default class ActivityTimelineComponent extends Component { {{#each @items as |item|}} {{#if (eq item.type "zap")}} + {{else if (eq item.type "photo")}} + {{/if}} {{/each}} + + {{/if}} diff --git a/app/components/activity-zap-item.gjs b/app/components/activity-zap-item.gjs index e404bf5..d58c8ae 100644 --- a/app/components/activity-zap-item.gjs +++ b/app/components/activity-zap-item.gjs @@ -50,40 +50,40 @@ export default class ActivityZapItem extends Component {
  • diff --git a/app/styles/app.css b/app/styles/app.css index 654a673..25c62de 100644 --- a/app/styles/app.css +++ b/app/styles/app.css @@ -2661,14 +2661,14 @@ 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-item { width: 100%; text-align: left; border: none; @@ -2687,7 +2687,7 @@ button.create-place { background: var(--hover-bg); } - & .zap-sender-avatar { + & .sender-avatar { flex-shrink: 0; width: 32px; height: 32px; @@ -2696,7 +2696,7 @@ button.create-place { background: #f0f0f0; } - & .zap-sender-avatar-placeholder { + & .sender-avatar-placeholder { flex-shrink: 0; width: 32px; height: 32px; @@ -2708,39 +2708,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 +2765,7 @@ button.create-place { } } - & .zap-context { + & .context { display: flex; align-items: center; gap: 6px; @@ -2770,19 +2773,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,16 +2794,28 @@ 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; @@ -2807,7 +2823,7 @@ button.create-place { min-width: 0; } - & .zap-date { + & .activity-date { flex-shrink: 0; white-space: nowrap; } diff --git a/app/utils/activity.js b/app/utils/activity.js index 9f76666..07b2bc1 100644 --- a/app/utils/activity.js +++ b/app/utils/activity.js @@ -27,6 +27,7 @@ export class ActivityEntry { type = 'zap'; photoEventId; photo; + photos = []; placeIdentifier; osmType; osmId; @@ -43,6 +44,7 @@ export class ActivityEntry { constructor({ photoEventId, photo, + photos, placeIdentifier, senderPubkey, amountSats, @@ -51,6 +53,7 @@ export class ActivityEntry { }) { this.photoEventId = photoEventId; this.photo = photo; + this.photos = photos ?? []; this.placeIdentifier = placeIdentifier; this.senderPubkey = senderPubkey; this.amountSats = amountSats; diff --git a/tests/acceptance/activity-test.js b/tests/acceptance/activity-test.js index 4500822..239231f 100644 --- a/tests/acceptance/activity-test.js +++ b/tests/acceptance/activity-test.js @@ -97,12 +97,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: 3 }); + 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 +124,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..244f0f3 100644 --- a/tests/integration/components/activity-timeline-test.gjs +++ b/tests/integration/components/activity-timeline-test.gjs @@ -143,7 +143,7 @@ module('Integration | Component | activity-timeline', function (hooks) { ); - assert.dom('.activity-zap-item').exists({ count: 2 }); + assert.dom('.activity-item').exists({ count: 4 }); assert.dom(this.element).includesText('Alice'); assert.dom(this.element).includesText('21 ⚡'); assert.dom(this.element).includesText('Bob'); diff --git a/tests/integration/components/activity-zap-item-test.gjs b/tests/integration/components/activity-zap-item-test.gjs index bf42b0d..e5254e3 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,7 +168,7 @@ 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') @@ -197,7 +197,7 @@ module('Integration | Component | activity-zap-item', function (hooks) { ); assert - .dom('.zap-context-text .contribution-name-loading') + .dom('.context-text .contribution-name-loading') .hasText('Loading…', 'Loading state is displayed'); }); @@ -223,11 +223,11 @@ module('Integration | Component | activity-zap-item', function (hooks) { ); assert - .dom('.zap-context-text .contribution-name-loading') + .dom('.context-text .contribution-name-loading') .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 +248,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 +290,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'); }); }); -- 2.50.1 From 92c6190d28b0b8edd2c1aefa81e23348a310d016 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Wed, 2 Sep 2026 11:23:34 -0600 Subject: [PATCH 2/8] Add followee's photo uploads to Activity timeline --- app/components/activity-timeline.gjs | 70 ----- app/services/activity.js | 153 +++++++++-- app/services/nostr-data.js | 119 +++++++++ app/utils/activity.js | 112 +++++++- app/utils/contributions.js | 2 +- tests/acceptance/activity-test.js | 2 +- .../components/activity-timeline-test.gjs | 2 +- tests/unit/services/activity-test.js | 177 ++++++++++++- tests/unit/services/nostr-data-test.js | 108 ++++++++ tests/unit/utils/activity-test.js | 250 ++++++++++++++++++ 10 files changed, 893 insertions(+), 102 deletions(-) diff --git a/app/components/activity-timeline.gjs b/app/components/activity-timeline.gjs index ebc23bc..f459059 100644 --- a/app/components/activity-timeline.gjs +++ b/app/components/activity-timeline.gjs @@ -10,72 +10,10 @@ import NostrConnect from './nostr-connect'; import not from 'ember-truth-helpers/helpers/not'; import eq from 'ember-truth-helpers/helpers/eq'; import restoreScroll from '../modifiers/restore-scroll'; -import { ActivityEntry } from '../utils/activity'; export default class ActivityTimelineComponent extends Component { @tracked isNostrConnectModalOpen = false; - get dummyPhotoEntry() { - const entry = new ActivityEntry({ - photoEventId: 'dummy-photo-event-id', - photos: [ - { - thumbUrl: 'https://picsum.photos/seed/marco-photo-1/200/200', - url: 'https://picsum.photos/seed/marco-photo-1/800/800', - placeIdentifier: 'osm:node:123456789', - }, - ], - placeIdentifier: 'osm:node:123456789', - senderPubkey: 'dummy-sender-pubkey', - amountSats: 0, - message: null, - createdAt: Math.floor(Date.now() / 1000) - 3600, - }); - entry.type = 'photo'; - entry.senderName = 'Alice Mapper'; - entry.senderAvatar = - 'https://api.dicebear.com/7.x/avataaars/svg?seed=alice'; - entry.placeName = 'Central Park Café'; - entry.placeNameLoading = false; - entry.senderProfileLoading = false; - return entry; - } - - get dummyMultiPhotoEntry() { - const entry = new ActivityEntry({ - photoEventId: 'dummy-multi-photo-event-id', - photos: [ - { - thumbUrl: 'https://picsum.photos/seed/marco-multi-1/200/200', - url: 'https://picsum.photos/seed/marco-multi-1/800/800', - placeIdentifier: 'osm:node:987654321', - }, - { - thumbUrl: 'https://picsum.photos/seed/marco-multi-2/200/200', - url: 'https://picsum.photos/seed/marco-multi-2/800/800', - placeIdentifier: 'osm:node:987654321', - }, - { - thumbUrl: 'https://picsum.photos/seed/marco-multi-3/200/200', - url: 'https://picsum.photos/seed/marco-multi-3/800/800', - placeIdentifier: 'osm:node:987654321', - }, - ], - placeIdentifier: 'osm:node:987654321', - senderPubkey: 'dummy-multi-sender-pubkey', - amountSats: 0, - message: null, - createdAt: Math.floor(Date.now() / 1000) - 7200, - }); - entry.type = 'photo'; - entry.senderName = 'Bob Cartographer'; - entry.senderAvatar = 'https://api.dicebear.com/7.x/avataaars/svg?seed=bob'; - entry.placeName = 'Riverside Lookout'; - entry.placeNameLoading = false; - entry.senderProfileLoading = false; - return entry; - } - @action openNostrConnectModal(event) { event.preventDefault(); @@ -136,14 +74,6 @@ export default class ActivityTimelineComponent extends Component { {{/if}} {{/each}} - - {{/if}} diff --git a/app/services/activity.js b/app/services/activity.js index 7d16d67..0d1e5d3 100644 --- a/app/services/activity.js +++ b/app/services/activity.js @@ -2,7 +2,13 @@ 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 /** * Orchestrates loading the user's incoming social activity (zaps received on @@ -27,11 +33,18 @@ export default class ActivityService extends Service { @tracked items = []; _sub = null; + _socialSub = null; _profileSubs = new Map(); _userPubkey = null; + _zapItems = []; + _socialItems = []; + _lastSocialEvents = []; + _since = null; + _sourceMode = 'social'; /** - * Loads the user's incoming zap receipts and subscribes to live updates. + * Loads the user's incoming zap receipts and social photo activity, then + * subscribes to live updates. * * @param {string} pubkey The user's Nostr pubkey */ @@ -42,20 +55,33 @@ export default class ActivityService extends Service { } this._userPubkey = pubkey; + this._since = Math.floor(Date.now() / 1000) - SINCE_WINDOW; - const filters = [{ kinds: [9735], '#p': [pubkey] }]; + const zapFilters = [{ kinds: [9735], '#p': [pubkey] }]; console.debug('[activity] Subscribing to zap receipts', { - filters, + filters: zapFilters, pubkey, activeReadRelays: this.nostrData.activeReadRelays, }); - // 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); - }); + // Subscribe to zap receipts (kind 9735 where #p = user) + this._sub = this.nostrData.store + .timeline(zapFilters) + .subscribe((events) => { + this._updateZapItems(events, pubkey); + }); + + // Subscribe to all kind 360 photos in the time window. The callback + // filters by source mode (e.g. followed contacts only) so we only + // show photos from the relevant source. + const photoFilters = [{ kinds: [360], since: this._since }]; + this._socialSub = this.nostrData.store + .timeline(photoFilters) + .subscribe((events) => { + this._lastSocialEvents = events; + this._updateSocialItems(events); + }); // Ensure the user's kind 360 photo events are in the store first so // enrichWithPhoto can look them up when zap receipts arrive. @@ -64,6 +90,33 @@ export default class ActivityService extends Service { // Then load zap receipts — adding them to the store triggers the // timeline subscription, and by now the photo events are available. await this.nostrData.loadIncomingZaps(pubkey); + + // Load social photos (contacts dependency handled internally — if + // contacts haven't loaded yet, loadActivityPhotos returns early and + // the ContactsModel callback re-triggers it when they arrive). + await this.nostrData.loadActivityPhotos(this._since, this._sourceMode); + } + + /** + * Switches the activity source mode (e.g. 'social' for followed contacts, + * 'trusted-relays' for content from trusted relays). Re-filters existing + * events and re-fetches with the new mode. + * + * @param {string} mode The new source mode + */ + setSourceMode(mode) { + if (mode === this._sourceMode) return; + this._sourceMode = mode; + + // Re-filter existing events with the new mode + if (this._lastSocialEvents.length > 0) { + this._updateSocialItems(this._lastSocialEvents); + } + + // Re-fetch with the new mode + if (this._userPubkey && this._since !== null) { + this.nostrData.loadActivityPhotos(this._since, mode); + } } /** @@ -75,9 +128,17 @@ export default class ActivityService extends Service { this._sub.unsubscribe(); this._sub = null; } + if (this._socialSub) { + this._socialSub.unsubscribe(); + this._socialSub = null; + } this._cleanupProfileSubs(); + this._zapItems = []; + this._socialItems = []; + this._lastSocialEvents = []; this.items = []; this._userPubkey = null; + this._since = null; this.placeNameResolver.reset(); } @@ -86,7 +147,7 @@ export default class ActivityService extends Service { super.willDestroy(...arguments); } - _updateItems(receipts, pubkey) { + _updateZapItems(receipts, pubkey) { const entries = []; for (const receipt of receipts) { @@ -113,8 +174,8 @@ export default class ActivityService extends Service { pubkey, }); - // 2. Bookmark lookup is synchronous — resolve those immediately so the - // first render shows bookmarked place names without a "Loading…" flicker. + // Synchronous bookmark lookup — resolve those immediately so the + // first render shows bookmarked place names without a "Loading…" flicker. for (const entry of entries) { if (entry.osmId) { const bookmarkName = this.placeNameResolver.resolveBookmark( @@ -127,15 +188,75 @@ export default class ActivityService extends Service { } } - this.items = entries; + 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. + // 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) { + // Filter by source mode (e.g. only followed contacts, excluding own photos) + const filtered = events.filter((e) => this._matchesSourceMode(e)); + const entries = groupSocialPhotos(filtered); + + // Resolve sender profiles (async, fire-and-forget per sender) + for (const entry of entries) { + this._resolveSender(entry); + } + + // Synchronous bookmark lookup for instant first render + for (const entry of entries) { + if (entry.osmId) { + const bookmarkName = this.placeNameResolver.resolveBookmark( + entry.osmId + ); + if (bookmarkName) { + entry.placeName = bookmarkName; + entry.placeNameLoading = false; + } + } + } + + this._socialItems = entries; + this._mergeItems(); + + // Async-resolve remaining place names from caches → network batch + void this.placeNameResolver.resolveInBackground(entries).then(() => { + this._mergeItems(); + }); + } + + /** + * Merges `_zapItems` and `_socialItems` into `items`, sorted newest-first. + */ + _mergeItems() { + const all = [...this._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. + * + * - `'social'`: only events from followed contacts (excluding own photos) + * - Future modes (e.g. `'trusted-relays'`) can be added here + */ + _matchesSourceMode(event) { + if (this._sourceMode === 'social') { + return ( + this._userPubkey && + event.pubkey !== this._userPubkey && + this.nostrData._contactPubkeys?.has(event.pubkey) + ); + } + return false; + } + _resolveSender(entry) { const pubkey = entry.senderPubkey; if (!pubkey) return; diff --git a/app/services/nostr-data.js b/app/services/nostr-data.js index c20fbbd..e77a372 100644 --- a/app/services/nostr-data.js +++ b/app/services/nostr-data.js @@ -97,6 +97,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 = 'social'; + _activityPhotosNetworkSub = null; + _requestSub = null; _cachePromise = null; _currentPlaceEntityId = null; @@ -600,6 +606,106 @@ 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. + * + * - `'social'` mode: fetches photos authored by the user's followed contacts + * (kind 3). 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. + * + * Future modes (e.g. `'trusted-relays'`) can be added by extending the + * switch below. + * + * @param {number} since Unix timestamp (seconds) for the start of the window + * @param {string} [mode='social'] Source mode + */ + async loadActivityPhotos(since, mode = 'social') { + this._activityPhotosSince = since; + this._activityPhotosMode = mode; + + if (this._activityPhotosNetworkSub) { + this._activityPhotosNetworkSub.unsubscribe(); + this._activityPhotosNetworkSub = null; + } + + if (mode === 'social') { + this._loadSocialCirclePhotos(since); + } + // Future modes (e.g. 'trusted-relays') can be added here. + } + + /** + * Fetches kind 360 photos from the user's followed contacts, batched by + * author pubkey (≤100 per filter to stay under relay REQ limits). + */ + _loadSocialCirclePhotos(since) { + const pubkeys = this._contactPubkeys + ? Array.from(this._contactPubkeys) + : []; + + if (pubkeys.length === 0) { + console.debug( + '[nostr-data] No contacts loaded yet, deferring social photo load' + ); + return; + } + + const filters = this._batchAuthorFilters(pubkeys, [360], since); + + console.debug('[nostr-data] Loading social circle 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 social 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 social circle photos:' + ); + } + + /** + * 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) { + const BATCH_SIZE = 100; + const filters = []; + for (let i = 0; i < pubkeys.length; i += BATCH_SIZE) { + filters.push({ + kinds, + authors: pubkeys.slice(i, i + BATCH_SIZE), + since, + }); + } + return filters; + } + loadProfiles(pubkeys) { const newPubkeys = pubkeys.filter( (pk) => pk && !this._profileModelSubs.has(pk) @@ -659,6 +765,15 @@ export default class NostrDataService extends Service { this.contacts = contacts; this._contactPubkeys = new Set(contacts.map((c) => c.pubkey)); 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 @@ -896,6 +1011,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/utils/activity.js b/app/utils/activity.js index 07b2bc1..97f3e51 100644 --- a/app/utils/activity.js +++ b/app/utils/activity.js @@ -14,7 +14,7 @@ import { getZapEventPointer, getZapRequest, } from 'applesauce-common/helpers'; -import { parsePhotoFromEvent } from './contributions'; +import { parsePhotoFromEvent, applyDeletions } from './contributions'; /** * A single activity timeline entry. @@ -46,6 +46,8 @@ export class ActivityEntry { photo, photos, placeIdentifier, + osmType, + osmId, senderPubkey, amountSats, message, @@ -55,6 +57,8 @@ export class ActivityEntry { this.photo = photo; this.photos = photos ?? []; this.placeIdentifier = placeIdentifier; + this.osmType = osmType; + this.osmId = osmId; this.senderPubkey = senderPubkey; this.amountSats = amountSats; this.message = message; @@ -143,3 +147,109 @@ export function enrichWithPhoto(entry, store, userPubkey) { return true; } + +const HOUR_IN_SECONDS = 60 * 60; + +/** + * Builds a single `ActivityEntry` (type 'photo') from a group of kind 360 + * events by the same author for the same OSM entity. + * + * @param {Array} events Kind 360 events in this sub-group (same author + place) + * @returns {ActivityEntry} + */ +function buildSocialEntry(events) { + const photos = events + .map(parsePhotoFromEvent) + .filter(Boolean) + .sort((a, b) => 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 added 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 239231f..7a35522 100644 --- a/tests/acceptance/activity-test.js +++ b/tests/acceptance/activity-test.js @@ -98,7 +98,7 @@ module('Acceptance | activity', function (hooks) { await visit('/activity'); await waitFor('.activity-item'); - assert.dom('.activity-item').exists({ count: 3 }); + 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 ⚡'); diff --git a/tests/integration/components/activity-timeline-test.gjs b/tests/integration/components/activity-timeline-test.gjs index 244f0f3..8b4b855 100644 --- a/tests/integration/components/activity-timeline-test.gjs +++ b/tests/integration/components/activity-timeline-test.gjs @@ -143,7 +143,7 @@ module('Integration | Component | activity-timeline', function (hooks) { ); - assert.dom('.activity-item').exists({ count: 4 }); + 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'); diff --git a/tests/unit/services/activity-test.js b/tests/unit/services/activity-test.js index b3f47cd..b850c67 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,7 @@ class MockNostrDataService extends Service { }; loadProfiles() {} + loadActivityPhotos() {} getProfile(pubkey) { return this.profiles[pubkey]; @@ -125,9 +137,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 +153,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 +164,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 +176,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 +193,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 +202,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 +212,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 +221,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 +237,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 +247,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 ); @@ -270,4 +283,144 @@ 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 social mode', function (assert) { + const service = this.owner.lookup('service:activity'); + service._userPubkey = USER_PUBKEY; + service._sourceMode = 'social'; + 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('_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('setSourceMode re-filters existing events', function (assert) { + const service = this.owner.lookup('service:activity'); + service._userPubkey = USER_PUBKEY; + service._sourceMode = 'trusted-relays'; + service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]); + + const followedPhoto = makePhotoEvent({ + id: 'fp1'.padEnd(64, '0'), + author: SENDER_PUBKEY, + placeIdentifier: 'osm:node:100', + created_at: 1000, + }); + service._lastSocialEvents = [followedPhoto]; + + // In 'trusted-relays' mode, _matchesSourceMode returns false for all events + service._updateSocialItems([followedPhoto]); + assert.strictEqual( + service.items.length, + 0, + 'no social items in trusted-relays mode' + ); + + // Switch to 'social' mode — now the followed photo should appear + service.setSourceMode('social'); + assert.strictEqual( + service.items.length, + 1, + 're-filtered shows followed contact in social 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'); + }); }); diff --git a/tests/unit/services/nostr-data-test.js b/tests/unit/services/nostr-data-test.js index d1e1786..6e1f788 100644 --- a/tests/unit/services/nostr-data-test.js +++ b/tests/unit/services/nostr-data-test.js @@ -856,6 +856,114 @@ 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, 'social'); + + // 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, 'social'); + + 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, 'social'); + + 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, 'social'); + + 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('_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); + }); +}); + module('Unit | Service | nostr-data | zap receipts', function (hooks) { setupNostrDataService(hooks); 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' + ); + }); +}); -- 2.50.1 From a4a8d123d3e5f7f6c2251da09d70bb948ce0abe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Wed, 2 Sep 2026 15:06:10 -0600 Subject: [PATCH 3/8] Split Activity timeline in Home and Explore Adds a new Explore timeline with contributions from anyone on trusted relays. --- app/components/activity-timeline.gjs | 15 +- app/components/tab-nav.gjs | 17 +++ app/controllers/activity.js | 10 +- app/services/activity.js | 87 +++++++----- app/services/nostr-data.js | 73 +++++++--- app/services/place-name-resolver.js | 113 ++++++++------- app/styles/app.css | 32 +++++ app/templates/activity.gjs | 2 + tests/acceptance/activity-test.js | 3 + .../components/activity-timeline-test.gjs | 133 ++++++++++++++++++ tests/integration/components/tab-nav-test.gjs | 87 ++++++++++++ tests/unit/services/activity-test.js | 127 +++++++++++++++-- tests/unit/services/nostr-data-test.js | 24 +++- .../unit/services/place-name-resolver-test.js | 66 +++++++++ 14 files changed, 667 insertions(+), 122 deletions(-) create mode 100644 app/components/tab-nav.gjs create mode 100644 tests/integration/components/tab-nav-test.gjs diff --git a/app/components/activity-timeline.gjs b/app/components/activity-timeline.gjs index f459059..2d14491 100644 --- a/app/components/activity-timeline.gjs +++ b/app/components/activity-timeline.gjs @@ -3,17 +3,24 @@ 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'; export default class ActivityTimelineComponent extends Component { @tracked isNostrConnectModalOpen = false; + tabs = [ + { label: 'Home', value: 'home' }, + { label: 'Explore', value: 'explore' }, + ]; + @action openNostrConnectModal(event) { event.preventDefault(); @@ -48,12 +55,18 @@ export default class ActivityTimelineComponent extends Component { + + diff --git a/app/components/activity-zap-item.gjs b/app/components/activity-zap-item.gjs index d58c8ae..410471d 100644 --- a/app/components/activity-zap-item.gjs +++ b/app/components/activity-zap-item.gjs @@ -85,11 +85,9 @@ export default class ActivityZapItem extends Component {
    {{#if this.placeName}} - {{this.placeName}} - {{else if this.placeNameLoading}} - Loading… - {{else}} - Unnamed place + {{this.placeName}} + {{else if this.placeNameLoading}}{{else}} + Unnamed place {{/if}} {{formatRelativeDate diff --git a/app/controllers/activity.js b/app/controllers/activity.js index 86661fc..3d44623 100644 --- a/app/controllers/activity.js +++ b/app/controllers/activity.js @@ -21,6 +21,10 @@ export default class ActivityController extends Controller { return this.activity.items; } + get isLoading() { + return this.activity.isLoading; + } + get isConnected() { return this.nostrAuth.isConnected; } @@ -29,11 +33,20 @@ export default class ActivityController extends Controller { return this.activity.sourceMode; } + get isLoadingMore() { + return this.activity.isLoadingMore; + } + @action setSourceMode(mode) { this.activity.setSourceMode(mode); } + @action + loadMore() { + this.activity.loadMore(); + } + @action selectItem(item) { if (!item || !item.placeIdentifier) return; diff --git a/app/modifiers/observe-intersection.js b/app/modifiers/observe-intersection.js new file mode 100644 index 0000000..b080647 --- /dev/null +++ b/app/modifiers/observe-intersection.js @@ -0,0 +1,31 @@ +import { modifier } from 'ember-modifier'; + +export default modifier((element, [callback, disabled]) => { + if (disabled) return; + + let observer; + let cancelled = false; + + observer = new IntersectionObserver( + (entries) => { + if (cancelled) return; + if (entries[0]?.isIntersecting) { + callback(); + } + }, + { + root: null, + rootMargin: '200px', + threshold: 0, + } + ); + + observer.observe(element); + + return () => { + cancelled = true; + if (observer) { + observer.disconnect(); + } + }; +}); diff --git a/app/routes/activity.js b/app/routes/activity.js index 2b1db64..6a60525 100644 --- a/app/routes/activity.js +++ b/app/routes/activity.js @@ -18,6 +18,7 @@ export default class ActivityRoute extends Route { } deactivate() { - this.activity.stop(); + // Don't call activity.stop() — keep state alive when navigating to place + // details and back. The service's willDestroy() handles final cleanup. } } diff --git a/app/services/activity.js b/app/services/activity.js index 3d7011d..1996532 100644 --- a/app/services/activity.js +++ b/app/services/activity.js @@ -9,6 +9,8 @@ import { } from '../utils/activity'; const SINCE_WINDOW = 30 * 24 * 60 * 60; // 30 days in seconds +const MIN_SINCE = Math.floor(new Date('2026-04-20').getTime() / 1000); +const MAX_WINDOW = 960 * 24 * 60 * 60; // 960 days in seconds /** * Orchestrates loading the user's incoming social activity (zaps received on @@ -16,8 +18,7 @@ const SINCE_WINDOW = 30 * 24 * 60 * 60; // 30 days in seconds * timeline. * * The flow is: - * 1. Subscribe to `nostrData.store.timeline(...)` for kind 9735 zap receipts - * where the user is the recipient (`#p` filter). + * 1. Fetch kind 9735 zap receipts where the user is the recipient. * 2. Parse each receipt into an `ActivityEntry`, filtering to zaps on the * user's own kind 360 photos. * 3. Resolve sender profiles asynchronously via a per-sender `ProfileModel` @@ -32,9 +33,9 @@ export default class ActivityService extends Service { @tracked items = []; @tracked sourceMode = 'home'; + @tracked isLoading = false; + @tracked isLoadingMore = false; - _sub = null; - _socialSub = null; _profileSubs = new Map(); _userPubkey = null; _zapItems = []; @@ -42,86 +43,96 @@ export default class ActivityService extends Service { _lastSocialEvents = []; _since = null; _sourceMode = 'home'; + _isLoadingMore = false; /** - * Loads the user's incoming zap receipts and social photo activity, then - * subscribes to live updates. + * Loads the user's incoming zap receipts and social photo activity. * * When a Nostr account is connected, zaps received on the user's photos are * loaded and the 'home' mode shows photos from followed contacts. When no * account is connected, zaps and 'home' mode are skipped, but 'explore' * mode still loads photos from trusted relays. * + * If the initial 30-day window returns no events, the window expands + * exponentially (up to 960 days) until events are found or the minimum + * timestamp (2026-04-20) is reached. + * * @param {string|null} pubkey The user's Nostr pubkey, or null */ async load(pubkey) { + if (this.items.length > 0 && pubkey) return; this._userPubkey = pubkey || null; this._since = Math.floor(Date.now() / 1000) - SINCE_WINDOW; + this.isLoading = true; - if (pubkey) { - const zapFilters = [{ kinds: [9735], '#p': [pubkey] }]; + try { + if (pubkey) { + await this.nostrData.loadMyContributions(pubkey); + await this.nostrData.loadProfile(pubkey); + await this.nostrData.whenContactsLoaded(); + } - console.debug('[activity] Subscribing to zap receipts', { - filters: zapFilters, - pubkey, - activeReadRelays: this.nostrData.activeReadRelays, - }); + this._socialItems = []; + this._zapItems = []; + this._lastSocialEvents = []; + this._mergeItems(); - // Subscribe to zap receipts (kind 9735 where #p = user) - this._sub = this.nostrData.store - .timeline(zapFilters) - .subscribe((events) => { - this._updateZapItems(events, pubkey); - }); + const now = Math.floor(Date.now() / 1000); + await this._fetchWithBackoff(this._since, now); + } finally { + this.isLoading = false; } - - // Subscribe to all kind 360 photos in the time window. The callback - // filters by source mode (e.g. followed contacts only) so we only - // show photos from the relevant source. - const photoFilters = [{ kinds: [360], since: this._since }]; - this._socialSub = this.nostrData.store - .timeline(photoFilters) - .subscribe((events) => { - this._lastSocialEvents = events; - this._updateSocialItems(events); - }); - - if (pubkey) { - // Ensure the user's kind 360 photo events are in the store first so - // enrichWithPhoto can look them up when zap receipts arrive. - await this.nostrData.loadMyContributions(pubkey); - - // Then load zap receipts — adding them to the store triggers the - // timeline subscription, and by now the photo events are available. - await this.nostrData.loadIncomingZaps(pubkey); - } - - // Load social photos (contacts dependency handled internally — if - // contacts haven't loaded yet, loadActivityPhotos returns early and - // the ContactsModel callback re-triggers it when they arrive). - await this.nostrData.loadActivityPhotos(this._since, this._sourceMode); } /** * Switches the activity source mode (e.g. 'home' for followee photos, - * 'explore' for all photos from trusted relays). Re-filters existing - * events and re-fetches with the new mode. + * 'explore' for all photos from trusted relays). Resets to a fresh + * 30-day window and re-fetches both zaps and photos with the new mode. * * @param {string} mode The new source mode */ - setSourceMode(mode) { + async setSourceMode(mode) { if (mode === this._sourceMode) return; this._sourceMode = mode; this.sourceMode = mode; - // Re-filter existing events with the new mode - if (this._lastSocialEvents.length > 0) { - this._updateSocialItems(this._lastSocialEvents); - } + this._since = Math.floor(Date.now() / 1000) - SINCE_WINDOW; + this._socialItems = []; + this._zapItems = []; + this._lastSocialEvents = []; + this._mergeItems(); - // Re-fetch with the new mode - if (this._since !== null) { - this.nostrData.loadActivityPhotos(this._since, mode); + this.isLoading = true; + + try { + const now = Math.floor(Date.now() / 1000); + await this._fetchWithBackoff(this._since, now); + } finally { + this.isLoading = false; + } + } + + /** + * Loads older events by extending the time window further back. + * Only processes newly fetched events — existing items are not re-processed. + * + * If an empty window is returned (no photos AND no zaps), the window size + * doubles (up to 960 days) and the fetch is retried automatically until + * events are found or the minimum timestamp (2026-04-20) is reached. + * + * @returns {Promise} + */ + async loadMore() { + if (this._isLoadingMore || !this._since || !this.items.length) return; + this._isLoadingMore = true; + this.isLoadingMore = true; + + try { + const startSince = this._since - SINCE_WINDOW; + await this._fetchWithBackoff(startSince, this._since); + } finally { + this._isLoadingMore = false; + this.isLoadingMore = false; } } @@ -130,14 +141,6 @@ export default class ActivityService extends Service { * activity route. */ stop() { - if (this._sub) { - this._sub.unsubscribe(); - this._sub = null; - } - if (this._socialSub) { - this._socialSub.unsubscribe(); - this._socialSub = null; - } this._cleanupProfileSubs(); this._zapItems = []; this._socialItems = []; @@ -145,6 +148,9 @@ export default class ActivityService extends Service { this.items = []; this._userPubkey = null; this._since = null; + this._isLoadingMore = false; + this.isLoadingMore = false; + this.isLoading = false; this.placeNameResolver.reset(); } @@ -153,35 +159,141 @@ export default class ActivityService extends Service { super.willDestroy(...arguments); } - _updateZapItems(receipts, pubkey) { + /** + * Fetches photos and zaps for a single time window, processes them, and + * appends to the respective item arrays. + * + * @param {number} batchSince Start of the window (seconds) + * @param {number} batchUntil End of the window (seconds) + * @returns {Promise} True if any events were found + */ + async _fetchAndProcessWindow(batchSince, batchUntil) { + const newEvents = await this.nostrData.fetchActivityPhotos( + batchSince, + batchUntil, + this._sourceMode + ); + + let newZapEvents = []; + if (this._sourceMode === 'home' && this._userPubkey) { + newZapEvents = await this.nostrData.fetchIncomingZaps( + this._userPubkey, + batchSince, + batchUntil + ); + } + + if (newEvents.length === 0 && newZapEvents.length === 0) { + return false; + } + + let added = false; + + if (newEvents.length > 0) { + this._lastSocialEvents = [...this._lastSocialEvents, ...newEvents]; + + const filtered = newEvents.filter((e) => this._matchesSourceMode(e)); + if (filtered.length > 0) { + const newEntries = groupSocialPhotos(filtered); + + for (const entry of newEntries) { + this._resolveSender(entry); + } + + for (const entry of newEntries) { + if (entry.osmId) { + const bookmarkName = this.placeNameResolver.resolveBookmark( + entry.osmId + ); + if (bookmarkName) { + entry.placeName = bookmarkName; + entry.placeNameLoading = false; + } + } + } + + this._socialItems = [...this._socialItems, ...newEntries]; + this._mergeItems(); + + void this.placeNameResolver + .resolveInBackground(newEntries) + .then(() => this._mergeItems()); + + added = true; + } + } + + if (newZapEvents.length > 0) { + const newZapEntries = this._processZapEvents( + newZapEvents, + this._userPubkey + ); + if (newZapEntries.length > 0) { + this._zapItems = [...this._zapItems, ...newZapEntries]; + this._mergeItems(); + + void this.placeNameResolver + .resolveInBackground(newZapEntries) + .then(() => this._mergeItems()); + + added = true; + } + } + + return added; + } + + /** + * Fetches events with exponential backoff. Starts with a 30-day window + * and doubles the window size on each empty result (up to 960 days), + * until events are found or MIN_SINCE is reached. + * + * @param {number} startSince Start of the first window (seconds) + * @param {number} startUntil End of the first window (seconds) + * @returns {Promise} + */ + async _fetchWithBackoff(startSince, startUntil) { + let windowSize = SINCE_WINDOW; + let batchSince = startSince; + let batchUntil = startUntil; + + while (true) { + if (this._since === null) return; // stopped + + // Clamp to MIN_SINCE — this is the final iteration if clamped + const isFinal = batchSince < MIN_SINCE; + if (isFinal) { + batchSince = MIN_SINCE; + } + + const found = await this._fetchAndProcessWindow(batchSince, batchUntil); + this._since = batchSince; + + if (found || isFinal) break; + + windowSize = Math.min(windowSize * 2, MAX_WINDOW); + batchUntil = batchSince; + batchSince = batchSince - windowSize; + } + } + + _processZapEvents(receipts, pubkey) { const entries = []; for (const receipt of receipts) { const entry = parseZapReceipt(receipt, pubkey); if (!entry) continue; - // Only keep zaps for the user's own kind 360 photos if (!enrichWithPhoto(entry, this.nostrData.store, pubkey)) { continue; } - // Resolve sender profile (async, fire-and-forget) this._resolveSender(entry); - entries.push(entry); } - // Sort newest-first by created_at entries.sort((a, b) => b.createdAt - a.createdAt); - console.debug('[activity] Zap receipts received', { - total: receipts.length, - matched: entries.length, - pubkey, - }); - - // Synchronous bookmark lookup — resolve those immediately so the - // first render shows bookmarked place names without a "Loading…" flicker. for (const entry of entries) { if (entry.osmId) { const bookmarkName = this.placeNameResolver.resolveBookmark( @@ -194,27 +306,28 @@ export default class ActivityService extends Service { } } + return entries; + } + + _updateZapItems(receipts, pubkey) { + const entries = this._processZapEvents(receipts, pubkey); + this._zapItems = entries; this._mergeItems(); - // Async-resolve remaining entries from the IndexedDB name cache and OSM - // cache, then fall through to the network batch for the rest. void this.placeNameResolver.resolveInBackground(entries).then(() => { this._mergeItems(); }); } _updateSocialItems(events) { - // Filter by source mode (e.g. only followed contacts, excluding own photos) const filtered = events.filter((e) => this._matchesSourceMode(e)); const entries = groupSocialPhotos(filtered); - // Resolve sender profiles (async, fire-and-forget per sender) for (const entry of entries) { this._resolveSender(entry); } - // Synchronous bookmark lookup for instant first render for (const entry of entries) { if (entry.osmId) { const bookmarkName = this.placeNameResolver.resolveBookmark( @@ -230,15 +343,11 @@ export default class ActivityService extends Service { this._socialItems = entries; this._mergeItems(); - // Async-resolve remaining place names from caches → network batch void this.placeNameResolver.resolveInBackground(entries).then(() => { this._mergeItems(); }); } - /** - * Merges `_zapItems` and `_socialItems` into `items`, sorted newest-first. - */ _mergeItems() { const all = this._sourceMode === 'explore' @@ -282,10 +391,6 @@ export default class ActivityService extends Service { const pubkey = entry.senderPubkey; if (!pubkey) return; - // Set up a ProfileModel subscription for this sender so the entry's - // tracked fields update when the profile arrives from cache or network. - // The event loader (configured in nostrData) auto-fetches missing kind 0 - // events from the IDB cache and relays. if (!this._profileSubs.has(pubkey)) { const sub = this.nostrData.store .model(ProfileModel, pubkey) @@ -295,17 +400,12 @@ export default class ActivityService extends Service { this._profileSubs.set(pubkey, sub); } - // Read immediately in case the profile is already cached this._applySenderProfile(entry, pubkey); } _applySenderProfile(entry, pubkey) { - // Try nostrData's profiles dict first (populated by other parts of the app) let profile = this.nostrData.getProfile(pubkey); - // Fall back to reading the kind 0 event directly from the store. This - // handles the re-open case where the event is already in the store from - // a previous load but nostrData.profiles wasn't populated by this service. if (!profile) { const event = this.nostrData.store.getReplaceable(0, pubkey); if (event) { @@ -321,7 +421,6 @@ export default class ActivityService extends Service { } _applyProfileToPubkey(pubkey, profileContent) { - // Update all entries matching this sender let changed = false; for (const entry of this.items) { if (entry.senderPubkey === pubkey) { @@ -334,7 +433,6 @@ export default class ActivityService extends Service { } if (changed) { - // Trigger re-render this.items = [...this.items]; } } diff --git a/app/services/nostr-data.js b/app/services/nostr-data.js index 02bb3b0..895ae12 100644 --- a/app/services/nostr-data.js +++ b/app/services/nostr-data.js @@ -76,6 +76,11 @@ export default class NostrDataService extends Service { // trust lookups. Rebuilt whenever contacts change. _contactPubkeys = null; + // Deferred that resolves when contacts are first loaded. Created in + // loadProfile, resolved in the ContactsModel callback. + _contactsResolver = null; + _contactsPromise = null; + // Session-only reveal toggle for untrusted content. Not persisted. @tracked showUntrustedContent = false; // Count of currently-hidden (untrusted) place photos for the selected place. @@ -722,6 +727,166 @@ export default class NostrDataService extends Service { ); } + /** + * Fetches events from the network as a Promise that resolves on EOSE. + * Records provenance for each event and adds them to the store. + * + * @param {object[]} filters Nostr filters + * @param {string} errorLabel Log label for errors + * @returns {Promise} Deduplicated events received from relays + */ + async _fetchEventsWithProvenance(filters, errorLabel) { + const complete = RelayGroup.completeOnAny( + RelayGroup.completeAfterFirstRelay(5_000), + RelayGroup.completeOnAllEose() + ); + + const seen = new Set(); + const events = []; + + return new Promise((resolve) => { + this.nostrRelay.pool + .req(this.activeReadRelays, filters) + .pipe( + completeWhen(complete), + timeout({ first: 30_000 }), + filter((message) => message.type === 'EVENT') + ) + .subscribe({ + next: (message) => { + this._recordProvenance(message.event.id, message.from); + this.store.add(message.event); + if (!seen.has(message.event.id)) { + seen.add(message.event.id); + events.push(message.event); + } + }, + error: (err) => { + console.error(errorLabel, err); + resolve(events); + }, + complete: () => resolve(events), + }); + }); + } + + /** + * Fetches kind 360 (Place Photo) events for the activity feed as a Promise + * that resolves on EOSE. Loads from the IDB cache first (instant), then from + * the network with provenance tracking for trust filtering. + * + * - `'home'` mode: fetches photos authored by the user's followed contacts + * (followees), batched by author pubkey. + * - `'explore'` mode: fetches all kind 360 photos in the time window from + * trusted relays (no authors filter). + * + * @param {number} since Unix timestamp (seconds) for the start of the window + * @param {number} [until] Unix timestamp (seconds) for the end of the window + * @param {string} [mode='home'] Source mode + * @returns {Promise} Deduplicated events + */ + async fetchActivityPhotos(since, until, mode = 'home') { + let filters; + if (mode === 'home') { + const pubkeys = this._contactPubkeys + ? Array.from(this._contactPubkeys) + : []; + if (pubkeys.length === 0) return []; + filters = this._batchAuthorFilters(pubkeys, [360], since, until); + } else if (mode === 'explore') { + const filter = { kinds: [360], since }; + if (until !== undefined) filter.until = until; + filters = [filter]; + } else { + return []; + } + + // 1. Populate the store from the local Nostr IDB cache (instant) + const cacheEvents = await this._cachePromise + .then(() => this.cache.query(filters)) + .catch(() => []); + + const seen = new Set(); + const all = []; + + if (cacheEvents) { + for (const event of cacheEvents) { + if (!seen.has(event.id)) { + seen.add(event.id); + all.push(event); + this.store.add(event); + } + } + } + + // 2. Request fresh events from the network (resolves on EOSE) + const networkEvents = await this._fetchEventsWithProvenance( + filters, + '[nostr-data] Error fetching activity photos:' + ); + + for (const event of networkEvents) { + if (!seen.has(event.id)) { + seen.add(event.id); + all.push(event); + } + } + + return all; + } + + /** + * Fetches incoming zap receipts (kind 9735) where the user is the recipient, + * as a Promise that resolves on EOSE. Loads from the IDB cache first, then + * from the network. + * + * @param {string} pubkey The user's Nostr pubkey + * @param {number} [since] Unix timestamp (seconds) for the start of the window + * @param {number} [until] Unix timestamp (seconds) for the end of the window + * @returns {Promise} Deduplicated zap receipt events + */ + async fetchIncomingZaps(pubkey, since, until) { + if (!pubkey) return []; + + const filter = { kinds: [9735], '#p': [pubkey] }; + if (since !== undefined) filter.since = since; + if (until !== undefined) filter.until = until; + const filters = [filter]; + + const seen = new Set(); + const all = []; + + // 1. IDB cache + const cacheEvents = await this._cachePromise + .then(() => this.cache.query(filters)) + .catch(() => []); + + if (cacheEvents) { + for (const event of cacheEvents) { + if (!seen.has(event.id)) { + seen.add(event.id); + all.push(event); + this.store.add(event); + } + } + } + + // 2. Network (resolves on EOSE) + const networkEvents = await this._fetchEventsWithProvenance( + filters, + '[nostr-data] Error fetching incoming zap receipts:' + ); + + for (const event of networkEvents) { + if (!seen.has(event.id)) { + seen.add(event.id); + all.push(event); + } + } + + return all; + } + /** * Builds an array of Nostr filters, each with ≤ `BATCH_SIZE` author pubkeys. * @@ -730,15 +895,17 @@ export default class NostrDataService extends Service { * @param {number} since Unix timestamp (seconds) for the start of the window * @returns {object[]} Array of filter objects */ - _batchAuthorFilters(pubkeys, kinds, since) { + _batchAuthorFilters(pubkeys, kinds, since, until) { const BATCH_SIZE = 100; const filters = []; for (let i = 0; i < pubkeys.length; i += BATCH_SIZE) { - filters.push({ + const filter = { kinds, authors: pubkeys.slice(i, i + BATCH_SIZE), since, - }); + }; + if (until !== undefined) filter.until = until; + filters.push(filter); } return filters; } @@ -780,6 +947,11 @@ export default class NostrDataService extends Service { this._contactPubkeys = null; this.blossomServers = []; + // Create a deferred that resolves when contacts are first loaded + this._contactsPromise = new Promise((resolve) => { + this._contactsResolver = resolve; + }); + this._cleanupSubscriptions(); // Setup models to track state reactively FIRST @@ -801,6 +973,9 @@ export default class NostrDataService extends Service { .subscribe((contacts) => { this.contacts = contacts; this._contactPubkeys = new Set(contacts.map((c) => c.pubkey)); + // Resolve the deferred so callers awaiting whenContactsLoaded proceed. + this._contactsResolver?.(); + this._contactsResolver = null; this._updatePlacePhotos(); // Re-trigger activity photo load now that contacts are available. // If no activity load has been requested yet, _activityPhotosSince @@ -876,6 +1051,23 @@ export default class NostrDataService extends Service { }); } + /** + * Returns a promise that resolves when contacts are first loaded, or + * immediately if already loaded. Times out after 5s so callers don't hang + * forever if contacts never arrive. + * + * @param {number} [timeout=5000] Timeout in milliseconds + * @returns {Promise} + */ + whenContactsLoaded(timeout = 5000) { + if (this._contactPubkeys) return Promise.resolve(); + if (!this._contactsPromise) return Promise.resolve(); + return Promise.race([ + this._contactsPromise, + new Promise((resolve) => setTimeout(resolve, timeout)), + ]); + } + get userDisplayName() { if (this.profile) { if (this.profile.nip05) { diff --git a/app/styles/app.css b/app/styles/app.css index 2409f0f..e69d4ed 100644 --- a/app/styles/app.css +++ b/app/styles/app.css @@ -2700,6 +2700,19 @@ button.create-place { margin: -1rem -1rem 0; } +.activity-load-more { + list-style: none; + text-align: center; + padding: 2rem 1rem 1rem; + min-height: 48px; +} + +.activity-load-more-spinner { + display: flex; + justify-content: center; + align-items: center; +} + .activity-item { width: 100%; text-align: left; @@ -2853,6 +2866,10 @@ button.create-place { white-space: nowrap; flex: 1 1 auto; min-width: 0; + + & .place-name-text { + animation: place-name-fade-in 0.15s ease-out; + } } & .activity-date { @@ -2862,3 +2879,13 @@ button.create-place { } } } + +@keyframes place-name-fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} diff --git a/app/templates/activity.gjs b/app/templates/activity.gjs index 6726728..b46abad 100644 --- a/app/templates/activity.gjs +++ b/app/templates/activity.gjs @@ -4,10 +4,12 @@ import ActivityTimeline from '#components/activity-timeline'; {{#if @controller.mapUi.isSidebarVisible}} {}; + this.isLoadingMore = false; + this.onLoadMore = () => {}; }); test('it renders a loading state', async function (assert) { @@ -30,6 +32,8 @@ module('Integration | Component | activity-timeline', function (hooks) { @onBack={{this.noop}} @onClose={{this.noop}} @onNostrConnected={{this.noop}} + @isLoadingMore={{this.isLoadingMore}} + @onLoadMore={{this.onLoadMore}} /> ); @@ -51,6 +55,8 @@ module('Integration | Component | activity-timeline', function (hooks) { @onBack={{this.noop}} @onClose={{this.noop}} @onNostrConnected={{this.noop}} + @isLoadingMore={{this.isLoadingMore}} + @onLoadMore={{this.onLoadMore}} /> ); @@ -72,6 +78,8 @@ module('Integration | Component | activity-timeline', function (hooks) { @onBack={{this.noop}} @onClose={{this.noop}} @onNostrConnected={{this.noop}} + @isLoadingMore={{this.isLoadingMore}} + @onLoadMore={{this.onLoadMore}} /> ); @@ -96,6 +104,8 @@ module('Integration | Component | activity-timeline', function (hooks) { @onBack={{this.noop}} @onClose={{this.noop}} @onNostrConnected={{this.noop}} + @isLoadingMore={{this.isLoadingMore}} + @onLoadMore={{this.onLoadMore}} /> ); @@ -151,6 +161,8 @@ module('Integration | Component | activity-timeline', function (hooks) { @onBack={{this.noop}} @onClose={{this.noop}} @onNostrConnected={{this.noop}} + @isLoadingMore={{this.isLoadingMore}} + @onLoadMore={{this.onLoadMore}} /> ); @@ -180,6 +192,8 @@ module('Integration | Component | activity-timeline', function (hooks) { @onBack={{this.handleBack}} @onClose={{this.noop}} @onNostrConnected={{this.noop}} + @isLoadingMore={{this.isLoadingMore}} + @onLoadMore={{this.onLoadMore}} /> ); @@ -201,6 +215,8 @@ module('Integration | Component | activity-timeline', function (hooks) { @onBack={{this.noop}} @onClose={{this.noop}} @onNostrConnected={{this.noop}} + @isLoadingMore={{this.isLoadingMore}} + @onLoadMore={{this.onLoadMore}} /> ); @@ -230,6 +246,8 @@ module('Integration | Component | activity-timeline', function (hooks) { @onBack={{this.noop}} @onClose={{this.noop}} @onNostrConnected={{this.noop}} + @isLoadingMore={{this.isLoadingMore}} + @onLoadMore={{this.onLoadMore}} /> ); @@ -275,12 +293,14 @@ module('Integration | Component | activity-timeline', function (hooks) { @onBack={{this.noop}} @onClose={{this.noop}} @onNostrConnected={{this.noop}} + @isLoadingMore={{this.isLoadingMore}} + @onLoadMore={{this.onLoadMore}} /> ); assert.dom('.activity-list').exists('items render in explore mode'); - assert.dom('.activity-list li').exists({ count: 1 }); + assert.dom('.activity-list .activity-item').exists({ count: 1 }); assert.dom('.empty-state').doesNotExist('no connect prompt in explore'); }); @@ -299,6 +319,8 @@ module('Integration | Component | activity-timeline', function (hooks) { @onBack={{this.noop}} @onClose={{this.noop}} @onNostrConnected={{this.noop}} + @isLoadingMore={{this.isLoadingMore}} + @onLoadMore={{this.onLoadMore}} /> ); @@ -306,4 +328,87 @@ module('Integration | Component | activity-timeline', function (hooks) { assert.dom('.empty-state').includesText('No activity yet.'); assert.dom('.empty-state').doesNotIncludeText('Connect'); }); + + test('load-more sentinel renders when items exist', async function (assert) { + this.items = [ + { + type: 'zap', + photoEventId: 'photo-1', + photo: { + url: 'https://x.com/photo.jpg', + thumbUrl: 'https://x.com/thumb.jpg', + }, + placeIdentifier: 'osm:node:111', + senderPubkey: 'b'.repeat(64), + amountSats: 21, + message: 'Nice!', + createdAt: 2000, + senderName: 'Alice', + senderAvatar: 'https://x.com/avatar.jpg', + senderProfileLoading: false, + }, + ]; + + await render( + + ); + + assert.dom('.activity-load-more').exists('sentinel renders'); + }); + + test('load-more spinner shows when isLoadingMore is true', async function (assert) { + this.items = [ + { + type: 'zap', + photoEventId: 'photo-1', + photo: { + url: 'https://x.com/photo.jpg', + thumbUrl: 'https://x.com/thumb.jpg', + }, + placeIdentifier: 'osm:node:111', + senderPubkey: 'b'.repeat(64), + amountSats: 21, + message: 'Nice!', + createdAt: 2000, + senderName: 'Alice', + senderAvatar: 'https://x.com/avatar.jpg', + senderProfileLoading: false, + }, + ]; + this.isLoadingMore = true; + + await render( + + ); + + assert.dom('.activity-load-more-spinner').exists('spinner shows'); + }); }); diff --git a/tests/integration/components/activity-zap-item-test.gjs b/tests/integration/components/activity-zap-item-test.gjs index e5254e3..09ff345 100644 --- a/tests/integration/components/activity-zap-item-test.gjs +++ b/tests/integration/components/activity-zap-item-test.gjs @@ -175,7 +175,7 @@ module('Integration | Component | activity-zap-item', function (hooks) { .doesNotExist('No loading/fallback text shown'); }); - test('it shows "Loading…" when placeNameLoading is true', async function (assert) { + test('it shows no text when placeNameLoading is true', async function (assert) { this.item = new ActivityEntry({ photoEventId: 'photo-1', photo: null, @@ -197,8 +197,11 @@ module('Integration | Component | activity-zap-item', function (hooks) { ); assert - .dom('.context-text .contribution-name-loading') - .hasText('Loading…', 'Loading state is displayed'); + .dom('.context-text .activity-place-name') + .hasText('', 'No text shown while loading'); + assert + .dom('.context-text .place-name-text') + .doesNotExist('No place-name-text shown while loading'); }); test('it shows "Unnamed place" when not loading and no placeName', async function (assert) { @@ -223,7 +226,7 @@ module('Integration | Component | activity-zap-item', function (hooks) { ); assert - .dom('.context-text .contribution-name-loading') + .dom('.context-text .place-name-text') .hasText('Unnamed place', 'Fallback name is displayed'); }); diff --git a/tests/unit/services/activity-test.js b/tests/unit/services/activity-test.js index 74d3d75..79c796b 100644 --- a/tests/unit/services/activity-test.js +++ b/tests/unit/services/activity-test.js @@ -124,6 +124,19 @@ class MockNostrDataService extends Service { loadProfiles() {} loadActivityPhotos() {} + loadMyContributions() {} + loadProfile() {} + whenContactsLoaded() { + return Promise.resolve(); + } + + async fetchActivityPhotos() { + return []; + } + + async fetchIncomingZaps() { + return []; + } getProfile(pubkey) { return this.profiles[pubkey]; @@ -481,12 +494,11 @@ module('Unit | Service | activity', function (hooks) { ); }); - test('setSourceMode re-filters existing events', function (assert) { + test('setSourceMode resets and re-filters with fresh 30-day window', async function (assert) { const service = this.owner.lookup('service:activity'); service._userPubkey = USER_PUBKEY; service._sourceMode = 'explore'; service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]); - // In explore mode, isTrustedEvent returns false (no provenance in mock) service.nostrData.isTrustedEvent = () => false; const followedPhoto = makePhotoEvent({ @@ -495,22 +507,21 @@ module('Unit | Service | activity', function (hooks) { placeIdentifier: 'osm:node:100', created_at: 1000, }); - service._lastSocialEvents = [followedPhoto]; - // In 'explore' mode, _matchesSourceMode returns false (isTrustedEvent is false) + service.nostrData.fetchActivityPhotos = async (_since, _until, mode) => { + if (mode === 'home') return [followedPhoto]; + return []; + }; + service._updateSocialItems([followedPhoto]); - assert.strictEqual( - service.items.length, - 0, - 'no social items in explore mode with untrusted events' - ); + assert.strictEqual(service.items.length, 0, 'no items in explore mode'); - // Switch to 'home' mode — now the followed photo should appear - service.setSourceMode('home'); - assert.strictEqual( - service.items.length, - 1, - 're-filtered shows followed contact in home mode' + await service.setSourceMode('home'); + + assert.strictEqual(service._sourceMode, 'home', 'mode switched'); + assert.ok( + service.items.length > 0, + 'items loaded after switching to home mode' ); }); @@ -534,4 +545,234 @@ module('Unit | Service | activity', function (hooks) { assert.strictEqual(service._socialItems.length, 0, 'social items cleared'); assert.strictEqual(service._zapItems.length, 0, 'zap items cleared'); }); + + test('loadMore fetches older events with until set to old since', async function (assert) { + const service = this.owner.lookup('service:activity'); + service._userPubkey = USER_PUBKEY; + service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]); + + const NOW = Math.floor(Date.now() / 1000); + service._since = NOW; + + const recentPhoto = makePhotoEvent({ + id: 'r1'.padEnd(64, '0'), + author: SENDER_PUBKEY, + placeIdentifier: 'osm:node:100', + created_at: NOW - 100, + }); + service._updateSocialItems([recentPhoto]); + assert.ok(service.items.length > 0, 'has initial items'); + + const oldPhoto = makePhotoEvent({ + id: 'old1'.padEnd(64, '0'), + author: SENDER_PUBKEY, + placeIdentifier: 'osm:node:200', + created_at: NOW - 5000, + }); + service.nostrData.fetchActivityPhotos = async (since, until) => { + assert.strictEqual(since, NOW - 30 * 24 * 60 * 60, 'since extended'); + assert.strictEqual(until, NOW, 'until set to old since'); + return [oldPhoto]; + }; + + await service.loadMore(); + + assert.strictEqual( + service._since, + NOW - 30 * 24 * 60 * 60, + '_since extended' + ); + assert.false(service.isLoadingMore, 'isLoadingMore reset'); + assert.false(service._isLoadingMore, '_isLoadingMore reset'); + assert.ok( + service.items.length >= 2, + 'new items appended without re-processing' + ); + }); + + test('loadMore fetches zaps alongside photos in home mode', async function (assert) { + const service = this.owner.lookup('service:activity'); + service._userPubkey = USER_PUBKEY; + service._sourceMode = 'home'; + service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]); + + const NOW = Math.floor(Date.now() / 1000); + service._since = NOW; + + // Seed initial photo so loadMore doesn't bail + const recentPhoto = makePhotoEvent({ + id: 'r5'.padEnd(64, '0'), + author: SENDER_PUBKEY, + placeIdentifier: 'osm:node:100', + created_at: NOW - 100, + }); + service._updateSocialItems([recentPhoto]); + + // Also seed a photo for the zap to reference + const zapPhoto = makePhotoEvent({ + id: PHOTO_EVENT_ID_1, + author: USER_PUBKEY, + placeIdentifier: 'osm:node:50', + created_at: NOW - 200, + }); + service.nostrData.store.add(zapPhoto); + + const oldZapReceipt = makeZapReceiptEvent({ + zappedEventId: PHOTO_EVENT_ID_1, + created_at: NOW - 5000, + }); + + let zapCallCount = 0; + service.nostrData.fetchActivityPhotos = async () => []; + service.nostrData.fetchIncomingZaps = async (_pubkey, since, until) => { + zapCallCount++; + assert.strictEqual(since, NOW - 30 * 24 * 60 * 60, 'zap since matches'); + assert.strictEqual(until, NOW, 'zap until matches'); + return [oldZapReceipt]; + }; + + await service.loadMore(); + + assert.strictEqual(zapCallCount, 1, 'fetchIncomingZaps called once'); + assert.ok(service.items.length >= 2, 'zap and photo items both present'); + }); + + test('loadMore exponentially expands window on empty results', async function (assert) { + const service = this.owner.lookup('service:activity'); + service._userPubkey = USER_PUBKEY; + service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]); + + const NOW = Math.floor(Date.now() / 1000); + service._since = NOW; + + const recentPhoto = makePhotoEvent({ + id: 'r2'.padEnd(64, '0'), + author: SENDER_PUBKEY, + placeIdentifier: 'osm:node:100', + created_at: NOW - 100, + }); + service._updateSocialItems([recentPhoto]); + + const callArgs = []; + let callCount = 0; + service.nostrData.fetchActivityPhotos = async (since, until) => { + callArgs.push({ since, until }); + callCount++; + if (callCount < 2) return []; + return [ + makePhotoEvent({ + id: 'old2'.padEnd(64, '0'), + author: SENDER_PUBKEY, + placeIdentifier: 'osm:node:300', + created_at: NOW - 100000, + }), + ]; + }; + + await service.loadMore(); + + assert.strictEqual(callCount, 2, 'retried with expanding window'); + assert.strictEqual(callArgs[0].since, NOW - 30 * 24 * 60 * 60); + assert.strictEqual( + callArgs[1].since, + NOW - 30 * 24 * 60 * 60 - 60 * 24 * 60 * 60 + ); + assert.ok(service.items.length >= 2, 'events from 2nd call appended'); + }); + + test('loadMore stops at MIN_SINCE floor with clamped final fetch', async function (assert) { + const service = this.owner.lookup('service:activity'); + service._userPubkey = USER_PUBKEY; + service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]); + + const MIN_SINCE = Math.floor(new Date('2026-04-20').getTime() / 1000); + service._since = MIN_SINCE + 60 * 24 * 60 * 60; + + const recentPhoto = makePhotoEvent({ + id: 'r3'.padEnd(64, '0'), + author: SENDER_PUBKEY, + placeIdentifier: 'osm:node:100', + created_at: service._since - 100, + }); + service._updateSocialItems([recentPhoto]); + + let callCount = 0; + service.nostrData.fetchActivityPhotos = async () => { + callCount++; + return []; + }; + + await service.loadMore(); + + // First 30-day window: batchSince = MIN_SINCE + 30d (>= MIN_SINCE, executes) + // Second 60-day window: batchSince = MIN_SINCE - 30d (< MIN_SINCE, clamped to MIN_SINCE for final fetch) + assert.strictEqual(callCount, 2, 'one normal + one clamped final fetch'); + assert.false(service.isLoadingMore, 'isLoadingMore reset after exhaustion'); + }); + + test('loadMore guard prevents concurrent calls', async function (assert) { + const service = this.owner.lookup('service:activity'); + service._userPubkey = USER_PUBKEY; + service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]); + + const NOW = Math.floor(Date.now() / 1000); + service._since = NOW; + + const recentPhoto = makePhotoEvent({ + id: 'r4'.padEnd(64, '0'), + author: SENDER_PUBKEY, + placeIdentifier: 'osm:node:100', + created_at: NOW - 100, + }); + service._updateSocialItems([recentPhoto]); + + let callCount = 0; + service.nostrData.fetchActivityPhotos = async () => { + callCount++; + return [ + makePhotoEvent({ + id: 'old3'.padEnd(64, '0'), + author: SENDER_PUBKEY, + placeIdentifier: 'osm:node:200', + created_at: NOW - 5000, + }), + ]; + }; + + const promise1 = service.loadMore(); + await service.loadMore(); + await promise1; + + assert.strictEqual(callCount, 1, 'only one loadMore sequence'); + }); + + test('loadMore does nothing when no items', async function (assert) { + const service = this.owner.lookup('service:activity'); + service._userPubkey = USER_PUBKEY; + service._since = Math.floor(Date.now() / 1000); + service.items = []; + + let called = false; + service.nostrData.fetchActivityPhotos = async () => { + called = true; + return []; + }; + + await service.loadMore(); + + assert.false(called, 'no fetch when items is empty'); + }); + + test('stop resets isLoadingMore and isLoading', function (assert) { + const service = this.owner.lookup('service:activity'); + service._isLoadingMore = true; + service.isLoadingMore = true; + service.isLoading = true; + + service.stop(); + + assert.false(service.isLoadingMore, 'isLoadingMore reset'); + assert.false(service._isLoadingMore, '_isLoadingMore reset'); + assert.false(service.isLoading, 'isLoading reset'); + }); }); diff --git a/tests/unit/services/nostr-data-test.js b/tests/unit/services/nostr-data-test.js index 6c5c8ae..0480df9 100644 --- a/tests/unit/services/nostr-data-test.js +++ b/tests/unit/services/nostr-data-test.js @@ -1,6 +1,6 @@ import { module, test } from 'qunit'; import { setupTest } from 'marco/tests/helpers'; -import { Subject, EMPTY } from 'rxjs'; +import { Subject, EMPTY, of } from 'rxjs'; import Service from '@ember/service'; import NostrDataService from 'marco/services/nostr-data'; import { getGeohashPrefixesInBbox } from 'marco/utils/geohash-coverage'; @@ -205,6 +205,58 @@ module('Unit | Service | nostr-data | contacts', function (hooks) { ); }); + test('whenContactsLoaded resolves after contacts are loaded', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + const pubkey = makePubkey(1); + const contactA = makePubkey(2); + + // Start loadProfile (sets up the deferred + subscriptions) + await service.loadProfile(pubkey); + + // Contacts not loaded yet — whenContactsLoaded should not resolve yet + // (it will resolve after timeout, but we'll add the event first) + + // Add a contacts event to the store — the ContactsModel subscription fires + service.store.add(makeContactsEvent(pubkey, [contactA])); + + // Give the model a tick to process + await new Promise((r) => setTimeout(r, 50)); + + await service.whenContactsLoaded(); + + assert.ok(service._contactPubkeys, 'contacts loaded'); + assert.true( + service._contactPubkeys.has(contactA), + 'contact pubkey present' + ); + }); + + test('whenContactsLoaded resolves immediately when contacts already loaded', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + const pubkey = makePubkey(1); + const contactA = makePubkey(2); + + service.store.add(makeContactsEvent(pubkey, [contactA])); + await service.loadProfile(pubkey); + await new Promise((r) => setTimeout(r, 50)); + + // Contacts are already loaded — whenContactsLoaded should resolve immediately + await service.whenContactsLoaded(); + + assert.true(service._contactPubkeys.has(contactA), 'contacts available'); + }); + + test('whenContactsLoaded resolves immediately when no profile loaded', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + // No loadProfile called — should resolve immediately + await service.whenContactsLoaded(); + + assert.true(true, 'resolved without hanging'); + }); + test('kind 3 events are persisted to IDB cache', async function (assert) { const service = this.owner.lookup('service:nostr-data'); @@ -977,6 +1029,190 @@ module('Unit | Service | nostr-data | activity photos', function (hooks) { assert.strictEqual(filters[2].authors.length, 50); assert.deepEqual(filters[0].kinds, [360]); assert.strictEqual(filters[0].since, 1000); + assert.notOk(filters[0].until, 'no until when not provided'); + }); + + test('_batchAuthorFilters includes until when provided', function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + const pubkeys = Array.from({ length: 5 }, (_, i) => makePubkey(i)); + const filters = service._batchAuthorFilters(pubkeys, [360], 1000, 2000); + + assert.strictEqual(filters.length, 1); + assert.strictEqual(filters[0].since, 1000); + assert.strictEqual(filters[0].until, 2000); + }); +}); + +module('Unit | Service | nostr-data | fetchActivityPhotos', function (hooks) { + setupNostrDataService(hooks); + + test('fetchActivityPhotos in home mode returns events from network', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + const userPubkey = makePubkey(1); + const contactPubkey = makePubkey(2); + const photo = makePhotoEvent(contactPubkey, 'osm:node:100', { + id: makeEventId(100), + }); + + service.store.add(makeContactsEvent(userPubkey, [contactPubkey])); + await service.loadProfile(userPubkey); + + service.nostrRelay.pool.req = (_relays, filters) => { + this.requestedFilters.push(...filters); + return of({ + type: 'EVENT', + event: photo, + from: 'wss://relay.example', + }); + }; + + const events = await service.fetchActivityPhotos(1000, undefined, 'home'); + + assert.ok( + events.some((e) => e.id === photo.id), + 'photo event from network returned' + ); + }); + + test('fetchActivityPhotos includes until in filters', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + const userPubkey = makePubkey(1); + const contactPubkey = makePubkey(2); + + service.store.add(makeContactsEvent(userPubkey, [contactPubkey])); + await service.loadProfile(userPubkey); + + service.nostrRelay.pool.req = (_relays, filters) => { + this.requestedFilters.push(...filters); + return EMPTY; + }; + + await service.fetchActivityPhotos(1000, 2000, 'home'); + + const photoFilter = this.requestedFilters.find( + (f) => f.kinds?.includes(360) && f.authors + ); + assert.ok(photoFilter, 'photo filter found'); + assert.strictEqual(photoFilter.since, 1000, 'since value matches'); + assert.strictEqual(photoFilter.until, 2000, 'until value matches'); + }); + + test('fetchActivityPhotos in explore mode does not include authors filter', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + service.nostrRelay.pool.req = (_relays, filters) => { + this.requestedFilters.push(...filters); + return EMPTY; + }; + + await service.fetchActivityPhotos(9999, undefined, 'explore'); + + const photoFilter = this.requestedFilters.find( + (f) => f.kinds?.includes(360) && !f.authors + ); + assert.ok(photoFilter, 'photo filter without authors found'); + assert.strictEqual(photoFilter.since, 9999); + assert.notOk(photoFilter.until, 'no until when not provided'); + }); + + test('fetchActivityPhotos in explore mode includes until', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + service.nostrRelay.pool.req = (_relays, filters) => { + this.requestedFilters.push(...filters); + return EMPTY; + }; + + await service.fetchActivityPhotos(1000, 2000, 'explore'); + + const photoFilter = this.requestedFilters.find( + (f) => f.kinds?.includes(360) && !f.authors + ); + assert.ok(photoFilter, 'photo filter found'); + assert.strictEqual(photoFilter.since, 1000); + assert.strictEqual(photoFilter.until, 2000); + }); + + test('fetchActivityPhotos returns empty array when no contacts in home mode', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + service.nostrRelay.pool.req = () => EMPTY; + + const events = await service.fetchActivityPhotos(1000, undefined, 'home'); + + assert.strictEqual(events.length, 0, 'empty array when no contacts'); + }); + + test('fetchActivityPhotos deduplicates cache and network events', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + const userPubkey = makePubkey(1); + const contactPubkey = makePubkey(2); + const photo = makePhotoEvent(contactPubkey, 'osm:node:100', { + id: makeEventId(100), + }); + + service.store.add(makeContactsEvent(userPubkey, [contactPubkey])); + await service.loadProfile(userPubkey); + + // Add to IDB cache + await service.cache.add(photo); + + service.nostrRelay.pool.req = (_relays, filters) => { + this.requestedFilters.push(...filters); + return of({ + type: 'EVENT', + event: photo, + from: 'wss://relay.example', + }); + }; + + const events = await service.fetchActivityPhotos(1000, undefined, 'home'); + + assert.strictEqual( + events.filter((e) => e.id === photo.id).length, + 1, + 'photo appears only once despite being in both cache and network' + ); + }); + + test('fetchIncomingZaps includes since and until in filter', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + service.nostrRelay.pool.req = (_relays, filters) => { + this.requestedFilters.push(...filters); + return EMPTY; + }; + + await service.fetchIncomingZaps(makePubkey(1), 1000, 2000); + + const zapFilter = this.requestedFilters.find((f) => + f.kinds?.includes(9735) + ); + assert.ok(zapFilter, 'zap filter found'); + assert.strictEqual(zapFilter.since, 1000, 'since value matches'); + assert.strictEqual(zapFilter.until, 2000, 'until value matches'); + }); + + test('fetchIncomingZaps without since/until does not include them', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + service.nostrRelay.pool.req = (_relays, filters) => { + this.requestedFilters.push(...filters); + return EMPTY; + }; + + await service.fetchIncomingZaps(makePubkey(1)); + + const zapFilter = this.requestedFilters.find((f) => + f.kinds?.includes(9735) + ); + assert.ok(zapFilter, 'zap filter found'); + assert.notOk('since' in zapFilter, 'no since when not provided'); + assert.notOk('until' in zapFilter, 'no until when not provided'); }); }); -- 2.50.1 From 39e5240af215105bd7807c890ba4fe103c3c1ceb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Wed, 2 Sep 2026 18:48:50 -0600 Subject: [PATCH 5/8] Use new icon in My Contributions header --- app/components/contributions-timeline.gjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 { -- 2.50.1 From 1b60915e3fc3347b49d75490623ad22c86cb014c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Thu, 3 Sep 2026 08:15:34 -0600 Subject: [PATCH 6/8] Load activity items from cache immediately, network later Considerably improves time to first render, and usually there isn't anything more to load once having opened the cache before in the same session --- app/services/activity.js | 156 ++++++++++++++++--------- app/services/nostr-data.js | 77 ++++++------ tests/unit/services/activity-test.js | 72 +++++++----- tests/unit/services/nostr-data-test.js | 31 +++-- 4 files changed, 215 insertions(+), 121 deletions(-) diff --git a/app/services/activity.js b/app/services/activity.js index 1996532..183e01a 100644 --- a/app/services/activity.js +++ b/app/services/activity.js @@ -44,6 +44,7 @@ export default class ActivityService extends Service { _since = null; _sourceMode = 'home'; _isLoadingMore = false; + _cacheReadyCallback = null; /** * Loads the user's incoming zap receipts and social photo activity. @@ -64,6 +65,9 @@ export default class ActivityService extends Service { this._userPubkey = pubkey || null; this._since = Math.floor(Date.now() / 1000) - SINCE_WINDOW; this.isLoading = true; + this._cacheReadyCallback = () => { + this.isLoading = false; + }; try { if (pubkey) { @@ -81,6 +85,7 @@ export default class ActivityService extends Service { await this._fetchWithBackoff(this._since, now); } finally { this.isLoading = false; + this._cacheReadyCallback = null; } } @@ -103,12 +108,16 @@ export default class ActivityService extends Service { this._mergeItems(); this.isLoading = true; + this._cacheReadyCallback = () => { + this.isLoading = false; + }; try { const now = Math.floor(Date.now() / 1000); await this._fetchWithBackoff(this._since, now); } finally { this.isLoading = false; + this._cacheReadyCallback = null; } } @@ -159,85 +168,126 @@ export default class ActivityService extends Service { super.willDestroy(...arguments); } + /** + * 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. + * 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 newEvents = await this.nostrData.fetchActivityPhotos( + const photoResult = await this.nostrData.fetchActivityPhotos( batchSince, batchUntil, this._sourceMode ); - let newZapEvents = []; + let zapResult = { + cacheEvents: [], + networkEvents: Promise.resolve([]), + }; if (this._sourceMode === 'home' && this._userPubkey) { - newZapEvents = await this.nostrData.fetchIncomingZaps( + zapResult = await this.nostrData.fetchIncomingZaps( this._userPubkey, batchSince, batchUntil ); } - if (newEvents.length === 0 && newZapEvents.length === 0) { - return false; - } - + // Phase 1: Process cache events immediately let added = false; - if (newEvents.length > 0) { - this._lastSocialEvents = [...this._lastSocialEvents, ...newEvents]; - - const filtered = newEvents.filter((e) => this._matchesSourceMode(e)); - if (filtered.length > 0) { - const newEntries = groupSocialPhotos(filtered); - - for (const entry of newEntries) { - this._resolveSender(entry); - } - - for (const entry of newEntries) { - if (entry.osmId) { - const bookmarkName = this.placeNameResolver.resolveBookmark( - entry.osmId - ); - if (bookmarkName) { - entry.placeName = bookmarkName; - entry.placeNameLoading = false; - } - } - } - - this._socialItems = [...this._socialItems, ...newEntries]; - this._mergeItems(); - - void this.placeNameResolver - .resolveInBackground(newEntries) - .then(() => this._mergeItems()); - - added = true; - } + if (photoResult.cacheEvents.length > 0) { + added = this._processPhotoEvents(photoResult.cacheEvents) || added; + } + if (zapResult.cacheEvents.length > 0) { + added = + this._processZapEventsIntoTimeline(zapResult.cacheEvents) || added; } - if (newZapEvents.length > 0) { - const newZapEntries = this._processZapEvents( - newZapEvents, - this._userPubkey - ); - if (newZapEntries.length > 0) { - this._zapItems = [...this._zapItems, ...newZapEntries]; - this._mergeItems(); + // Signal that cached items are ready for display + if (added) { + this._cacheReadyCallback?.(); + } - void this.placeNameResolver - .resolveInBackground(newZapEntries) - .then(() => this._mergeItems()); + // Phase 2: Await network events and process them + const [networkPhotos, networkZaps] = await Promise.all([ + photoResult.networkEvents, + zapResult.networkEvents, + ]); - added = true; - } + if (networkPhotos.length > 0) { + added = this._processPhotoEvents(networkPhotos) || added; + } + if (networkZaps.length > 0) { + added = this._processZapEventsIntoTimeline(networkZaps) || added; } return added; diff --git a/app/services/nostr-data.js b/app/services/nostr-data.js index 895ae12..a911d64 100644 --- a/app/services/nostr-data.js +++ b/app/services/nostr-data.js @@ -771,9 +771,10 @@ export default class NostrDataService extends Service { } /** - * Fetches kind 360 (Place Photo) events for the activity feed as a Promise - * that resolves on EOSE. Loads from the IDB cache first (instant), then from - * the network with provenance tracking for trust filtering. + * 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. @@ -783,7 +784,7 @@ export default class NostrDataService extends Service { * @param {number} since Unix timestamp (seconds) for the start of the window * @param {number} [until] Unix timestamp (seconds) for the end of the window * @param {string} [mode='home'] Source mode - * @returns {Promise} Deduplicated events + * @returns {Promise<{cacheEvents: object[], networkEvents: Promise}>} */ async fetchActivityPhotos(since, until, mode = 'home') { let filters; @@ -791,62 +792,66 @@ export default class NostrDataService extends Service { const pubkeys = this._contactPubkeys ? Array.from(this._contactPubkeys) : []; - if (pubkeys.length === 0) return []; + 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 []; + 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(() => []); - const seen = new Set(); - const all = []; - if (cacheEvents) { for (const event of cacheEvents) { if (!seen.has(event.id)) { seen.add(event.id); - all.push(event); + cacheResult.push(event); this.store.add(event); } } } // 2. Request fresh events from the network (resolves on EOSE) - const networkEvents = await this._fetchEventsWithProvenance( + const networkEvents = this._fetchEventsWithProvenance( filters, '[nostr-data] Error fetching activity photos:' - ); - - for (const event of networkEvents) { - if (!seen.has(event.id)) { - seen.add(event.id); - all.push(event); + ).then((events) => { + const result = []; + for (const event of events) { + if (!seen.has(event.id)) { + seen.add(event.id); + result.push(event); + } } - } + return result; + }); - return all; + return { cacheEvents: cacheResult, networkEvents }; } /** - * Fetches incoming zap receipts (kind 9735) where the user is the recipient, - * as a Promise that resolves on EOSE. Loads from the IDB cache first, then - * from the network. + * 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} Deduplicated zap receipt events + * @returns {Promise<{cacheEvents: object[], networkEvents: Promise}>} */ async fetchIncomingZaps(pubkey, since, until) { - if (!pubkey) return []; + if (!pubkey) return { cacheEvents: [], networkEvents: Promise.resolve([]) }; const filter = { kinds: [9735], '#p': [pubkey] }; if (since !== undefined) filter.since = since; @@ -854,7 +859,7 @@ export default class NostrDataService extends Service { const filters = [filter]; const seen = new Set(); - const all = []; + const cacheResult = []; // 1. IDB cache const cacheEvents = await this._cachePromise @@ -865,26 +870,28 @@ export default class NostrDataService extends Service { for (const event of cacheEvents) { if (!seen.has(event.id)) { seen.add(event.id); - all.push(event); + cacheResult.push(event); this.store.add(event); } } } // 2. Network (resolves on EOSE) - const networkEvents = await this._fetchEventsWithProvenance( + const networkEvents = this._fetchEventsWithProvenance( filters, '[nostr-data] Error fetching incoming zap receipts:' - ); - - for (const event of networkEvents) { - if (!seen.has(event.id)) { - seen.add(event.id); - all.push(event); + ).then((events) => { + const result = []; + for (const event of events) { + if (!seen.has(event.id)) { + seen.add(event.id); + result.push(event); + } } - } + return result; + }); - return all; + return { cacheEvents: cacheResult, networkEvents }; } /** diff --git a/tests/unit/services/activity-test.js b/tests/unit/services/activity-test.js index 79c796b..d992c23 100644 --- a/tests/unit/services/activity-test.js +++ b/tests/unit/services/activity-test.js @@ -131,11 +131,11 @@ class MockNostrDataService extends Service { } async fetchActivityPhotos() { - return []; + return { cacheEvents: [], networkEvents: Promise.resolve([]) }; } async fetchIncomingZaps() { - return []; + return { cacheEvents: [], networkEvents: Promise.resolve([]) }; } getProfile(pubkey) { @@ -509,8 +509,12 @@ module('Unit | Service | activity', function (hooks) { }); service.nostrData.fetchActivityPhotos = async (_since, _until, mode) => { - if (mode === 'home') return [followedPhoto]; - return []; + if (mode === 'home') + return { + cacheEvents: [followedPhoto], + networkEvents: Promise.resolve([]), + }; + return { cacheEvents: [], networkEvents: Promise.resolve([]) }; }; service._updateSocialItems([followedPhoto]); @@ -572,7 +576,10 @@ module('Unit | Service | activity', function (hooks) { service.nostrData.fetchActivityPhotos = async (since, until) => { assert.strictEqual(since, NOW - 30 * 24 * 60 * 60, 'since extended'); assert.strictEqual(until, NOW, 'until set to old since'); - return [oldPhoto]; + return { + cacheEvents: [oldPhoto], + networkEvents: Promise.resolve([]), + }; }; await service.loadMore(); @@ -623,12 +630,18 @@ module('Unit | Service | activity', function (hooks) { }); let zapCallCount = 0; - service.nostrData.fetchActivityPhotos = async () => []; + 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 [oldZapReceipt]; + return { + cacheEvents: [oldZapReceipt], + networkEvents: Promise.resolve([]), + }; }; await service.loadMore(); @@ -658,15 +671,19 @@ module('Unit | Service | activity', function (hooks) { service.nostrData.fetchActivityPhotos = async (since, until) => { callArgs.push({ since, until }); callCount++; - if (callCount < 2) return []; - return [ - makePhotoEvent({ - id: 'old2'.padEnd(64, '0'), - author: SENDER_PUBKEY, - placeIdentifier: 'osm:node:300', - created_at: NOW - 100000, - }), - ]; + 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(); @@ -699,7 +716,7 @@ module('Unit | Service | activity', function (hooks) { let callCount = 0; service.nostrData.fetchActivityPhotos = async () => { callCount++; - return []; + return { cacheEvents: [], networkEvents: Promise.resolve([]) }; }; await service.loadMore(); @@ -729,14 +746,17 @@ module('Unit | Service | activity', function (hooks) { let callCount = 0; service.nostrData.fetchActivityPhotos = async () => { callCount++; - return [ - makePhotoEvent({ - id: 'old3'.padEnd(64, '0'), - author: SENDER_PUBKEY, - placeIdentifier: 'osm:node:200', - created_at: NOW - 5000, - }), - ]; + 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(); @@ -755,7 +775,7 @@ module('Unit | Service | activity', function (hooks) { let called = false; service.nostrData.fetchActivityPhotos = async () => { called = true; - return []; + return { cacheEvents: [], networkEvents: Promise.resolve([]) }; }; await service.loadMore(); diff --git a/tests/unit/services/nostr-data-test.js b/tests/unit/services/nostr-data-test.js index 0480df9..04a458d 100644 --- a/tests/unit/services/nostr-data-test.js +++ b/tests/unit/services/nostr-data-test.js @@ -1068,10 +1068,11 @@ module('Unit | Service | nostr-data | fetchActivityPhotos', function (hooks) { }); }; - const events = await service.fetchActivityPhotos(1000, undefined, 'home'); + const result = await service.fetchActivityPhotos(1000, undefined, 'home'); + const networkEvents = await result.networkEvents; assert.ok( - events.some((e) => e.id === photo.id), + networkEvents.some((e) => e.id === photo.id), 'photo event from network returned' ); }); @@ -1141,9 +1142,19 @@ module('Unit | Service | nostr-data | fetchActivityPhotos', function (hooks) { service.nostrRelay.pool.req = () => EMPTY; - const events = await service.fetchActivityPhotos(1000, undefined, 'home'); + const result = await service.fetchActivityPhotos(1000, undefined, 'home'); - assert.strictEqual(events.length, 0, 'empty array when no contacts'); + 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) { @@ -1170,12 +1181,18 @@ module('Unit | Service | nostr-data | fetchActivityPhotos', function (hooks) { }); }; - const events = await service.fetchActivityPhotos(1000, undefined, 'home'); + const result = await service.fetchActivityPhotos(1000, undefined, 'home'); + const networkEvents = await result.networkEvents; assert.strictEqual( - events.filter((e) => e.id === photo.id).length, + result.cacheEvents.filter((e) => e.id === photo.id).length, 1, - 'photo appears only once despite being in both cache and network' + '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)' ); }); -- 2.50.1 From 9d928217ca2fa05480f1ceb8f10b3b5a7d1cf5fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Thu, 3 Sep 2026 08:25:03 -0600 Subject: [PATCH 7/8] Preserve scroll position when no Nostr connected --- app/services/activity.js | 2 +- tests/unit/services/activity-test.js | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/services/activity.js b/app/services/activity.js index 183e01a..5e7b50f 100644 --- a/app/services/activity.js +++ b/app/services/activity.js @@ -61,7 +61,7 @@ export default class ActivityService extends Service { * @param {string|null} pubkey The user's Nostr pubkey, or null */ async load(pubkey) { - if (this.items.length > 0 && pubkey) return; + if (this.items.length > 0) return; this._userPubkey = pubkey || null; this._since = Math.floor(Date.now() / 1000) - SINCE_WINDOW; this.isLoading = true; diff --git a/tests/unit/services/activity-test.js b/tests/unit/services/activity-test.js index d992c23..cf4e40d 100644 --- a/tests/unit/services/activity-test.js +++ b/tests/unit/services/activity-test.js @@ -273,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) { -- 2.50.1 From 7dfd58d07508084e44f27a7bd4d98f18d896d585 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Thu, 3 Sep 2026 09:33:55 -0600 Subject: [PATCH 8/8] Fix flaky test in CI --- tests/integration/components/search-box-test.gjs | 1 + 1 file changed, 1 insertion(+) 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 }); -- 2.50.1