Show photo sharing activities of other users in Activity timelines #98

Merged
raucao merged 8 commits from feature/activity_timeline into master 2026-09-03 15:42:19 +00:00
24 changed files with 2830 additions and 208 deletions
+132
View File
@@ -0,0 +1,132 @@
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
import formatRelativeDate from '../helpers/format-relative-date';
import { npubEncode } from 'applesauce-core/helpers/pointers';
import Icon from './icon';
export default class ActivityPhotoItem extends Component {
get item() {
return this.args.item;
}
get senderName() {
return this.item?.senderName;
}
get senderDisplayName() {
const name = this.senderName;
if (name) return name;
const pubkey = this.item?.senderPubkey;
if (!pubkey) return 'Someone';
try {
return `${npubEncode(pubkey).slice(0, 12)}…`;
} catch {
return `${pubkey.slice(0, 12)}…`;
}
}
get senderAvatar() {
return this.item?.senderAvatar;
}
get hasPhoto() {
return this.photos.length > 0;
}
get photos() {
const photos = this.item?.photos;
if (photos && photos.length > 0) return photos;
const single = this.item?.photo;
return single ? [single] : [];
}
get primaryPhoto() {
return this.photos[0];
}
get extraPhotoCount() {
return Math.max(0, this.photos.length - 1);
}
get photoCountText() {
const count = this.photos.length;
return count === 1 ? 'a photo' : `${count} photos`;
}
get photoThumbUrl() {
return this.primaryPhoto?.thumbUrl || this.primaryPhoto?.url;
}
get placeName() {
return this.item?.placeName;
}
get placeNameLoading() {
return this.item?.placeNameLoading;
}
<template>
<li>
<button
type="button"
class="activity-item"
{{on "click" (fn @onSelect @item)}}
>
<div class="header">
<span class="sender-line">
<span class="sender-name">{{this.senderDisplayName}}</span>
{{! template-lint-disable no-whitespace-for-layout }}
<span class="action"> shared {{this.photoCountText}}</span>
</span>
<span class="amount">
<Icon
@name="feather-camera"
@size={{16}}
@color="var(--secondary-text-color)"
/>
</span>
</div>
<div class="context">
<div class="context-images">
{{#if this.senderAvatar}}
<img
class="sender-avatar"
src={{this.senderAvatar}}
alt=""
loading="lazy"
/>
{{else}}
<div class="sender-avatar-placeholder">
<Icon @name="user" @size={{16}} @color="#999" />
</div>
{{/if}}
{{#if this.hasPhoto}}
<div class="context-thumb">
<img src={{this.photoThumbUrl}} alt="" loading="lazy" />
{{#if (gt this.extraPhotoCount 0)}}
<span
class="context-thumb-badge"
>+{{this.extraPhotoCount}}</span>
{{/if}}
</div>
{{/if}}
</div>
<div class="context-text">
<span class="activity-place-name">
{{#if this.placeName}}
<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
@item.createdAt
}}</span>
</div>
</div>
</button>
</li>
</template>
}
+25 -1
View File
@@ -3,16 +3,25 @@ import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
import Icon from './icon';
import TabNav from './tab-nav';
import ActivityZapItem from './activity-zap-item';
import ActivityPhotoItem from './activity-photo-item';
import Modal from './modal';
import NostrConnect from './nostr-connect';
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;
tabs = [
{ label: 'Home', value: 'home' },
{ label: 'Explore', value: 'explore' },
];
@action
openNostrConnectModal(event) {
event.preventDefault();
@@ -47,12 +56,18 @@ export default class ActivityTimelineComponent extends Component {
</button>
</div>
<TabNav
@tabs={{this.tabs}}
@active={{@sourceMode}}
@onChange={{@onSetSourceMode}}
/>
<div class="sidebar-content" {{restoreScroll @scrollTop}}>
{{#if @isLoading}}
<div class="sidebar-loading">
<Icon @name="loading-ring" @size={{24}} @color="#898989" />
</div>
{{else if (not @isConnected)}}
{{else if (and (not @isConnected) (eq @sourceMode "home"))}}
<p class="empty-state">
<a
href="#"
@@ -69,8 +84,17 @@ export default class ActivityTimelineComponent extends Component {
{{#each @items as |item|}}
{{#if (eq item.type "zap")}}
<ActivityZapItem @item={{item}} @onSelect={{@onSelect}} />
{{else if (eq item.type "photo")}}
<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>
+20 -20
View File
@@ -50,54 +50,54 @@ export default class ActivityZapItem extends Component {
<li>
<button
type="button"
class="activity-zap-item"
class="activity-item"
{{on "click" (fn @onSelect @item)}}
>
<div class="zap-header">
<span class="zap-sender-line">
<span class="zap-sender-name">{{this.senderDisplayName}}</span>
<div class="header">
<span class="sender-line">
<span class="sender-name">{{this.senderDisplayName}}</span>
{{! template-lint-disable no-whitespace-for-layout }}
<span class="zap-action"> zapped your photo</span>
<span class="action"> zapped your photo</span>
</span>
<span class="zap-amount">{{this.item.amountSats}} ⚡</span>
<span class="amount">{{this.item.amountSats}} ⚡</span>
</div>
<div class="zap-context">
<div class="zap-context-images">
<div class="context">
<div class="context-images">
{{#if this.senderAvatar}}
<img
class="zap-sender-avatar"
class="sender-avatar"
src={{this.senderAvatar}}
alt=""
loading="lazy"
/>
{{else}}
<div class="zap-sender-avatar-placeholder">
<div class="sender-avatar-placeholder">
<Icon @name="user" @size={{16}} @color="#999" />
</div>
{{/if}}
{{#if this.hasPhoto}}
<div class="zap-context-thumb">
<div class="context-thumb">
<img src={{this.photoThumbUrl}} alt="" loading="lazy" />
</div>
{{/if}}
</div>
<div class="zap-context-text">
<span class="zap-place-name">
<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="zap-date">{{formatRelativeDate @item.createdAt}}</span>
<span class="activity-date">{{formatRelativeDate
@item.createdAt
}}</span>
</div>
</div>
{{#if this.item.message}}
<div class="zap-message">{{this.item.message}}</div>
<div class="message">{{this.item.message}}</div>
{{/if}}
</button>
</li>
+1 -1
View File
@@ -38,7 +38,7 @@ export default class ContributionsTimelineComponent extends Component {
</button>
<h2 class="sidebar-header-text-centered">
<span class="sidebar-header-icon-wrapper">
<Icon @name="activity" @size={{20}} @color="#898989" />
<Icon @name="user-check" @size={{20}} @color="#898989" />
</span>
My Contributions
</h2>
+17
View File
@@ -0,0 +1,17 @@
import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
import eq from 'ember-truth-helpers/helpers/eq';
<template>
<nav class="tab-nav">
{{#each @tabs as |tab|}}
<button
type="button"
class="tab-nav-button {{if (eq @active tab.value) 'is-active'}}"
{{on "click" (fn @onChange tab.value)}}
>
{{tab.label}}
</button>
{{/each}}
</nav>
</template>
+22 -1
View File
@@ -10,7 +10,6 @@ export default class ActivityController extends Controller {
@service activity;
loadTask = task({ restartable: true }, async (pubkey) => {
if (!pubkey) return;
await this.activity.load(pubkey);
});
@@ -22,10 +21,32 @@ export default class ActivityController extends Controller {
return this.activity.items;
}
get isLoading() {
return this.activity.isLoading;
}
get isConnected() {
return this.nostrAuth.isConnected;
}
get sourceMode() {
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.
}
}
+349 -59
View File
@@ -2,7 +2,15 @@ import Service, { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { ProfileModel } from 'applesauce-core/models/profile';
import { getProfileContent } from 'applesauce-core/helpers/profile';
import { parseZapReceipt, enrichWithPhoto } from '../utils/activity';
import {
parseZapReceipt,
enrichWithPhoto,
groupSocialPhotos,
} from '../utils/activity';
const SINCE_WINDOW = 30 * 24 * 60 * 60; // 30 days in seconds
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
@@ -10,8 +18,7 @@ import { parseZapReceipt, enrichWithPhoto } from '../utils/activity';
* 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`
@@ -25,45 +32,117 @@ export default class ActivityService extends Service {
@service placeNameResolver;
@tracked items = [];
@tracked sourceMode = 'home';
@tracked isLoading = false;
@tracked isLoadingMore = false;
_sub = null;
_profileSubs = new Map();
_userPubkey = null;
_zapItems = [];
_socialItems = [];
_lastSocialEvents = [];
_since = null;
_sourceMode = 'home';
_isLoadingMore = false;
_cacheReadyCallback = null;
/**
* Loads the user's incoming zap receipts and subscribes to live updates.
* Loads the user's incoming zap receipts and social photo activity.
*
* @param {string} pubkey The user's Nostr pubkey
* 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 (!pubkey) {
this.items = [];
return;
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;
}
}
this._userPubkey = pubkey;
/**
* 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;
const filters = [{ kinds: [9735], '#p': [pubkey] }];
this._since = Math.floor(Date.now() / 1000) - SINCE_WINDOW;
this._socialItems = [];
this._zapItems = [];
this._lastSocialEvents = [];
this._mergeItems();
console.debug('[activity] Subscribing to zap receipts', {
filters,
pubkey,
activeReadRelays: this.nostrData.activeReadRelays,
});
this.isLoading = true;
this._cacheReadyCallback = () => {
this.isLoading = false;
};
// Subscribe to the store timeline so we get live updates as receipts
// arrive and are added to the store.
this._sub = this.nostrData.store.timeline(filters).subscribe((events) => {
this._updateItems(events, pubkey);
});
try {
const now = Math.floor(Date.now() / 1000);
await this._fetchWithBackoff(this._since, now);
} finally {
this.isLoading = false;
this._cacheReadyCallback = null;
}
}
// 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);
/**
* 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;
// 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);
try {
const startSince = this._since - SINCE_WINDOW;
await this._fetchWithBackoff(startSince, this._since);
} finally {
this._isLoadingMore = false;
this.isLoadingMore = false;
}
}
/**
@@ -71,13 +150,16 @@ export default class ActivityService extends Service {
* activity route.
*/
stop() {
if (this._sub) {
this._sub.unsubscribe();
this._sub = null;
}
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();
}
@@ -86,35 +168,182 @@ export default class ActivityService extends Service {
super.willDestroy(...arguments);
}
_updateItems(receipts, pubkey) {
/**
* 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;
// 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,
});
// 2. Bookmark lookup is synchronous — resolve those immediately so the
// first render shows bookmarked place names without a "Loading…" flicker.
for (const entry of entries) {
if (entry.osmId) {
const bookmarkName = this.placeNameResolver.resolveBookmark(
@@ -127,23 +356,91 @@ export default class ActivityService extends Service {
}
}
this.items = entries;
return entries;
}
_updateZapItems(receipts, pubkey) {
const entries = this._processZapEvents(receipts, pubkey);
this._zapItems = entries;
this._mergeItems();
// 3. Async-resolve remaining entries from the IndexedDB name cache and OSM
// cache, then fall through to the network batch for the rest.
void this.placeNameResolver.resolveInBackground(entries).then(() => {
this.items = [...this.items];
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;
// 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)
@@ -153,17 +450,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) {
@@ -179,7 +471,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) {
@@ -192,7 +483,6 @@ export default class ActivityService extends Service {
}
if (changed) {
// Trigger re-render
this.items = [...this.items];
}
}
+355
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.
@@ -97,6 +102,12 @@ export default class NostrDataService extends Service {
_lastPhotoIds = new Set();
_incomingZapsNetworkSub = null;
// Activity photos state: tracks the current time window and source mode
// so that the contacts callback can re-trigger when contacts arrive.
_activityPhotosSince = null;
_activityPhotosMode = 'home';
_activityPhotosNetworkSub = null;
_requestSub = null;
_cachePromise = null;
_currentPlaceEntityId = null;
@@ -600,6 +611,312 @@ export default class NostrDataService extends Service {
);
}
/**
* Loads kind 360 (Place Photo) events for the activity feed, scoped to the
* given time window and source mode.
*
* - `'home'` mode: fetches photos authored by the user's followed contacts
* (followees). If contacts haven't loaded yet (`_contactPubkeys` is null or
* empty), this returns early and is re-triggered automatically by the
* `ContactsModel` subscription callback when contacts arrive.
* - `'explore'` mode: fetches all kind 360 photos in the time window from
* trusted relays (no authors filter). Provenance is captured so
* `isTrustedEvent` can filter at presentation time.
*
* @param {number} since Unix timestamp (seconds) for the start of the window
* @param {string} [mode='home'] Source mode
*/
async loadActivityPhotos(since, mode = 'home') {
this._activityPhotosSince = since;
this._activityPhotosMode = mode;
if (this._activityPhotosNetworkSub) {
this._activityPhotosNetworkSub.unsubscribe();
this._activityPhotosNetworkSub = null;
}
if (mode === 'home') {
this._loadFolloweePhotos(since);
} else if (mode === 'explore') {
this._loadTrustedRelaysPhotos(since);
}
}
/**
* Fetches kind 360 photos from the user's followees (followed contacts),
* batched by author pubkey (≤100 per filter to stay under relay REQ limits).
* Defers if contacts haven't loaded yet.
*/
_loadFolloweePhotos(since) {
const pubkeys = this._contactPubkeys
? Array.from(this._contactPubkeys)
: [];
if (pubkeys.length === 0) {
console.debug(
'[nostr-data] No contacts loaded yet, deferring followee photo load'
);
return;
}
const filters = this._batchAuthorFilters(pubkeys, [360], since);
console.debug('[nostr-data] Loading followee photos', {
filterCount: filters.length,
pubkeyCount: pubkeys.length,
since,
});
// 1. Populate the store from the local Nostr IDB cache (instant)
this._cachePromise
.then(() => this.cache.query(filters))
.then((cachedEvents) => {
if (cachedEvents && cachedEvents.length > 0) {
for (const event of cachedEvents) {
this.store.add(event);
}
}
})
.catch((e) => {
console.warn(
'[nostr-data] Failed to read followee photos from local Nostr IDB cache',
e
);
});
// 2. Request fresh events from the network (captures provenance for trust)
this._activityPhotosNetworkSub = this._requestContentWithProvenance(
this.activeReadRelays,
filters,
'[nostr-data] Error fetching followee photos:'
);
}
/**
* Fetches all kind 360 photos in the time window from trusted relays (no
* authors filter). Provenance is captured so `isTrustedEvent` can filter
* at presentation time. Fires immediately — no contacts dependency.
*/
_loadTrustedRelaysPhotos(since) {
const filters = [{ kinds: [360], since }];
console.debug('[nostr-data] Loading trusted relay photos', { since });
// 1. Populate the store from the local Nostr IDB cache (instant)
this._cachePromise
.then(() => this.cache.query(filters))
.then((cachedEvents) => {
if (cachedEvents && cachedEvents.length > 0) {
for (const event of cachedEvents) {
this.store.add(event);
}
}
})
.catch((e) => {
console.warn(
'[nostr-data] Failed to read trusted relay photos from local Nostr IDB cache',
e
);
});
// 2. Request fresh events from the network (captures provenance for trust)
this._activityPhotosNetworkSub = this._requestContentWithProvenance(
this.activeReadRelays,
filters,
'[nostr-data] Error fetching trusted relay photos:'
);
}
/**
* 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.
*
* Returns `{ cacheEvents, networkEvents }` so the caller can show cached
* items instantly while the network request resolves on EOSE.
*
* - `'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<{cacheEvents: object[], networkEvents: Promise<object[]>}>}
*/
async fetchActivityPhotos(since, until, mode = 'home') {
let filters;
if (mode === 'home') {
const pubkeys = this._contactPubkeys
? Array.from(this._contactPubkeys)
: [];
if (pubkeys.length === 0)
return { cacheEvents: [], networkEvents: Promise.resolve([]) };
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 { cacheEvents: [], networkEvents: Promise.resolve([]) };
}
// 1. Populate the store from the local Nostr IDB cache (instant)
const seen = new Set();
const cacheResult = [];
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);
cacheResult.push(event);
this.store.add(event);
}
}
}
// 2. Request fresh events from the network (resolves on EOSE)
const networkEvents = this._fetchEventsWithProvenance(
filters,
'[nostr-data] Error fetching activity photos:'
).then((events) => {
const result = [];
for (const event of events) {
if (!seen.has(event.id)) {
seen.add(event.id);
result.push(event);
}
}
return result;
});
return { cacheEvents: cacheResult, networkEvents };
}
/**
* Fetches incoming zap receipts (kind 9735) where the user is the recipient.
*
* Returns `{ cacheEvents, networkEvents }` so the caller can show cached
* items instantly while the network request resolves on EOSE.
*
* @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<{cacheEvents: object[], networkEvents: Promise<object[]>}>}
*/
async fetchIncomingZaps(pubkey, since, until) {
if (!pubkey) return { cacheEvents: [], networkEvents: Promise.resolve([]) };
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 cacheResult = [];
// 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);
cacheResult.push(event);
this.store.add(event);
}
}
}
// 2. Network (resolves on EOSE)
const networkEvents = this._fetchEventsWithProvenance(
filters,
'[nostr-data] Error fetching incoming zap receipts:'
).then((events) => {
const result = [];
for (const event of events) {
if (!seen.has(event.id)) {
seen.add(event.id);
result.push(event);
}
}
return result;
});
return { cacheEvents: cacheResult, networkEvents };
}
/**
* Builds an array of Nostr filters, each with ≤ `BATCH_SIZE` author pubkeys.
*
* @param {string[]} pubkeys Author pubkeys to batch
* @param {number[]} kinds Event kinds to request
* @param {number} since Unix timestamp (seconds) for the start of the window
* @returns {object[]} Array of filter objects
*/
_batchAuthorFilters(pubkeys, kinds, since, until) {
const BATCH_SIZE = 100;
const filters = [];
for (let i = 0; i < pubkeys.length; i += BATCH_SIZE) {
const filter = {
kinds,
authors: pubkeys.slice(i, i + BATCH_SIZE),
since,
};
if (until !== undefined) filter.until = until;
filters.push(filter);
}
return filters;
}
loadProfiles(pubkeys) {
const newPubkeys = pubkeys.filter(
(pk) => pk && !this._profileModelSubs.has(pk)
@@ -637,6 +954,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
@@ -658,7 +980,19 @@ 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
// is null and the call is a no-op.
if (this._activityPhotosSince !== null) {
this.loadActivityPhotos(
this._activityPhotosSince,
this._activityPhotosMode
);
}
});
this._blossomSub = this.store
@@ -724,6 +1058,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) {
@@ -896,6 +1247,10 @@ export default class NostrDataService extends Service {
this._incomingZapsNetworkSub.unsubscribe();
this._incomingZapsNetworkSub = null;
}
if (this._activityPhotosNetworkSub) {
this._activityPhotosNetworkSub.unsubscribe();
this._activityPhotosNetworkSub = null;
}
}
willDestroy() {
+56 -57
View File
@@ -56,11 +56,9 @@ export default class PlaceNameResolverService extends Service {
*/
async resolveInBackground(entries) {
const pending = entries.filter((e) => e.placeNameLoading);
if (pending.length === 0) {
this._maybeBatchFetchNames(entries);
return;
}
if (pending.length === 0) return;
// Phase 1: Cache lookup (localForage → OSM IDB cache)
await Promise.all(
pending.map(async (entry) => {
const name = await this._resolveCachedName(entry);
@@ -71,20 +69,6 @@ export default class PlaceNameResolverService extends Service {
})
);
// Trigger re-render via caller, then kick off the network batch for
// entries that are still loading.
this._maybeBatchFetchNames(entries);
}
_isUnresolvable(entry) {
return this._unresolvable.has(entry.placeIdentifier);
}
_maybeBatchFetchNames(entries) {
const unresolved = entries.filter(
(e) => e.placeNameLoading && !this._isUnresolvable(e)
);
// Apply fallbacks for items already marked unresolvable in this session
for (const entry of entries) {
if (entry.placeNameLoading && this._isUnresolvable(entry)) {
@@ -93,52 +77,67 @@ export default class PlaceNameResolverService extends Service {
}
}
if (unresolved.length === 0) {
return;
}
const unresolved = entries.filter(
(e) => e.placeNameLoading && !this._isUnresolvable(e)
);
if (unresolved.length === 0) return;
// Build a stable signature so we only fire one batch request per unique set
// Phase 2: Batch OSM fetch (awaited, deduplicated by signature)
try {
const nameMap = await this._getOrCreateBatch(unresolved);
for (const entry of entries) {
if (!entry.placeNameLoading) continue;
if (nameMap.has(entry.placeIdentifier)) {
entry.placeName = nameMap.get(entry.placeIdentifier);
entry.placeNameLoading = false;
} else {
this._unresolvable.add(entry.placeIdentifier);
entry.placeName = this._fallbackName(entry);
entry.placeNameLoading = false;
}
}
} catch (e) {
console.error('[place-name-resolver] Batch resolution failed', e);
for (const entry of entries) {
if (entry.placeNameLoading) {
this._unresolvable.add(entry.placeIdentifier);
entry.placeName = this._fallbackName(entry);
entry.placeNameLoading = false;
}
}
}
}
_isUnresolvable(entry) {
return this._unresolvable.has(entry.placeIdentifier);
}
/**
* Returns a promise for the batch OSM fetch, deduplicating calls that have
* the same set of place identifiers. If a batch with the same signature is
* already in flight, returns that promise instead of firing a new one.
*
* @param {Array} unresolved Entries that still need a network fetch
* @returns {Promise<Map<string, string>>} Map of placeIdentifier → name
*/
_getOrCreateBatch(unresolved) {
const signature = unresolved
.map((e) => e.placeIdentifier)
.sort()
.join('|');
if (signature === this._lastBatchSignature && this._pendingBatchPromise) {
return;
}
this._lastBatchSignature = signature;
this._pendingBatchPromise = this._batchResolveNames(unresolved)
.then((nameMap) => {
// Merge resolved names back into the entries
for (const entry of entries) {
if (!entry.placeNameLoading) continue;
if (nameMap.has(entry.placeIdentifier)) {
const name = nameMap.get(entry.placeIdentifier);
entry.placeName = name;
entry.placeNameLoading = false;
} else {
// Could not be resolved (deleted object, network error for this item).
// Mark as unresolvable for this session and use a fallback.
this._unresolvable.add(entry.placeIdentifier);
entry.placeName = this._fallbackName(entry);
entry.placeNameLoading = false;
}
}
})
.catch((e) => {
console.error('[place-name-resolver] Batch name resolution failed', e);
// On a total failure, mark all as unresolvable and apply fallbacks
for (const entry of entries) {
if (entry.placeNameLoading) {
this._unresolvable.add(entry.placeIdentifier);
entry.placeName = this._fallbackName(entry);
entry.placeNameLoading = false;
}
}
})
.finally(() => {
if (signature === this._lastBatchSignature && this._pendingBatchPromise) {
return this._pendingBatchPromise;
}
this._lastBatchSignature = signature;
this._pendingBatchPromise = this._batchResolveNames(unresolved).finally(
() => {
this._pendingBatchPromise = null;
});
}
);
return this._pendingBatchPromise;
}
_fallbackName(entry) {
+91 -16
View File
@@ -2417,6 +2417,38 @@ button.create-place {
padding: 4rem 1rem;
}
/* Tab Navigation */
.tab-nav {
display: flex;
border-bottom: 1px solid var(--divider-color);
height: 48px;
flex-shrink: 0;
}
.tab-nav-button {
flex: 1;
border: none;
background: none;
font-size: 0.9rem;
cursor: pointer;
color: var(--secondary-text-color);
font-family: inherit;
border-bottom: 2px solid transparent;
transition:
border-color 0.15s,
color 0.15s;
}
.tab-nav-button:hover {
color: var(--body-text-color);
}
.tab-nav-button.is-active {
border-bottom-color: var(--link-color);
font-weight: 600;
color: var(--body-text-color);
}
/* Contributions Timeline */
.contributions-list {
list-style: none;
@@ -2661,14 +2693,27 @@ button.create-place {
}
}
/* Activity Timeline — zap activity list rendered in the sidebar */
/* Activity Timeline — activity list rendered in the sidebar */
.activity-list {
list-style: none;
padding: 0;
margin: -1rem -1rem 0;
}
.activity-zap-item {
.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;
border: none;
@@ -2687,7 +2732,7 @@ button.create-place {
background: var(--hover-bg);
}
& .zap-sender-avatar {
& .sender-avatar {
flex-shrink: 0;
width: 32px;
height: 32px;
@@ -2696,7 +2741,7 @@ button.create-place {
background: #f0f0f0;
}
& .zap-sender-avatar-placeholder {
& .sender-avatar-placeholder {
flex-shrink: 0;
width: 32px;
height: 32px;
@@ -2708,39 +2753,42 @@ button.create-place {
color: #999;
}
& .zap-header {
& .header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
& .zap-sender-line {
& .sender-line {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.95rem;
& .zap-sender-name {
& .sender-name {
font-weight: bold;
}
& .zap-action {
& .action {
color: #666;
font-weight: normal;
}
}
& .zap-amount {
& .amount {
flex-shrink: 0;
font-weight: bold;
font-size: 0.95rem;
color: var(--body-text-color);
white-space: nowrap;
align-self: center;
display: flex;
align-items: center;
}
& .zap-message {
& .message {
color: var(--body-text-color);
font-size: 0.85rem;
font-style: italic;
@@ -2762,7 +2810,7 @@ button.create-place {
}
}
& .zap-context {
& .context {
display: flex;
align-items: center;
gap: 6px;
@@ -2770,19 +2818,20 @@ button.create-place {
font-size: 0.8rem;
margin-top: 8px;
& .zap-context-images {
& .context-images {
display: flex;
align-items: center;
gap: 0.5rem;
}
& .zap-context-thumb {
& .context-thumb {
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 4px;
overflow: hidden;
background: #f0f0f0;
position: relative;
& img {
width: 100%;
@@ -2790,27 +2839,53 @@ button.create-place {
object-fit: cover;
display: block;
}
& .context-thumb-badge {
position: absolute;
bottom: 2px;
right: 2px;
background: rgb(0 0 0 / 65%);
color: #fff;
font-size: 0.7rem;
font-weight: bold;
padding: 1px 4px;
border-radius: 4px;
}
}
& .zap-context-text {
& .context-text {
display: flex;
align-items: baseline;
gap: 6px;
flex: 1 1 auto;
min-width: 0;
& .zap-place-name {
& .activity-place-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 1 auto;
min-width: 0;
& .place-name-text {
animation: place-name-fade-in 0.15s ease-out;
}
}
& .zap-date {
& .activity-date {
flex-shrink: 0;
white-space: nowrap;
}
}
}
}
@keyframes place-name-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
+5 -1
View File
@@ -4,8 +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}}
+114 -1
View File
@@ -14,7 +14,7 @@ import {
getZapEventPointer,
getZapRequest,
} from 'applesauce-common/helpers';
import { parsePhotoFromEvent } from './contributions';
import { parsePhotoFromEvent, applyDeletions } from './contributions';
/**
* A single activity timeline entry.
@@ -27,6 +27,7 @@ export class ActivityEntry {
type = 'zap';
photoEventId;
photo;
photos = [];
placeIdentifier;
osmType;
osmId;
@@ -43,7 +44,10 @@ export class ActivityEntry {
constructor({
photoEventId,
photo,
photos,
placeIdentifier,
osmType,
osmId,
senderPubkey,
amountSats,
message,
@@ -51,7 +55,10 @@ export class ActivityEntry {
}) {
this.photoEventId = photoEventId;
this.photo = photo;
this.photos = photos ?? [];
this.placeIdentifier = placeIdentifier;
this.osmType = osmType;
this.osmId = osmId;
this.senderPubkey = senderPubkey;
this.amountSats = amountSats;
this.message = message;
@@ -140,3 +147,109 @@ export function enrichWithPhoto(entry, store, userPubkey) {
return true;
}
const HOUR_IN_SECONDS = 60 * 60;
/**
* Builds a single `ActivityEntry` (type 'photo') from a group of kind 360
* events by the same author for the same OSM entity.
*
* @param {Array} events Kind 360 events in this sub-group (same author + place)
* @returns {ActivityEntry}
*/
function buildSocialEntry(events) {
const photos = events
.map(parsePhotoFromEvent)
.filter(Boolean)
.sort((a, b) => a.createdAt - b.createdAt);
const createdAt = events.reduce(
(max, e) => (e.created_at > max ? e.created_at : max),
0
);
const placeIdentifier =
events[0].tags?.find((t) => t[0] === 'i')?.[1] || null;
let osmType;
let osmId;
if (placeIdentifier) {
const parts = placeIdentifier.split(':');
osmType = parts[1];
osmId = parts[2];
}
const entry = new ActivityEntry({
photo: photos[0] || null,
photos,
placeIdentifier,
osmType,
osmId,
senderPubkey: events[0].pubkey,
createdAt,
});
entry.type = 'photo';
return entry;
}
/**
* Groups kind 360 (Place Photo) events from followed contacts into activity
* entries, keyed by (author + OSM entity + time proximity).
*
* This mirrors the time-proximity grouping logic from `groupPhotoContributions`
* in `utils/contributions.js` but adds author as a grouping dimension so that
* "Alice 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
* @returns {Array} Sorted `ActivityEntry` entries (newest first)
*/
export function groupSocialPhotos(events, thresholdHours = 3) {
if (!events || events.length === 0) return [];
const photoEvents = applyDeletions(events);
if (photoEvents.length === 0) return [];
// Group by (author pubkey + OSM entity identifier)
const byAuthorEntity = new Map();
for (const event of photoEvents) {
const entityTag = (event.tags || []).find((t) => t[0] === 'i');
const entityId = entityTag?.[1];
if (!entityId) continue;
const key = `${event.pubkey}:${entityId}`;
if (!byAuthorEntity.has(key)) byAuthorEntity.set(key, []);
byAuthorEntity.get(key).push(event);
}
const thresholdSeconds = thresholdHours * HOUR_IN_SECONDS;
const entries = [];
for (const [, groupEvents] of byAuthorEntity) {
// Sort newest-first within the group
const sorted = [...groupEvents].sort((a, b) => b.created_at - a.created_at);
// Walk newest -> oldest, starting a new sub-group when the gap to the
// previous event exceeds the threshold.
let currentGroup = [];
let prevTime = null;
for (const event of sorted) {
if (prevTime !== null && prevTime - event.created_at > thresholdSeconds) {
entries.push(buildSocialEntry(currentGroup));
currentGroup = [];
}
currentGroup.push(event);
prevTime = event.created_at;
}
if (currentGroup.length > 0) {
entries.push(buildSocialEntry(currentGroup));
}
}
// Sort entries by their newest event's created_at, descending. Entries with
// no usable photos (e.g. malformed imeta) are filtered out.
return entries
.filter((e) => e.photos.length > 0)
.sort((a, b) => b.createdAt - a.createdAt);
}
+1 -1
View File
@@ -120,7 +120,7 @@ export function parsePhotoFromEvent(event) {
* @param {Array} events Mixed kind 360 and kind 5 events
* @returns {Array} Surviving kind 360 events
*/
function applyDeletions(events) {
export function applyDeletions(events) {
const deletedIds = new Set();
for (const event of events) {
if (event.kind === 5) {
+15 -8
View File
@@ -23,9 +23,16 @@ class MockActivityService extends Service {
senderProfileLoading: false,
},
];
@tracked sourceMode = 'home';
@tracked isLoadingMore = false;
@tracked isLoading = false;
async load() {}
async setSourceMode() {}
loadMore() {}
stop() {
this.items = [];
}
@@ -97,12 +104,12 @@ module('Acceptance | activity', function (hooks) {
test('activity items are rendered as zap rows', async function (assert) {
await visit('/activity');
await waitFor('.activity-zap-item');
assert.dom('.activity-zap-item').exists({ count: 1 });
assert.dom('.zap-sender-name').hasText('Alice');
assert.dom('.zap-action').includesText('zapped your photo');
assert.dom('.zap-amount').includesText('21 ⚡');
assert.dom('.zap-message').includesText('Great photo!');
await waitFor('.activity-item');
assert.dom('.activity-item').exists({ count: 1 });
assert.dom('.sender-name').hasText('Alice');
assert.dom('.action').includesText('zapped your photo');
assert.dom('.amount').includesText('21 ⚡');
assert.dom('.message').includesText('Great photo!');
});
test('closing the sidebar returns to index', async function (assert) {
@@ -124,9 +131,9 @@ module('Acceptance | activity', function (hooks) {
const mapUi = this.owner.lookup('service:map-ui');
await visit('/activity');
await waitFor('.activity-zap-item');
await waitFor('.activity-item');
await click('.activity-zap-item');
await click('.activity-item');
assert.ok(
currentURL().includes('/place/osm:node:123'),
@@ -13,6 +13,10 @@ module('Integration | Component | activity-timeline', function (hooks) {
hooks.beforeEach(function () {
this.noop = noop;
this.emptyItems = [];
this.sourceMode = 'home';
this.onSetSourceMode = () => {};
this.isLoadingMore = false;
this.onLoadMore = () => {};
});
test('it renders a loading state', async function (assert) {
@@ -22,10 +26,14 @@ module('Integration | Component | activity-timeline', function (hooks) {
@items={{this.emptyItems}}
@isLoading={{true}}
@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>
);
@@ -41,10 +49,14 @@ module('Integration | Component | activity-timeline', function (hooks) {
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{false}}
@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>
);
@@ -60,10 +72,14 @@ module('Integration | Component | activity-timeline', function (hooks) {
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{false}}
@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>
);
@@ -82,10 +98,14 @@ module('Integration | Component | activity-timeline', function (hooks) {
@items={{this.emptyItems}}
@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>
);
@@ -135,15 +155,19 @@ module('Integration | Component | activity-timeline', function (hooks) {
@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-zap-item').exists({ count: 2 });
assert.dom('.activity-item').exists({ count: 2 });
assert.dom(this.element).includesText('Alice');
assert.dom(this.element).includesText('21 ⚡');
assert.dom(this.element).includesText('Bob');
@@ -162,10 +186,14 @@ module('Integration | Component | activity-timeline', function (hooks) {
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{true}}
@sourceMode={{this.sourceMode}}
@onSetSourceMode={{this.onSetSourceMode}}
@onSelect={{this.noop}}
@onBack={{this.handleBack}}
@onClose={{this.noop}}
@onNostrConnected={{this.noop}}
@isLoadingMore={{this.isLoadingMore}}
@onLoadMore={{this.onLoadMore}}
/>
</template>
);
@@ -173,4 +201,214 @@ module('Integration | Component | activity-timeline', function (hooks) {
await click('.sidebar-header .back-btn');
assert.true(backClicked);
});
test('it renders tab nav with Home and Explore tabs', async function (assert) {
await render(
<template>
<ActivityTimeline
@items={{this.emptyItems}}
@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('.tab-nav').exists();
assert.dom('.tab-nav-button').exists({ count: 2 });
const buttons = this.element.querySelectorAll('.tab-nav-button');
assert.strictEqual(buttons[0].textContent.trim(), 'Home');
assert.strictEqual(buttons[1].textContent.trim(), 'Explore');
});
test('clicking a tab fires @onSetSourceMode with the tab value', async function (assert) {
let selectedMode = null;
this.handleSetSourceMode = (mode) => {
selectedMode = mode;
};
await render(
<template>
<ActivityTimeline
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{true}}
@sourceMode={{this.sourceMode}}
@onSetSourceMode={{this.handleSetSourceMode}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@onNostrConnected={{this.noop}}
@isLoadingMore={{this.isLoadingMore}}
@onLoadMore={{this.onLoadMore}}
/>
</template>
);
const buttons = this.element.querySelectorAll('.tab-nav-button');
await click(buttons[1]);
assert.strictEqual(
selectedMode,
'explore',
'onSetSourceMode called with explore'
);
});
test('explore mode shows items when not connected', async function (assert) {
this.items = [
{
type: 'photo',
photoEventId: 'photo-1',
photo: {
url: 'https://x.com/photo.jpg',
thumbUrl: 'https://x.com/thumb.jpg',
},
placeIdentifier: 'osm:node:111',
authorPubkey: 'b'.repeat(64),
createdAt: 2000,
authorName: 'Alice',
authorAvatar: 'https://x.com/avatar.jpg',
authorProfileLoading: false,
},
];
this.sourceMode = 'explore';
await render(
<template>
<ActivityTimeline
@items={{this.items}}
@isLoading={{false}}
@isConnected={{false}}
@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-list').exists('items render in explore mode');
assert.dom('.activity-list .activity-item').exists({ count: 1 });
assert.dom('.empty-state').doesNotExist('no connect prompt in explore');
});
test('explore mode shows empty state when not connected and no items', async function (assert) {
this.sourceMode = 'explore';
await render(
<template>
<ActivityTimeline
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{false}}
@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('.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');
});
});
@@ -36,15 +36,15 @@ module('Integration | Component | activity-zap-item', function (hooks) {
</template>
);
assert.dom('.zap-sender-name').hasText('Alice');
assert.dom('.zap-action').includesText('zapped your photo');
assert.dom('.zap-amount').hasText('21 ⚡');
assert.dom('.zap-message').includesText('Great shot!');
assert.dom('.sender-name').hasText('Alice');
assert.dom('.action').includesText('zapped your photo');
assert.dom('.amount').hasText('21 ⚡');
assert.dom('.message').includesText('Great shot!');
assert
.dom('.zap-sender-avatar')
.dom('.sender-avatar')
.hasAttribute('src', 'https://x.com/avatar.jpg');
assert
.dom('.zap-context-thumb img')
.dom('.context-thumb img')
.hasAttribute('src', 'https://x.com/thumb.jpg');
});
@@ -68,8 +68,8 @@ module('Integration | Component | activity-zap-item', function (hooks) {
</template>
);
assert.dom('.zap-sender-avatar-placeholder').exists();
assert.dom('.zap-sender-avatar').doesNotExist();
assert.dom('.sender-avatar-placeholder').exists();
assert.dom('.sender-avatar').doesNotExist();
});
test('it omits the message line when there is no message', async function (assert) {
@@ -91,7 +91,7 @@ module('Integration | Component | activity-zap-item', function (hooks) {
</template>
);
assert.dom('.zap-message').doesNotExist();
assert.dom('.message').doesNotExist();
});
test('it omits the context thumbnail when there is no photo', async function (assert) {
@@ -113,8 +113,8 @@ module('Integration | Component | activity-zap-item', function (hooks) {
</template>
);
assert.dom('.zap-context-thumb').doesNotExist();
assert.dom('.zap-context-text').exists();
assert.dom('.context-thumb').doesNotExist();
assert.dom('.context-text').exists();
});
test('clicking the item fires @onSelect with the item', async function (assert) {
@@ -141,7 +141,7 @@ module('Integration | Component | activity-zap-item', function (hooks) {
</template>
);
await click('.activity-zap-item');
await click('.activity-item');
assert.strictEqual(selected, this.item);
});
@@ -168,14 +168,14 @@ module('Integration | Component | activity-zap-item', function (hooks) {
);
assert
.dom('.zap-context-text')
.dom('.context-text')
.includesText('Café Central', 'Resolved place name is displayed');
assert
.dom('.contribution-name-loading')
.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('.zap-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,11 +226,11 @@ module('Integration | Component | activity-zap-item', function (hooks) {
);
assert
.dom('.zap-context-text .contribution-name-loading')
.dom('.context-text .place-name-text')
.hasText('Unnamed place', 'Fallback name is displayed');
});
test('it displays place name in zap-place-name and date in zap-date', async function (assert) {
test('it displays place name in activity-place-name and date in activity-date', async function (assert) {
this.item = new ActivityEntry({
photoEventId: 'photo-1',
photo: null,
@@ -248,14 +251,14 @@ module('Integration | Component | activity-zap-item', function (hooks) {
</template>
);
const placeNameEl = this.element.querySelector('.zap-place-name');
const dateEl = this.element.querySelector('.zap-date');
const placeNameEl = this.element.querySelector('.activity-place-name');
const dateEl = this.element.querySelector('.activity-date');
assert.ok(placeNameEl, 'zap-place-name element exists');
assert.ok(dateEl, 'zap-date element exists');
assert.ok(placeNameEl, 'activity-place-name element exists');
assert.ok(dateEl, 'activity-date element exists');
assert.ok(
placeNameEl.textContent.includes('Café Central'),
'Place name is in zap-place-name'
'Place name is in activity-place-name'
);
assert.ok(dateEl.textContent.includes('hr ago'), 'Date contains hr ago');
assert.notOk(
@@ -290,7 +293,9 @@ module('Integration | Component | activity-zap-item', function (hooks) {
</template>
);
assert.dom('.zap-place-name').exists('zap-place-name element exists');
assert.dom('.zap-date').hasText('1 hr ago', 'Date is still visible');
assert
.dom('.activity-place-name')
.exists('activity-place-name element exists');
assert.dom('.activity-date').hasText('1 hr ago', 'Date is still visible');
});
});
@@ -394,6 +394,7 @@ module('Integration | Component | search-box', function (hooks) {
);
// Type "aw" (2 characters)
await focus('.search-input');
await fillIn('.search-input', 'aw');
await waitFor('.search-results-popover', { timeout: 2000 });
@@ -0,0 +1,87 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'marco/tests/helpers';
import { render, click } from '@ember/test-helpers';
import TabNav from 'marco/components/tab-nav';
module('Integration | Component | tab-nav', function (hooks) {
setupRenderingTest(hooks);
test('it renders all tab buttons with labels', async function (assert) {
this.tabs = [
{ label: 'Home', value: 'home' },
{ label: 'Explore', value: 'explore' },
];
this.active = 'home';
this.onChange = () => {};
await render(
<template>
<TabNav
@tabs={{this.tabs}}
@active={{this.active}}
@onChange={{this.onChange}}
/>
</template>
);
assert.dom('.tab-nav-button').exists({ count: 2 });
const buttons = this.element.querySelectorAll('.tab-nav-button');
assert.strictEqual(buttons[0].textContent.trim(), 'Home');
assert.strictEqual(buttons[1].textContent.trim(), 'Explore');
});
test('it applies is-active class to the active tab', async function (assert) {
this.tabs = [
{ label: 'Home', value: 'home' },
{ label: 'Explore', value: 'explore' },
];
this.active = 'explore';
this.onChange = () => {};
await render(
<template>
<TabNav
@tabs={{this.tabs}}
@active={{this.active}}
@onChange={{this.onChange}}
/>
</template>
);
const buttons = this.element.querySelectorAll('.tab-nav-button');
assert.false(
buttons[0].classList.contains('is-active'),
'first tab is not active'
);
assert.true(
buttons[1].classList.contains('is-active'),
'second tab is active'
);
});
test('clicking a tab fires onChange with the tab value', async function (assert) {
this.tabs = [
{ label: 'Home', value: 'home' },
{ label: 'Explore', value: 'explore' },
];
this.active = 'home';
this.handleChange = (value) => {
this.active = value;
};
await render(
<template>
<TabNav
@tabs={{this.tabs}}
@active={{this.active}}
@onChange={{this.handleChange}}
/>
</template>
);
const buttons = this.element.querySelectorAll('.tab-nav-button');
await click(buttons[1]);
assert.strictEqual(this.active, 'explore', 'onChange called with explore');
});
});
+543 -14
View File
@@ -10,6 +10,16 @@ import {
setVerifyWrappedEventMethod(fakeVerifyEvent);
class MockPlaceNameResolver extends Service {
resolveBookmark() {
return null;
}
resolveInBackground() {
return new Promise(() => {});
}
reset() {}
}
const USER_PUBKEY = 'a'.repeat(64);
const SENDER_PUBKEY = 'b'.repeat(64);
@@ -64,7 +74,7 @@ function makePhotoEvent(opts = {}) {
id: opts.id || PHOTO_EVENT_ID_1,
pubkey: opts.author || USER_PUBKEY,
kind: 360,
created_at: 5000,
created_at: opts.created_at || 5000,
tags: [
['i', opts.placeIdentifier || 'osm:node:123'],
['imeta', `url ${opts.url || 'https://x.com/photo.jpg'}`, 'dim 800x600'],
@@ -76,6 +86,7 @@ function makePhotoEvent(opts = {}) {
class MockNostrDataService extends Service {
@tracked profiles = {};
_contactPubkeys = new Set();
store = {
events: new Map(),
@@ -112,6 +123,20 @@ class MockNostrDataService extends Service {
};
loadProfiles() {}
loadActivityPhotos() {}
loadMyContributions() {}
loadProfile() {}
whenContactsLoaded() {
return Promise.resolve();
}
async fetchActivityPhotos() {
return { cacheEvents: [], networkEvents: Promise.resolve([]) };
}
async fetchIncomingZaps() {
return { cacheEvents: [], networkEvents: Promise.resolve([]) };
}
getProfile(pubkey) {
return this.profiles[pubkey];
@@ -125,9 +150,10 @@ module('Unit | Service | activity', function (hooks) {
hooks.beforeEach(function () {
this.owner.register('service:nostrData', MockNostrDataService);
this.owner.register('service:placeNameResolver', MockPlaceNameResolver);
});
test('_updateItems parses receipts and enriches with photos from the store', function (assert) {
test('_updateZapItems parses receipts and enriches with photos from the store', function (assert) {
const service = this.owner.lookup('service:activity');
const photoEvent = makePhotoEvent({
id: PHOTO_EVENT_ID_1,
@@ -140,7 +166,7 @@ module('Unit | Service | activity', function (hooks) {
message: 'Love it!',
});
service._updateItems([receipt], USER_PUBKEY);
service._updateZapItems([receipt], USER_PUBKEY);
assert.strictEqual(service.items.length, 1);
assert.strictEqual(service.items[0].type, 'zap');
@@ -151,7 +177,7 @@ module('Unit | Service | activity', function (hooks) {
assert.ok(service.items[0].photo, 'photo is populated');
});
test('_updateItems filters out zaps for non-photo events', function (assert) {
test('_updateZapItems filters out zaps for non-photo events', function (assert) {
const service = this.owner.lookup('service:activity');
service.nostrData.store.add({
id: TEXT_EVENT_ID,
@@ -163,12 +189,12 @@ module('Unit | Service | activity', function (hooks) {
const receipt = makeZapReceiptEvent({ zappedEventId: TEXT_EVENT_ID });
service._updateItems([receipt], USER_PUBKEY);
service._updateZapItems([receipt], USER_PUBKEY);
assert.strictEqual(service.items.length, 0, 'non-photo zap filtered out');
});
test('_updateItems filters out zaps for photos not authored by the user', function (assert) {
test('_updateZapItems filters out zaps for photos not authored by the user', function (assert) {
const service = this.owner.lookup('service:activity');
const otherUserPhoto = makePhotoEvent({
id: PHOTO_EVENT_ID_OTHER,
@@ -180,7 +206,7 @@ module('Unit | Service | activity', function (hooks) {
zappedEventId: PHOTO_EVENT_ID_OTHER,
});
service._updateItems([receipt], USER_PUBKEY);
service._updateZapItems([receipt], USER_PUBKEY);
assert.strictEqual(
service.items.length,
@@ -189,7 +215,7 @@ module('Unit | Service | activity', function (hooks) {
);
});
test('_updateItems filters out zaps not directed at the user', function (assert) {
test('_updateZapItems filters out zaps not directed at the user', function (assert) {
const service = this.owner.lookup('service:activity');
const photoEvent = makePhotoEvent({ id: PHOTO_EVENT_ID_1 });
service.nostrData.store.add(photoEvent);
@@ -199,7 +225,7 @@ module('Unit | Service | activity', function (hooks) {
zappedEventId: PHOTO_EVENT_ID_1,
});
service._updateItems([receipt], USER_PUBKEY);
service._updateZapItems([receipt], USER_PUBKEY);
assert.strictEqual(
service.items.length,
@@ -208,7 +234,7 @@ module('Unit | Service | activity', function (hooks) {
);
});
test('_updateItems sorts entries newest-first', function (assert) {
test('_updateZapItems sorts entries newest-first', function (assert) {
const service = this.owner.lookup('service:activity');
service.nostrData.store.add(makePhotoEvent({ id: PHOTO_EVENT_ID_1 }));
service.nostrData.store.add(makePhotoEvent({ id: PHOTO_EVENT_ID_2 }));
@@ -224,7 +250,7 @@ module('Unit | Service | activity', function (hooks) {
created_at: 9000,
});
service._updateItems([oldReceipt, newReceipt], USER_PUBKEY);
service._updateZapItems([oldReceipt, newReceipt], USER_PUBKEY);
assert.strictEqual(service.items.length, 2);
assert.strictEqual(service.items[0].createdAt, 9000, 'newest first');
@@ -234,7 +260,7 @@ module('Unit | Service | activity', function (hooks) {
test('stop clears items and resets state', function (assert) {
const service = this.owner.lookup('service:activity');
service.nostrData.store.add(makePhotoEvent({ id: PHOTO_EVENT_ID_1 }));
service._updateItems(
service._updateZapItems(
[makeZapReceiptEvent({ zappedEventId: PHOTO_EVENT_ID_1 })],
USER_PUBKEY
);
@@ -247,13 +273,17 @@ module('Unit | Service | activity', function (hooks) {
assert.strictEqual(service._userPubkey, null);
});
test('load with no pubkey clears items', async function (assert) {
test('load with no pubkey preserves existing items', async function (assert) {
const service = this.owner.lookup('service:activity');
service.items = [{ fake: true }];
await service.load(null);
assert.strictEqual(service.items.length, 0);
assert.strictEqual(
service.items.length,
1,
'items preserved when already loaded'
);
});
test('_resolveSender applies profile when available', function (assert) {
@@ -270,4 +300,503 @@ module('Unit | Service | activity', function (hooks) {
assert.strictEqual(entry.senderAvatar, 'https://x.com/avatar.jpg');
assert.false(entry.senderProfileLoading);
});
test('_updateSocialItems groups photos by author + place', function (assert) {
const service = this.owner.lookup('service:activity');
service._userPubkey = USER_PUBKEY;
service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]);
const photo1 = makePhotoEvent({
id: 'p1'.padEnd(64, '0'),
author: SENDER_PUBKEY,
placeIdentifier: 'osm:node:100',
created_at: 1000,
});
const photo2 = makePhotoEvent({
id: 'p2'.padEnd(64, '0'),
author: SENDER_PUBKEY,
placeIdentifier: 'osm:node:100',
created_at: 2000,
});
service._updateSocialItems([photo1, photo2]);
assert.strictEqual(
service.items.length,
1,
'one entry for same author + place'
);
assert.strictEqual(service.items[0].type, 'photo');
assert.strictEqual(service.items[0].photos.length, 2);
assert.strictEqual(service.items[0].senderPubkey, SENDER_PUBKEY);
});
test('_matchesSourceMode filters by contact pubkeys in home mode', function (assert) {
const service = this.owner.lookup('service:activity');
service._userPubkey = USER_PUBKEY;
service._sourceMode = 'home';
service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]);
const followedPhoto = makePhotoEvent({ author: SENDER_PUBKEY });
const unfollowedPhoto = makePhotoEvent({ author: 'z'.repeat(64) });
const ownPhoto = makePhotoEvent({ author: USER_PUBKEY });
assert.true(
service._matchesSourceMode(followedPhoto),
'followed contact passes'
);
assert.false(
service._matchesSourceMode(unfollowedPhoto),
'unfollowed pubkey rejected'
);
assert.false(service._matchesSourceMode(ownPhoto), 'own photo excluded');
});
test('_matchesSourceMode in explore mode includes trusted strangers, excludes own photos and followees', function (assert) {
const service = this.owner.lookup('service:activity');
service._userPubkey = USER_PUBKEY;
service._sourceMode = 'explore';
service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]);
const STRANGER_PUBKEY = 'c'.repeat(64);
// Mock isTrustedEvent to return true for SENDER_PUBKEY and STRANGER_PUBKEY
service.nostrData.isTrustedEvent = (event) =>
event.pubkey === SENDER_PUBKEY || event.pubkey === STRANGER_PUBKEY;
const trustedStrangerPhoto = makePhotoEvent({ author: STRANGER_PUBKEY });
const trustedFolloweePhoto = makePhotoEvent({ author: SENDER_PUBKEY });
const untrustedPhoto = makePhotoEvent({ author: 'z'.repeat(64) });
const ownPhoto = makePhotoEvent({ author: USER_PUBKEY });
assert.true(
service._matchesSourceMode(trustedStrangerPhoto),
'trusted stranger passes in explore mode'
);
assert.false(
service._matchesSourceMode(trustedFolloweePhoto),
'trusted followee excluded in explore mode'
);
assert.false(
service._matchesSourceMode(untrustedPhoto),
'untrusted event rejected in explore mode'
);
assert.false(
service._matchesSourceMode(ownPhoto),
'own photo excluded in explore mode'
);
});
test('_matchesSourceMode in explore mode with no pubkey includes all trusted events', function (assert) {
const service = this.owner.lookup('service:activity');
service._userPubkey = null;
service._sourceMode = 'explore';
service.nostrData._contactPubkeys = null;
const STRANGER_PUBKEY = 'c'.repeat(64);
service.nostrData.isTrustedEvent = (event) =>
event.pubkey === SENDER_PUBKEY || event.pubkey === STRANGER_PUBKEY;
const trustedPhoto = makePhotoEvent({ author: SENDER_PUBKEY });
const trustedStrangerPhoto = makePhotoEvent({ author: STRANGER_PUBKEY });
const untrustedPhoto = makePhotoEvent({ author: 'z'.repeat(64) });
assert.true(
service._matchesSourceMode(trustedPhoto),
'trusted event passes with no pubkey'
);
assert.true(
service._matchesSourceMode(trustedStrangerPhoto),
'trusted stranger passes with no pubkey'
);
assert.false(
service._matchesSourceMode(untrustedPhoto),
'untrusted event rejected with no pubkey'
);
});
test('_updateSocialItems merges with zap items sorted by createdAt', function (assert) {
const service = this.owner.lookup('service:activity');
service._userPubkey = USER_PUBKEY;
service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]);
// Seed a zap item
service.nostrData.store.add(
makePhotoEvent({ id: PHOTO_EVENT_ID_1, placeIdentifier: 'osm:node:50' })
);
service._updateZapItems(
[
makeZapReceiptEvent({
zappedEventId: PHOTO_EVENT_ID_1,
created_at: 5000,
}),
],
USER_PUBKEY
);
// Add a social photo that is newer
const socialPhoto = makePhotoEvent({
id: 'sp1'.padEnd(64, '0'),
author: SENDER_PUBKEY,
placeIdentifier: 'osm:node:100',
created_at: 9000,
});
service._updateSocialItems([socialPhoto]);
assert.strictEqual(service.items.length, 2);
assert.strictEqual(
service.items[0].createdAt,
9000,
'newer social photo first'
);
assert.strictEqual(service.items[1].createdAt, 5000, 'older zap second');
});
test('explore mode excludes zap items from merged results', function (assert) {
const service = this.owner.lookup('service:activity');
service._userPubkey = USER_PUBKEY;
service._sourceMode = 'explore';
service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]);
// Seed a zap item
service.nostrData.store.add(
makePhotoEvent({ id: PHOTO_EVENT_ID_1, placeIdentifier: 'osm:node:50' })
);
service._updateZapItems(
[
makeZapReceiptEvent({
zappedEventId: PHOTO_EVENT_ID_1,
created_at: 5000,
}),
],
USER_PUBKEY
);
// Add a trusted stranger social photo
const STRANGER_PUBKEY = 'c'.repeat(64);
service.nostrData.isTrustedEvent = (event) =>
event.pubkey === STRANGER_PUBKEY;
const socialPhoto = makePhotoEvent({
id: 'sp1'.padEnd(64, '0'),
author: STRANGER_PUBKEY,
placeIdentifier: 'osm:node:100',
created_at: 9000,
});
service._updateSocialItems([socialPhoto]);
assert.strictEqual(
service.items.length,
1,
'zap excluded in explore mode, only social photo shown'
);
assert.strictEqual(
service.items[0].createdAt,
9000,
'only the social photo is present'
);
});
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]);
service.nostrData.isTrustedEvent = () => false;
const followedPhoto = makePhotoEvent({
id: 'fp1'.padEnd(64, '0'),
author: SENDER_PUBKEY,
placeIdentifier: 'osm:node:100',
created_at: 1000,
});
service.nostrData.fetchActivityPhotos = async (_since, _until, mode) => {
if (mode === 'home')
return {
cacheEvents: [followedPhoto],
networkEvents: Promise.resolve([]),
};
return { cacheEvents: [], networkEvents: Promise.resolve([]) };
};
service._updateSocialItems([followedPhoto]);
assert.strictEqual(service.items.length, 0, 'no items in explore 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'
);
});
test('stop clears social items and resets state', function (assert) {
const service = this.owner.lookup('service:activity');
service._userPubkey = USER_PUBKEY;
service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]);
const photo = makePhotoEvent({
id: 'sp2'.padEnd(64, '0'),
author: SENDER_PUBKEY,
placeIdentifier: 'osm:node:100',
});
service._updateSocialItems([photo]);
assert.strictEqual(service.items.length, 1);
service.stop();
assert.strictEqual(service.items.length, 0, 'items cleared');
assert.strictEqual(service._socialItems.length, 0, 'social items cleared');
assert.strictEqual(service._zapItems.length, 0, 'zap items cleared');
});
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 {
cacheEvents: [oldPhoto],
networkEvents: Promise.resolve([]),
};
};
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 () => ({
cacheEvents: [],
networkEvents: Promise.resolve([]),
});
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 {
cacheEvents: [oldZapReceipt],
networkEvents: Promise.resolve([]),
};
};
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 { cacheEvents: [], networkEvents: Promise.resolve([]) };
return {
cacheEvents: [
makePhotoEvent({
id: 'old2'.padEnd(64, '0'),
author: SENDER_PUBKEY,
placeIdentifier: 'osm:node:300',
created_at: NOW - 100000,
}),
],
networkEvents: Promise.resolve([]),
};
};
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 { cacheEvents: [], networkEvents: Promise.resolve([]) };
};
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 {
cacheEvents: [
makePhotoEvent({
id: 'old3'.padEnd(64, '0'),
author: SENDER_PUBKEY,
placeIdentifier: 'osm:node:200',
created_at: NOW - 5000,
}),
],
networkEvents: Promise.resolve([]),
};
};
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 { cacheEvents: [], networkEvents: Promise.resolve([]) };
};
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');
});
});
+378 -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');
@@ -856,6 +908,331 @@ module('Unit | Service | nostr-data | my contributions', function (hooks) {
});
});
module('Unit | Service | nostr-data | activity photos', function (hooks) {
setupNostrDataService(hooks);
test('loadActivityPhotos defers when contacts are not loaded yet', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
// _contactPubkeys is null initially (no loadProfile called)
assert.strictEqual(service._contactPubkeys, null);
await service.loadActivityPhotos(1000, 'home');
// No network request should have been made
const photoFilters = this.requestedFilters.filter(
(f) => f.kinds?.includes(360) && f.authors
);
assert.strictEqual(photoFilters.length, 0, 'no request without contacts');
});
test('loadActivityPhotos batches >100 pubkeys into multiple filters', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const userPubkey = makePubkey(1);
// Create 250 contact pubkeys
const contactPubkeys = Array.from({ length: 250 }, (_, i) =>
makePubkey(100 + i)
);
// Load contacts so _contactPubkeys is populated
service.store.add(makeContactsEvent(userPubkey, contactPubkeys));
await service.loadProfile(userPubkey);
await service.loadActivityPhotos(1000, 'home');
const photoFilters = this.requestedFilters.filter(
(f) => f.kinds?.includes(360) && f.authors && f.since !== undefined
);
assert.strictEqual(
photoFilters.length,
3,
'250 pubkeys → 3 filters (100+100+50)'
);
assert.strictEqual(photoFilters[0].authors.length, 100);
assert.strictEqual(photoFilters[1].authors.length, 100);
assert.strictEqual(photoFilters[2].authors.length, 50);
});
test('loadActivityPhotos includes since in filter', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const userPubkey = makePubkey(1);
const contactPubkey = makePubkey(2);
service.store.add(makeContactsEvent(userPubkey, [contactPubkey]));
await service.loadProfile(userPubkey);
const since = 12345;
await service.loadActivityPhotos(since, 'home');
const photoFilter = this.requestedFilters.find(
(f) => f.kinds?.includes(360) && f.authors && f.since !== undefined
);
assert.ok(photoFilter, 'photo filter with since found');
assert.strictEqual(photoFilter.since, since, 'since value matches');
assert.deepEqual(photoFilter.kinds, [360], 'requests kind 360');
});
test('loadActivityPhotos re-triggers when contacts arrive', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const userPubkey = makePubkey(1);
const contactPubkey = makePubkey(2);
// Call loadActivityPhotos before contacts are loaded
await service.loadActivityPhotos(1000, 'home');
const beforeCount = this.requestedFilters.filter(
(f) => f.kinds?.includes(360) && f.authors && f.since !== undefined
).length;
assert.strictEqual(beforeCount, 0, 'no request before contacts');
// Now load contacts — the ContactsModel callback should re-trigger
service.store.add(makeContactsEvent(userPubkey, [contactPubkey]));
await service.loadProfile(userPubkey);
// Give the callback a tick to propagate
await new Promise((r) => setTimeout(r, 50));
const afterCount = this.requestedFilters.filter(
(f) => f.kinds?.includes(360) && f.authors && f.since !== undefined
).length;
assert.ok(afterCount > 0, 'request made after contacts arrived');
});
test("loadActivityPhotos in 'explore' mode fetches all photos without authors filter", async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const since = 9999;
await service.loadActivityPhotos(since, 'explore');
// Should make a request immediately (no contacts dependency)
const photoFilter = this.requestedFilters.find(
(f) => f.kinds?.includes(360) && f.since !== undefined && !f.authors
);
assert.ok(photoFilter, 'photo filter without authors found');
assert.strictEqual(photoFilter.since, since, 'since value matches');
assert.deepEqual(photoFilter.kinds, [360], 'requests kind 360');
assert.notOk(photoFilter.authors, 'no authors filter in explore mode');
});
test('_batchAuthorFilters produces correct batches', function (assert) {
const service = this.owner.lookup('service:nostr-data');
const pubkeys = Array.from({ length: 250 }, (_, i) => makePubkey(i));
const filters = service._batchAuthorFilters(pubkeys, [360], 1000);
assert.strictEqual(filters.length, 3, '250 → 3 filters');
assert.strictEqual(filters[0].authors.length, 100);
assert.strictEqual(filters[1].authors.length, 100);
assert.strictEqual(filters[2].authors.length, 50);
assert.deepEqual(filters[0].kinds, [360]);
assert.strictEqual(filters[0].since, 1000);
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 result = await service.fetchActivityPhotos(1000, undefined, 'home');
const networkEvents = await result.networkEvents;
assert.ok(
networkEvents.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 result = await service.fetchActivityPhotos(1000, undefined, 'home');
assert.strictEqual(
result.cacheEvents.length,
0,
'empty cache events when no contacts'
);
const networkEvents = await result.networkEvents;
assert.strictEqual(
networkEvents.length,
0,
'empty network events 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 result = await service.fetchActivityPhotos(1000, undefined, 'home');
const networkEvents = await result.networkEvents;
assert.strictEqual(
result.cacheEvents.filter((e) => e.id === photo.id).length,
1,
'photo appears once in cache events'
);
assert.strictEqual(
networkEvents.filter((e) => e.id === photo.id).length,
0,
'photo not in network events (deduplicated against cache)'
);
});
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');
});
});
module('Unit | Service | nostr-data | zap receipts', function (hooks) {
setupNostrDataService(hooks);
@@ -207,4 +207,70 @@ module('Unit | Service | place-name-resolver', function (hooks) {
assert.strictEqual(resolver._lastBatchSignature, '');
assert.strictEqual(resolver._pendingBatchPromise, null);
});
test('deduplicated batch resolves entries from a second call with same place identifiers', async function (assert) {
const resolver = this.owner.lookup('service:place-name-resolver');
const localForage = this.owner.lookup('service:localForage');
let fetchCount = 0;
resolver.osm.fetchOsmObjectsBatch = async () => {
fetchCount++;
const map = new Map();
map.set('node:222', { title: 'Shared Place' });
return map;
};
// First call with entry1 — triggers a batch fetch
const entry1 = makeEntry({ placeIdentifier: 'osm:node:222' });
const promise1 = resolver.resolveInBackground([entry1]);
await flushPromises();
await promise1;
// Verify the name persisted so the second call's cache check finds it
assert.strictEqual(entry1.placeName, 'Shared Place');
assert.strictEqual(fetchCount, 1, 'only one batch fetch fired');
// Simulate the real-world race: a second set of entries for the same
// place arrives before the cache is consulted. Clear the persistent cache
// to simulate the incognito scenario where the batch hasn't persisted yet.
await localForage.clear('place-name-cache');
// Second call with a fresh entry2 — should resolve from the batch result
// that was already persisted (or re-use the pending batch if still in flight)
const entry2 = makeEntry({ placeIdentifier: 'osm:node:222' });
await resolver.resolveInBackground([entry2]);
assert.strictEqual(
entry2.placeName,
'Shared Place',
'second entry resolves even when batch was from a prior call'
);
});
test('pending batch is shared when second call fires before first completes', async function (assert) {
const resolver = this.owner.lookup('service:place-name-resolver');
let fetchCount = 0;
resolver.osm.fetchOsmObjectsBatch = async () => {
fetchCount++;
const map = new Map();
map.set('node:333', { title: 'Concurrent Place' });
return map;
};
// First call — don't await yet (simulates subscription firing)
const entry1 = makeEntry({ placeIdentifier: 'osm:node:333' });
const promise1 = resolver.resolveInBackground([entry1]);
// Second call with a different entry for the same place — should piggyback
const entry2 = makeEntry({ placeIdentifier: 'osm:node:333' });
const promise2 = resolver.resolveInBackground([entry2]);
await Promise.all([promise1, promise2]);
await flushPromises();
assert.strictEqual(fetchCount, 1, 'only one batch fetch fired');
assert.strictEqual(entry1.placeName, 'Concurrent Place');
assert.strictEqual(entry2.placeName, 'Concurrent Place');
});
});
+250
View File
@@ -3,6 +3,7 @@ import {
ActivityEntry,
parseZapReceipt,
enrichWithPhoto,
groupSocialPhotos,
} from 'marco/utils/activity';
import {
setVerifyWrappedEventMethod,
@@ -209,3 +210,252 @@ module('Unit | Utility | activity', function () {
assert.false(result, 'event not in store');
});
});
const SOCIAL_PK_A = 'a'.repeat(64);
const SOCIAL_PK_B = 'b'.repeat(64);
function makeSocialPhotoEvent(opts = {}) {
return {
id: opts.id || `e${(opts.idx ?? 0).toString().padStart(63, '0')}`,
pubkey: opts.author || SOCIAL_PK_A,
kind: 360,
created_at: opts.created_at || 5000,
tags: [
['i', opts.placeIdentifier || 'osm:node:123'],
['imeta', `url ${opts.url || 'https://x.com/photo.jpg'}`, 'dim 800x600'],
],
content: '',
sig: 'sig',
};
}
module('Unit | Utility | activity | groupSocialPhotos', function () {
test('returns empty for no events', function (assert) {
assert.deepEqual(groupSocialPhotos([]), []);
assert.deepEqual(groupSocialPhotos(null), []);
});
test('groups by author + place within time threshold', function (assert) {
const events = [
makeSocialPhotoEvent({
idx: 1,
author: SOCIAL_PK_A,
placeIdentifier: 'osm:node:1',
created_at: 1000,
}),
makeSocialPhotoEvent({
idx: 2,
author: SOCIAL_PK_A,
placeIdentifier: 'osm:node:1',
created_at: 2000,
}),
makeSocialPhotoEvent({
idx: 3,
author: SOCIAL_PK_A,
placeIdentifier: 'osm:node:1',
created_at: 3000,
}),
];
const entries = groupSocialPhotos(events);
assert.strictEqual(entries.length, 1, 'one entry for same author + place');
assert.strictEqual(entries[0].type, 'photo');
assert.strictEqual(entries[0].photos.length, 3);
assert.strictEqual(entries[0].senderPubkey, SOCIAL_PK_A);
assert.strictEqual(entries[0].createdAt, 3000, 'newest created_at');
assert.strictEqual(entries[0].placeIdentifier, 'osm:node:1');
});
test('separates entries by author for the same place', function (assert) {
const events = [
makeSocialPhotoEvent({
idx: 1,
author: SOCIAL_PK_A,
placeIdentifier: 'osm:node:1',
created_at: 1000,
}),
makeSocialPhotoEvent({
idx: 2,
author: SOCIAL_PK_B,
placeIdentifier: 'osm:node:1',
created_at: 2000,
}),
];
const entries = groupSocialPhotos(events);
assert.strictEqual(entries.length, 2, 'two entries (one per author)');
assert.strictEqual(entries[0].senderPubkey, SOCIAL_PK_B, 'newest first');
assert.strictEqual(entries[1].senderPubkey, SOCIAL_PK_A);
});
test('separates entries by place for the same author', function (assert) {
const events = [
makeSocialPhotoEvent({
idx: 1,
author: SOCIAL_PK_A,
placeIdentifier: 'osm:node:1',
created_at: 1000,
}),
makeSocialPhotoEvent({
idx: 2,
author: SOCIAL_PK_A,
placeIdentifier: 'osm:node:2',
created_at: 2000,
}),
];
const entries = groupSocialPhotos(events);
assert.strictEqual(entries.length, 2, 'two entries (one per place)');
assert.strictEqual(
entries[0].placeIdentifier,
'osm:node:2',
'newest first'
);
assert.strictEqual(entries[1].placeIdentifier, 'osm:node:1');
});
test('splits by time proximity within same author + place', function (assert) {
const events = [
makeSocialPhotoEvent({
idx: 1,
author: SOCIAL_PK_A,
placeIdentifier: 'osm:node:1',
created_at: 1000,
}),
makeSocialPhotoEvent({
idx: 2,
author: SOCIAL_PK_A,
placeIdentifier: 'osm:node:1',
created_at: 2000,
}),
// 5-hour gap (> 3h threshold)
makeSocialPhotoEvent({
idx: 3,
author: SOCIAL_PK_A,
placeIdentifier: 'osm:node:1',
created_at: 20000,
}),
];
const entries = groupSocialPhotos(events);
assert.strictEqual(entries.length, 2, 'split by time gap');
assert.strictEqual(entries[0].photos.length, 1, 'newer group has 1 photo');
assert.strictEqual(entries[1].photos.length, 2, 'older group has 2 photos');
});
test('sorts entries newest-first', function (assert) {
const events = [
makeSocialPhotoEvent({
idx: 1,
author: SOCIAL_PK_A,
placeIdentifier: 'osm:node:1',
created_at: 1000,
}),
makeSocialPhotoEvent({
idx: 2,
author: SOCIAL_PK_B,
placeIdentifier: 'osm:node:2',
created_at: 5000,
}),
makeSocialPhotoEvent({
idx: 3,
author: SOCIAL_PK_A,
placeIdentifier: 'osm:node:1',
created_at: 2000,
}),
];
const entries = groupSocialPhotos(events);
assert.strictEqual(entries[0].createdAt, 5000, 'newest entry first');
assert.strictEqual(entries[1].createdAt, 2000, 'second entry');
});
test('sets osmType and osmId from placeIdentifier', function (assert) {
const events = [
makeSocialPhotoEvent({
idx: 1,
author: SOCIAL_PK_A,
placeIdentifier: 'osm:way:999',
created_at: 1000,
}),
];
const entries = groupSocialPhotos(events);
assert.strictEqual(entries[0].osmType, 'way');
assert.strictEqual(entries[0].osmId, '999');
});
test('filters out events without an i tag', function (assert) {
const event = makeSocialPhotoEvent({ idx: 1, author: SOCIAL_PK_A });
event.tags = [['imeta', 'url https://x.com/photo.jpg', 'dim 800x600']];
const entries = groupSocialPhotos([event]);
assert.strictEqual(entries.length, 0, 'no i tag → filtered out');
});
test('applies kind 5 deletions', function (assert) {
const photoId = 'e'.padStart(64, '0');
const events = [
{
id: photoId,
pubkey: SOCIAL_PK_A,
kind: 360,
created_at: 1000,
tags: [
['i', 'osm:node:1'],
['imeta', 'url https://x.com/photo.jpg', 'dim 800x600'],
],
content: '',
sig: 'sig',
},
{
id: 'd'.padStart(64, '0'),
pubkey: SOCIAL_PK_A,
kind: 5,
created_at: 5000,
tags: [['e', photoId]],
content: '',
sig: 'sig',
},
];
const entries = groupSocialPhotos(events);
assert.strictEqual(entries.length, 0, 'deleted photo is filtered out');
});
test('filters out entries with no usable photos (malformed imeta)', function (assert) {
const event = makeSocialPhotoEvent({ idx: 1, author: SOCIAL_PK_A });
event.tags = [
['i', 'osm:node:1'],
['imeta', 'dim 800x600'], // no url
];
const entries = groupSocialPhotos([event]);
assert.strictEqual(entries.length, 0, 'malformed imeta → filtered out');
});
test('photo is set to first photo in the group', function (assert) {
const events = [
makeSocialPhotoEvent({
idx: 1,
author: SOCIAL_PK_A,
url: 'https://x.com/a.jpg',
created_at: 1000,
}),
makeSocialPhotoEvent({
idx: 2,
author: SOCIAL_PK_A,
url: 'https://x.com/b.jpg',
created_at: 2000,
}),
];
const entries = groupSocialPhotos(events);
assert.ok(entries[0].photo, 'photo is set');
assert.strictEqual(
entries[0].photo.url,
'https://x.com/a.jpg',
'first photo (oldest) is the thumbnail'
);
});
});