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' + ); + }); +});