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