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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user