Auto-load older activities

Both initially (if not enough recent ones are available), and when
scrolling to the end of an activity timeline
This commit is contained in:
2026-09-02 18:36:58 -06:00
parent a4a8d123d3
commit 35aa3c950f
16 changed files with 1092 additions and 135 deletions
+4 -6
View File
@@ -77,7 +77,7 @@ export default class ActivityPhotoItem extends Component {
<span class="sender-line">
<span class="sender-name">{{this.senderDisplayName}}</span>
{{! template-lint-disable no-whitespace-for-layout }}
<span class="action"> added {{this.photoCountText}}</span>
<span class="action"> shared {{this.photoCountText}}</span>
</span>
<span class="amount">
<Icon
@@ -116,11 +116,9 @@ export default class ActivityPhotoItem extends Component {
<div class="context-text">
<span class="activity-place-name">
{{#if this.placeName}}
{{this.placeName}}
{{else if this.placeNameLoading}}
<span class="contribution-name-loading">Loading…</span>
{{else}}
<span class="contribution-name-loading">Unnamed place</span>
<span class="place-name-text">{{this.placeName}}</span>
{{else if this.placeNameLoading}}{{else}}
<span class="place-name-text">Unnamed place</span>
{{/if}}
</span>
<span class="activity-date">{{formatRelativeDate
+8
View File
@@ -12,6 +12,7 @@ import not from 'ember-truth-helpers/helpers/not';
import eq from 'ember-truth-helpers/helpers/eq';
import and from 'ember-truth-helpers/helpers/and';
import restoreScroll from '../modifiers/restore-scroll';
import observeIntersection from '../modifiers/observe-intersection';
export default class ActivityTimelineComponent extends Component {
@tracked isNostrConnectModalOpen = false;
@@ -87,6 +88,13 @@ export default class ActivityTimelineComponent extends Component {
<ActivityPhotoItem @item={{item}} @onSelect={{@onSelect}} />
{{/if}}
{{/each}}
<li class="activity-load-more" {{observeIntersection @onLoadMore}}>
{{#if @isLoadingMore}}
<div class="activity-load-more-spinner">
<Icon @name="loading-ring" @size={{24}} @color="#898989" />
</div>
{{/if}}
</li>
</ul>
{{/if}}
</div>
+3 -5
View File
@@ -85,11 +85,9 @@ export default class ActivityZapItem extends Component {
<div class="context-text">
<span class="activity-place-name">
{{#if this.placeName}}
{{this.placeName}}
{{else if this.placeNameLoading}}
<span class="contribution-name-loading">Loading…</span>
{{else}}
<span class="contribution-name-loading">Unnamed place</span>
<span class="place-name-text">{{this.placeName}}</span>
{{else if this.placeNameLoading}}{{else}}
<span class="place-name-text">Unnamed place</span>
{{/if}}
</span>
<span class="activity-date">{{formatRelativeDate
+13
View File
@@ -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;
+31
View File
@@ -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();
}
};
});
+2 -1
View File
@@ -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.
}
}
+194 -96
View File
@@ -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<void>}
*/
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<boolean>} 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<void>}
*/
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];
}
}
+195 -3
View File
@@ -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<object[]>} 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<object[]>} 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<object[]>} 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<void>}
*/
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) {
+27
View File
@@ -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;
}
}
+3 -1
View File
@@ -4,10 +4,12 @@ import ActivityTimeline from '#components/activity-timeline';
{{#if @controller.mapUi.isSidebarVisible}}
<ActivityTimeline
@items={{@controller.items}}
@isLoading={{@controller.loadTask.isRunning}}
@isLoading={{@controller.isLoading}}
@isConnected={{@controller.isConnected}}
@sourceMode={{@controller.sourceMode}}
@onSetSourceMode={{@controller.setSourceMode}}
@isLoadingMore={{@controller.isLoadingMore}}
@onLoadMore={{@controller.loadMore}}
@scrollTop={{@controller.scrollTop}}
@onSelect={{@controller.selectItem}}
@onBack={{@controller.backToMenu}}
+1 -1
View File
@@ -198,7 +198,7 @@ function buildSocialEntry(events) {
*
* 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.
* "Alice shared 3 photos to Central Park Café" is one entry.
*
* @param {Array} events Mixed kind 360 / kind 5 events from followed contacts
* @param {number} [thresholdHours=3] Max gap in hours within a sub-group
+5 -1
View File
@@ -24,10 +24,14 @@ class MockActivityService extends Service {
},
];
@tracked sourceMode = 'home';
@tracked isLoadingMore = false;
@tracked isLoading = false;
async load() {}
setSourceMode() {}
async setSourceMode() {}
loadMore() {}
stop() {
this.items = [];
@@ -15,6 +15,8 @@ module('Integration | Component | activity-timeline', function (hooks) {
this.emptyItems = [];
this.sourceMode = 'home';
this.onSetSourceMode = () => {};
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}}
/>
</template>
);
@@ -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}}
/>
</template>
);
@@ -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}}
/>
</template>
);
@@ -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}}
/>
</template>
);
@@ -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}}
/>
</template>
);
@@ -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}}
/>
</template>
);
@@ -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}}
/>
</template>
);
@@ -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}}
/>
</template>
);
@@ -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}}
/>
</template>
);
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}}
/>
</template>
);
@@ -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(
<template>
<ActivityTimeline
@items={{this.items}}
@isLoading={{false}}
@isConnected={{true}}
@sourceMode={{this.sourceMode}}
@onSetSourceMode={{this.onSetSourceMode}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@onNostrConnected={{this.noop}}
@isLoadingMore={{this.isLoadingMore}}
@onLoadMore={{this.onLoadMore}}
/>
</template>
);
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(
<template>
<ActivityTimeline
@items={{this.items}}
@isLoading={{false}}
@isConnected={{true}}
@sourceMode={{this.sourceMode}}
@onSetSourceMode={{this.onSetSourceMode}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@onNostrConnected={{this.noop}}
@isLoadingMore={{this.isLoadingMore}}
@onLoadMore={{this.onLoadMore}}
/>
</template>
);
assert.dom('.activity-load-more-spinner').exists('spinner shows');
});
});
@@ -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');
});
+256 -15
View File
@@ -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');
});
});
+237 -1
View File
@@ -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');
});
});