Files
marco/app/services/activity.js
T
raucao 9d928217ca
CI / Lint (pull_request) Successful in 1m34s
CI / Test (pull_request) Successful in 59s
Preserve scroll position when no Nostr connected
2026-09-03 08:25:03 -06:00

497 lines
14 KiB
JavaScript

import Service, { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { ProfileModel } from 'applesauce-core/models/profile';
import { getProfileContent } from 'applesauce-core/helpers/profile';
import {
parseZapReceipt,
enrichWithPhoto,
groupSocialPhotos,
} 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
* their photos) and exposing it as a tracked `items` list for the activity
* timeline.
*
* The flow is:
* 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`
* subscription, updating the tracked entry fields when they load.
* 4. Resolve place names via the shared `placeNameResolver` service.
* 5. Update `@tracked items` so the UI renders progressively.
*/
export default class ActivityService extends Service {
@service nostrData;
@service nostrAuth;
@service placeNameResolver;
@tracked items = [];
@tracked sourceMode = 'home';
@tracked isLoading = false;
@tracked isLoadingMore = false;
_profileSubs = new Map();
_userPubkey = null;
_zapItems = [];
_socialItems = [];
_lastSocialEvents = [];
_since = null;
_sourceMode = 'home';
_isLoadingMore = false;
_cacheReadyCallback = null;
/**
* 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) return;
this._userPubkey = pubkey || null;
this._since = Math.floor(Date.now() / 1000) - SINCE_WINDOW;
this.isLoading = true;
this._cacheReadyCallback = () => {
this.isLoading = false;
};
try {
if (pubkey) {
await this.nostrData.loadMyContributions(pubkey);
await this.nostrData.loadProfile(pubkey);
await this.nostrData.whenContactsLoaded();
}
this._socialItems = [];
this._zapItems = [];
this._lastSocialEvents = [];
this._mergeItems();
const now = Math.floor(Date.now() / 1000);
await this._fetchWithBackoff(this._since, now);
} finally {
this.isLoading = false;
this._cacheReadyCallback = null;
}
}
/**
* Switches the activity source mode (e.g. 'home' for followee photos,
* '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
*/
async setSourceMode(mode) {
if (mode === this._sourceMode) return;
this._sourceMode = mode;
this.sourceMode = mode;
this._since = Math.floor(Date.now() / 1000) - SINCE_WINDOW;
this._socialItems = [];
this._zapItems = [];
this._lastSocialEvents = [];
this._mergeItems();
this.isLoading = true;
this._cacheReadyCallback = () => {
this.isLoading = false;
};
try {
const now = Math.floor(Date.now() / 1000);
await this._fetchWithBackoff(this._since, now);
} finally {
this.isLoading = false;
this._cacheReadyCallback = null;
}
}
/**
* 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;
}
}
/**
* Stops subscriptions and clears the timeline. Called when leaving the
* activity route.
*/
stop() {
this._cleanupProfileSubs();
this._zapItems = [];
this._socialItems = [];
this._lastSocialEvents = [];
this.items = [];
this._userPubkey = null;
this._since = null;
this._isLoadingMore = false;
this.isLoadingMore = false;
this.isLoading = false;
this.placeNameResolver.reset();
}
willDestroy() {
this.stop();
super.willDestroy(...arguments);
}
/**
* Processes photo events into social items and appends them to the timeline.
*
* @param {object[]} events Photo events to process
* @returns {boolean} True if any items were added
*/
_processPhotoEvents(events) {
if (events.length === 0) return false;
this._lastSocialEvents = [...this._lastSocialEvents, ...events];
const filtered = events.filter((e) => this._matchesSourceMode(e));
if (filtered.length === 0) return false;
const newEntries = groupSocialPhotos(filtered);
for (const entry of newEntries) {
this._resolveSender(entry);
}
for (const entry of newEntries) {
if (entry.osmId) {
const bookmarkName = this.placeNameResolver.resolveBookmark(
entry.osmId
);
if (bookmarkName) {
entry.placeName = bookmarkName;
entry.placeNameLoading = false;
}
}
}
this._socialItems = [...this._socialItems, ...newEntries];
this._mergeItems();
void this.placeNameResolver
.resolveInBackground(newEntries)
.then(() => this._mergeItems());
return true;
}
/**
* Processes zap receipt events and appends them to the timeline.
*
* @param {object[]} events Zap receipt events to process
* @returns {boolean} True if any items were added
*/
_processZapEventsIntoTimeline(events) {
if (events.length === 0) return false;
const newZapEntries = this._processZapEvents(events, this._userPubkey);
if (newZapEntries.length === 0) return false;
this._zapItems = [...this._zapItems, ...newZapEntries];
this._mergeItems();
void this.placeNameResolver
.resolveInBackground(newZapEntries)
.then(() => this._mergeItems());
return true;
}
/**
* Fetches photos and zaps for a single time window, processes them, and
* appends to the respective item arrays. Cache events are processed first
* (firing `_cacheReadyCallback` to dismiss the loading spinner), then
* network events are awaited and processed.
*
* @param {number} batchSince Start of the window (seconds)
* @param {number} batchUntil End of the window (seconds)
* @returns {Promise<boolean>} True if any events were found
*/
async _fetchAndProcessWindow(batchSince, batchUntil) {
const photoResult = await this.nostrData.fetchActivityPhotos(
batchSince,
batchUntil,
this._sourceMode
);
let zapResult = {
cacheEvents: [],
networkEvents: Promise.resolve([]),
};
if (this._sourceMode === 'home' && this._userPubkey) {
zapResult = await this.nostrData.fetchIncomingZaps(
this._userPubkey,
batchSince,
batchUntil
);
}
// Phase 1: Process cache events immediately
let added = false;
if (photoResult.cacheEvents.length > 0) {
added = this._processPhotoEvents(photoResult.cacheEvents) || added;
}
if (zapResult.cacheEvents.length > 0) {
added =
this._processZapEventsIntoTimeline(zapResult.cacheEvents) || added;
}
// Signal that cached items are ready for display
if (added) {
this._cacheReadyCallback?.();
}
// Phase 2: Await network events and process them
const [networkPhotos, networkZaps] = await Promise.all([
photoResult.networkEvents,
zapResult.networkEvents,
]);
if (networkPhotos.length > 0) {
added = this._processPhotoEvents(networkPhotos) || added;
}
if (networkZaps.length > 0) {
added = this._processZapEventsIntoTimeline(networkZaps) || added;
}
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;
if (!enrichWithPhoto(entry, this.nostrData.store, pubkey)) {
continue;
}
this._resolveSender(entry);
entries.push(entry);
}
entries.sort((a, b) => b.createdAt - a.createdAt);
for (const entry of entries) {
if (entry.osmId) {
const bookmarkName = this.placeNameResolver.resolveBookmark(
entry.osmId
);
if (bookmarkName) {
entry.placeName = bookmarkName;
entry.placeNameLoading = false;
}
}
}
return entries;
}
_updateZapItems(receipts, pubkey) {
const entries = this._processZapEvents(receipts, pubkey);
this._zapItems = entries;
this._mergeItems();
void this.placeNameResolver.resolveInBackground(entries).then(() => {
this._mergeItems();
});
}
_updateSocialItems(events) {
const filtered = events.filter((e) => this._matchesSourceMode(e));
const entries = groupSocialPhotos(filtered);
for (const entry of entries) {
this._resolveSender(entry);
}
for (const entry of entries) {
if (entry.osmId) {
const bookmarkName = this.placeNameResolver.resolveBookmark(
entry.osmId
);
if (bookmarkName) {
entry.placeName = bookmarkName;
entry.placeNameLoading = false;
}
}
}
this._socialItems = entries;
this._mergeItems();
void this.placeNameResolver.resolveInBackground(entries).then(() => {
this._mergeItems();
});
}
_mergeItems() {
const all =
this._sourceMode === 'explore'
? [...this._socialItems]
: [...this._zapItems, ...this._socialItems];
all.sort((a, b) => b.createdAt - a.createdAt);
this.items = all;
}
/**
* Returns true if an event should be included in the activity feed given
* the current source mode.
*
* - `'home'`: only events from followees (excluding own photos)
* - `'explore'`: events from non-followed authors trusted by relay provenance
* (excluding own photos). When no pubkey is connected, all trusted events
* pass (no own-photo or followee exclusion).
*/
_matchesSourceMode(event) {
if (this._sourceMode === 'home') {
return (
this._userPubkey &&
event.pubkey !== this._userPubkey &&
this.nostrData._contactPubkeys?.has(event.pubkey)
);
}
if (this._sourceMode === 'explore') {
if (!this._userPubkey) {
return this.nostrData.isTrustedEvent(event);
}
return (
event.pubkey !== this._userPubkey &&
!this.nostrData._contactPubkeys?.has(event.pubkey) &&
this.nostrData.isTrustedEvent(event)
);
}
return false;
}
_resolveSender(entry) {
const pubkey = entry.senderPubkey;
if (!pubkey) return;
if (!this._profileSubs.has(pubkey)) {
const sub = this.nostrData.store
.model(ProfileModel, pubkey)
.subscribe((profileContent) => {
this._applyProfileToPubkey(pubkey, profileContent);
});
this._profileSubs.set(pubkey, sub);
}
this._applySenderProfile(entry, pubkey);
}
_applySenderProfile(entry, pubkey) {
let profile = this.nostrData.getProfile(pubkey);
if (!profile) {
const event = this.nostrData.store.getReplaceable(0, pubkey);
if (event) {
profile = getProfileContent(event);
}
}
if (profile) {
entry.senderName = profile.name || profile.display_name || null;
entry.senderAvatar = profile.picture || null;
entry.senderProfileLoading = false;
}
}
_applyProfileToPubkey(pubkey, profileContent) {
let changed = false;
for (const entry of this.items) {
if (entry.senderPubkey === pubkey) {
entry.senderName =
profileContent.name || profileContent.display_name || null;
entry.senderAvatar = profileContent.picture || null;
entry.senderProfileLoading = false;
changed = true;
}
}
if (changed) {
this.items = [...this.items];
}
}
_cleanupProfileSubs() {
for (const sub of this._profileSubs.values()) {
sub.unsubscribe();
}
this._profileSubs.clear();
}
}