Add Activity timeline, show zaps received #91

Merged
raucao merged 5 commits from feature/89-zaps_received into master 2026-08-29 21:55:53 +00:00
29 changed files with 2480 additions and 395 deletions
+85
View File
@@ -0,0 +1,85 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
import Icon from './icon';
import ActivityZapItem from './activity-zap-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 restoreScroll from '../modifiers/restore-scroll';
export default class ActivityTimelineComponent extends Component {
@tracked isNostrConnectModalOpen = false;
@action
openNostrConnectModal(event) {
event.preventDefault();
this.isNostrConnectModalOpen = true;
}
@action
closeNostrConnectModal() {
this.isNostrConnectModalOpen = false;
}
@action
onNostrConnected() {
this.closeNostrConnectModal();
this.args.onNostrConnected?.();
}
<template>
<div class="sidebar">
<div class="sidebar-header has-back-btn">
<button type="button" class="back-btn" {{on "click" @onBack}}>
<Icon @name="arrow-left" @size={{20}} @color="#333" />
</button>
<h2 class="sidebar-header-text-centered">
<span class="sidebar-header-icon-wrapper">
<Icon @name="activity" @size={{20}} @color="#898989" />
</span>
Activity
</h2>
<button type="button" class="close-btn" {{on "click" @onClose}}>
<Icon @name="x" @size={{20}} @color="#333" />
</button>
</div>
<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)}}
<p class="empty-state">
<a
href="#"
role="button"
tabindex="0"
{{on "click" this.openNostrConnectModal}}
>Connect your Nostr account</a>
to see your activity.
</p>
{{else if (not @items.length)}}
<p class="empty-state">No activity yet.</p>
{{else}}
<ul class="activity-list">
{{#each @items as |item|}}
{{#if (eq item.type "zap")}}
<ActivityZapItem @item={{item}} @onSelect={{@onSelect}} />
{{/if}}
{{/each}}
</ul>
{{/if}}
</div>
</div>
{{#if this.isNostrConnectModalOpen}}
<Modal @onClose={{this.closeNostrConnectModal}}>
<NostrConnect @onConnect={{this.onNostrConnected}} />
</Modal>
{{/if}}
</template>
}
+105
View File
@@ -0,0 +1,105 @@
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 ActivityZapItem 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.item?.photo;
}
get photoThumbUrl() {
return this.item?.photo?.thumbUrl || this.item?.photo?.url;
}
get placeName() {
return this.item?.placeName;
}
get placeNameLoading() {
return this.item?.placeNameLoading;
}
<template>
<li>
<button
type="button"
class="activity-zap-item"
{{on "click" (fn @onSelect @item)}}
>
<div class="zap-header">
<span class="zap-sender-line">
<span class="zap-sender-name">{{this.senderDisplayName}}</span>
{{! template-lint-disable no-whitespace-for-layout }}
<span class="zap-action"> zapped your photo</span>
</span>
<span class="zap-amount">{{this.item.amountSats}} ⚡</span>
</div>
<div class="zap-context">
<div class="zap-context-images">
{{#if this.senderAvatar}}
<img
class="zap-sender-avatar"
src={{this.senderAvatar}}
alt=""
loading="lazy"
/>
{{else}}
<div class="zap-sender-avatar-placeholder">
<Icon @name="user" @size={{16}} @color="#999" />
</div>
{{/if}}
{{#if this.hasPhoto}}
<div class="zap-context-thumb">
<img src={{this.photoThumbUrl}} alt="" loading="lazy" />
</div>
{{/if}}
</div>
<div class="zap-context-text">
<span class="zap-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>
{{/if}}
</span>
<span class="zap-date">{{formatRelativeDate @item.createdAt}}</span>
</div>
</div>
{{#if this.item.message}}
<div class="zap-message">{{this.item.message}}</div>
{{/if}}
</button>
</li>
</template>
}
+7 -1
View File
@@ -26,8 +26,14 @@ import iconRounded from '../../icons/icon-rounded.svg?raw';
</button>
</li>
<li>
<button type="button" {{on "click" @onContributions}}>
<button type="button" {{on "click" @onActivity}}>
<Icon @name="activity" @size={{20}} />
<span>Activity</span>
</button>
</li>
<li>
<button type="button" {{on "click" @onContributions}}>
<Icon @name="user-check" @size={{20}} />
<span>My Contributions</span>
</button>
</li>
+6
View File
@@ -28,6 +28,11 @@ export default class AppMenu extends Component {
this.router.transitionTo('contributions');
}
@action
goToActivity() {
this.router.transitionTo('activity');
}
<template>
<div class="sidebar app-menu-pane">
{{#if (eq this.currentView "menu")}}
@@ -36,6 +41,7 @@ export default class AppMenu extends Component {
@onClose={{@onClose}}
@onSavedPlaces={{this.goToSavedPlaces}}
@onContributions={{this.goToContributions}}
@onActivity={{this.goToActivity}}
/>
{{else if (eq this.currentView "settings")}}
+59
View File
@@ -0,0 +1,59 @@
import Controller from '@ember/controller';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { task } from 'ember-concurrency';
export default class ActivityController extends Controller {
@service router;
@service mapUi;
@service nostrAuth;
@service activity;
loadTask = task({ restartable: true }, async (pubkey) => {
if (!pubkey) return;
await this.activity.load(pubkey);
});
get scrollTop() {
return this.mapUi.getScrollPosition('activity');
}
get items() {
return this.activity.items;
}
get isConnected() {
return this.nostrAuth.isConnected;
}
@action
selectItem(item) {
if (!item || !item.placeIdentifier) return;
const sidebarContent = document.querySelector('.sidebar-content');
if (sidebarContent) {
this.mapUi.saveScrollPosition('activity', sidebarContent.scrollTop);
}
this.mapUi.returnToRoute = { name: 'activity' };
this.mapUi.showSidebar();
this.mapUi.preventNextZoom = true;
this.router.transitionTo(`/place/${item.placeIdentifier}`);
}
@action
onNostrConnected() {
this.loadTask.perform(this.nostrAuth.pubkey);
}
@action
backToMenu() {
this.router.transitionTo('menu');
}
@action
close() {
this.router.transitionTo('index');
}
}
+1
View File
@@ -15,6 +15,7 @@ Router.map(function () {
this.route('list', { path: '/:list_id' });
});
this.route('contributions');
this.route('activity');
this.route('oauth', function () {
this.route('osm-callback', { path: '/osm/callback' });
});
+23
View File
@@ -0,0 +1,23 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class ActivityRoute extends Route {
@service mapUi;
@service nostrAuth;
@service activity;
activate() {
this.mapUi.showSidebar();
}
setupController(controller, model) {
super.setupController(controller, model);
if (controller && controller.loadTask) {
controller.loadTask.perform(this.nostrAuth.pubkey);
}
}
deactivate() {
this.activity.stop();
}
}
+206
View File
@@ -0,0 +1,206 @@
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';
/**
* Orchestrates loading the user's incoming social activity (zaps received on
* their photos) and exposing it as a tracked `items` list for the activity
* timeline.
*
* The flow is:
* 1. Subscribe to `nostrData.store.timeline(...)` for kind 9735 zap receipts
* where the user is the recipient (`#p` filter).
* 2. Parse each receipt into an `ActivityEntry`, filtering to zaps on the
* user's own kind 360 photos.
* 3. Resolve sender profiles asynchronously via a per-sender `ProfileModel`
* subscription, updating the tracked entry fields when they load.
* 4. Resolve place names via the shared `placeNameResolver` service.
* 5. Update `@tracked items` so the UI renders progressively.
*/
export default class ActivityService extends Service {
@service nostrData;
@service nostrAuth;
@service placeNameResolver;
@tracked items = [];
_sub = null;
_profileSubs = new Map();
_userPubkey = null;
/**
* Loads the user's incoming zap receipts and subscribes to live updates.
*
* @param {string} pubkey The user's Nostr pubkey
*/
async load(pubkey) {
if (!pubkey) {
this.items = [];
return;
}
this._userPubkey = pubkey;
const filters = [{ kinds: [9735], '#p': [pubkey] }];
console.debug('[activity] Subscribing to zap receipts', {
filters,
pubkey,
activeReadRelays: this.nostrData.activeReadRelays,
});
// Subscribe to the store timeline so we get live updates as receipts
// arrive and are added to the store.
this._sub = this.nostrData.store.timeline(filters).subscribe((events) => {
this._updateItems(events, pubkey);
});
// Ensure the user's kind 360 photo events are in the store first so
// enrichWithPhoto can look them up when zap receipts arrive.
await this.nostrData.loadMyContributions(pubkey);
// Then load zap receipts — adding them to the store triggers the
// timeline subscription, and by now the photo events are available.
await this.nostrData.loadIncomingZaps(pubkey);
}
/**
* Stops subscriptions and clears the timeline. Called when leaving the
* activity route.
*/
stop() {
if (this._sub) {
this._sub.unsubscribe();
this._sub = null;
}
this._cleanupProfileSubs();
this.items = [];
this._userPubkey = null;
this.placeNameResolver.reset();
}
willDestroy() {
this.stop();
super.willDestroy(...arguments);
}
_updateItems(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(
entry.osmId
);
if (bookmarkName) {
entry.placeName = bookmarkName;
entry.placeNameLoading = false;
}
}
}
this.items = entries;
// 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];
});
}
_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)
.subscribe((profileContent) => {
this._applyProfileToPubkey(pubkey, profileContent);
});
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) {
profile = getProfileContent(event);
}
}
if (profile) {
entry.senderName = profile.name || profile.display_name || null;
entry.senderAvatar = profile.picture || null;
entry.senderProfileLoading = false;
}
}
_applyProfileToPubkey(pubkey, profileContent) {
// Update all entries matching this sender
let changed = false;
for (const entry of this.items) {
if (entry.senderPubkey === pubkey) {
entry.senderName =
profileContent.name || profileContent.display_name || null;
entry.senderAvatar = profileContent.picture || null;
entry.senderProfileLoading = false;
changed = true;
}
}
if (changed) {
// Trigger re-render
this.items = [...this.items];
}
}
_cleanupProfileSubs() {
for (const sub of this._profileSubs.values()) {
sub.unsubscribe();
}
this._profileSubs.clear();
}
}
+8 -191
View File
@@ -2,8 +2,6 @@ import Service, { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { groupPhotoContributions } from '../utils/contributions';
const NAME_CACHE_STORE = 'contributions-name-cache';
/**
* Orchestrates loading the user's own Nostr contributions, grouping them into
* timeline entries, and resolving place names from bookmarks or the OSM API.
@@ -25,77 +23,11 @@ const NAME_CACHE_STORE = 'contributions-name-cache';
export default class ContributionsService extends Service {
@service nostrData;
@service nostrAuth;
@service storage;
@service osm;
@service localForage;
@service placeNameResolver;
@tracked items = [];
_sub = null;
_pendingBatchPromise = null;
_lastBatchSignature = '';
_unresolvable = new Set();
/**
* Async name resolution. Checks, in order:
* 1. Bookmarks (sync, instant).
* 2. The persistent IndexedDB name cache (per-entry key).
* 3. The OSM service's IndexedDB cache (from place detail visits).
*
* @param {object} entry A contribution entry with `osmType`, `osmId`, and `placeIdentifier`.
* @returns {Promise<string|null>}
*/
async _resolveCachedName(entry) {
// 1. Try bookmarks (instant)
const bookmark = this.storage.findPlaceById(entry.osmId);
if (bookmark?.title) return bookmark.title;
// 2. Try the persistent name cache (IndexedDB, per-entry, survives sessions)
const cachedName = await this.localForage.get(
NAME_CACHE_STORE,
entry.placeIdentifier
);
if (cachedName) return cachedName;
// 3. Try OSM IndexedDB cache (from place detail visits)
const cached = await this.osm.getCachedOsmObject(
entry.osmType,
entry.osmId
);
return cached?.title || null;
}
/**
* Asynchronously resolves names for still-loading entries from the caches.
* Falls through to `_maybeBatchFetchNames` for anything that remains
* unresolved. Fire-and-forget from `_updateItems` so the list renders
* immediately with bookmark-resolved names.
*
* @param {Array} entries
* @returns {Promise<void>}
*/
async _resolveFromCache(entries) {
const pending = entries.filter((e) => e.placeNameLoading);
if (pending.length === 0) {
this._maybeBatchFetchNames(this.items);
return;
}
await Promise.all(
pending.map(async (entry) => {
const name = await this._resolveCachedName(entry);
if (name) {
entry.placeName = name;
entry.placeNameLoading = false;
}
})
);
// Re-render with whatever resolved, then kick off the network batch for
// entries that are still loading.
this.items = [...this.items];
this._maybeBatchFetchNames(this.items);
}
/**
* Loads the user's contributions and subscribes to live updates.
@@ -131,9 +63,7 @@ export default class ContributionsService extends Service {
this._sub = null;
}
this.items = [];
this._lastBatchSignature = '';
this._pendingBatchPromise = null;
this._unresolvable.clear();
this.placeNameResolver.reset();
}
willDestroy() {
@@ -148,9 +78,9 @@ export default class ContributionsService extends Service {
// 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) {
const bookmark = this.storage.findPlaceById(entry.osmId);
if (bookmark?.title) {
entry.placeName = bookmark.title;
const bookmarkName = this.placeNameResolver.resolveBookmark(entry.osmId);
if (bookmarkName) {
entry.placeName = bookmarkName;
entry.placeNameLoading = false;
}
}
@@ -159,121 +89,8 @@ export default class ContributionsService extends Service {
// 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._resolveFromCache(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)) {
entry.placeName = this._fallbackName(entry);
entry.placeNameLoading = false;
}
}
if (unresolved.length === 0) {
if (this.items.some((i) => !i.placeNameLoading)) {
this.items = [...this.items];
}
return;
}
// Build a stable signature so we only fire one batch request per unique set
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 current `items`.
for (const item of this.items) {
if (!item.placeNameLoading) continue;
if (nameMap.has(item.placeIdentifier)) {
const name = nameMap.get(item.placeIdentifier);
item.placeName = name;
item.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(item.placeIdentifier);
item.placeName = this._fallbackName(item);
item.placeNameLoading = false;
}
}
// Trigger a re-render
this.items = [...this.items];
})
.catch((e) => {
console.error('[contributions] Batch name resolution failed', e);
// On a total failure, mark all as unresolvable and apply fallbacks
for (const item of this.items) {
if (item.placeNameLoading) {
this._unresolvable.add(item.placeIdentifier);
item.placeName = this._fallbackName(item);
item.placeNameLoading = false;
}
}
this.items = [...this.items];
})
.finally(() => {
this._pendingBatchPromise = null;
});
}
_fallbackName(item) {
return `OSM ${item.osmType} ${item.osmId}`;
}
async _batchResolveNames(entries) {
const nameMap = new Map();
const toFetch = [];
// Re-check cache in case it was populated between the trigger and now
for (const entry of entries) {
const cached = await this._resolveCachedName(entry);
if (cached) {
nameMap.set(entry.placeIdentifier, cached);
} else {
toFetch.push({ osmType: entry.osmType, osmId: entry.osmId });
}
}
if (toFetch.length === 0) return nameMap;
const places = await this.osm.fetchOsmObjectsBatch(toFetch);
// Persist each newly-resolved name to the IndexedDB name cache as its own
// entry (per-placeIdentifier key) so we don't re-fetch next session.
const writePromises = [];
for (const entry of entries) {
if (nameMap.has(entry.placeIdentifier)) continue;
const cacheKey = `${entry.osmType}:${entry.osmId}`;
const place = places.get(cacheKey);
if (place?.title) {
nameMap.set(entry.placeIdentifier, place.title);
writePromises.push(
this.localForage.set(
NAME_CACHE_STORE,
entry.placeIdentifier,
place.title
)
);
}
}
await Promise.all(writePromises);
return nameMap;
void this.placeNameResolver.resolveInBackground(entries).then(() => {
this.items = [...this.items];
});
}
}
+53
View File
@@ -95,6 +95,7 @@ export default class NostrDataService extends Service {
_zapReceiptsNetworkSub = null;
_zapRefreshTimer = null;
_lastPhotoIds = new Set();
_incomingZapsNetworkSub = null;
_requestSub = null;
_cachePromise = null;
@@ -551,6 +552,54 @@ export default class NostrDataService extends Service {
);
}
/**
* Hydrates the store with incoming zap receipts (kind 9735) where the user is
* the recipient (`#p` filter). Loads from the IDB cache first (instant), then
* fires a network request. Called by the `activity` service.
*
* @param {string} pubkey The user's Nostr pubkey
*/
async loadIncomingZaps(pubkey) {
if (!pubkey) return;
const filters = [{ kinds: [9735], '#p': [pubkey] }];
console.debug('[nostr-data] Requesting incoming zap receipts from relays', {
filters,
relays: this.activeReadRelays,
});
if (this._incomingZapsNetworkSub) {
this._incomingZapsNetworkSub.unsubscribe();
this._incomingZapsNetworkSub = null;
}
// 1. Populate the store from the local Nostr IDB cache (instant)
try {
await this._cachePromise;
const cachedEvents = await this.cache.query(filters);
if (cachedEvents && cachedEvents.length > 0) {
for (const event of cachedEvents) {
this.store.add(event);
}
}
} catch (e) {
console.warn(
'[nostr-data] Failed to read incoming zap receipts from local Nostr IDB cache',
e
);
}
// 2. Request fresh events from the network in the background
this._incomingZapsNetworkSub = this._requestContentWithProvenance(
this.activeReadRelays,
filters,
'[nostr-data] Error fetching incoming zap receipts:'
);
}
loadProfiles(pubkeys) {
const newPubkeys = pubkeys.filter(
(pk) => pk && !this._profileModelSubs.has(pk)
@@ -843,6 +892,10 @@ export default class NostrDataService extends Service {
}
this._cleanupZapReceiptSubs();
this._clearZapRefreshTimer();
if (this._incomingZapsNetworkSub) {
this._incomingZapsNetworkSub.unsubscribe();
this._incomingZapsNetworkSub = null;
}
}
willDestroy() {
+20 -5
View File
@@ -457,11 +457,11 @@ out center;
// request using comma-separated IDs (max ~50 per request).
//
// Note: the multi-fetch endpoint returns ways/relations WITHOUT their child
// nodes, so normalized data may lack lat/lon and geometry. We intentionally
// do NOT write these results to the general OSM cache — only the returned
// Map is used by the caller (the contributions service manages its own
// name cache). This prevents incomplete data from breaking place detail
// navigation, which needs the full single-object endpoint.
// nodes, so normalized data may lack lat/lon and geometry.
// We only write complete results (those with lat/lon) to the general OSM
// cache. Nodes from the batch endpoint always include coordinates, so they
// are cached. Ways/relations without child node coordinates are not cached,
// preserving the full single-object endpoint for place detail navigation.
const MAX_IDS_PER_REQUEST = 50;
for (let i = 0; i < group.length; i += MAX_IDS_PER_REQUEST) {
@@ -488,6 +488,21 @@ out center;
);
if (normalized) {
result.set(item.cacheKey, normalized);
// Warm the OSM cache for complete results (nodes always have
// lat/lon from the batch endpoint; ways/relations only when
// child nodes are included in the response).
if (
normalized.lat != null &&
normalized.lon != null &&
normalized.title
) {
this._storeInMemoryAndLocalStorage(
item.cacheKey,
osmType,
item.osmId,
normalized
);
}
}
}
} catch (e) {
+196
View File
@@ -0,0 +1,196 @@
import Service, { service } from '@ember/service';
const NAME_CACHE_STORE = 'place-name-cache';
export default class PlaceNameResolverService extends Service {
@service storage;
@service osm;
@service localForage;
_pendingBatchPromise = null;
_lastBatchSignature = '';
_unresolvable = new Set();
/**
* Synchronous bookmark lookup. Returns the bookmark title if found, null otherwise.
*
* @param {string} osmId The OSM entity ID (e.g. "123456")
* @returns {string|null}
*/
resolveBookmark(osmId) {
const bookmark = this.storage.findPlaceById(osmId);
return bookmark?.title || null;
}
/**
* Asynchronous name resolution from caches (localForage → OSM IDB cache).
* Returns the resolved name or null if not in cache.
*
* @param {object} entry Entry with placeIdentifier, osmType, osmId
* @returns {Promise<string|null>}
*/
async _resolveCachedName(entry) {
// 1. Try the persistent name cache (IndexedDB, per-entry, survives sessions)
const cachedName = await this.localForage.get(
NAME_CACHE_STORE,
entry.placeIdentifier
);
if (cachedName) return cachedName;
// 2. Try OSM IndexedDB cache (from place detail visits)
const cached = await this.osm.getCachedOsmObject(
entry.osmType,
entry.osmId
);
return cached?.title || null;
}
/**
* Resolves names for entries in the background.
* Fire-and-forget: resolves from caches, then falls through to batch OSM fetch
* for anything still unresolved. Mutates entry.placeName and entry.placeNameLoading.
* The caller is responsible for triggering a re-render (e.g. `this.items = [...this.items]`).
*
* @param {Array} entries Entries with placeIdentifier, osmType, osmId, placeName, placeNameLoading
* @returns {Promise<void>}
*/
async resolveInBackground(entries) {
const pending = entries.filter((e) => e.placeNameLoading);
if (pending.length === 0) {
this._maybeBatchFetchNames(entries);
return;
}
await Promise.all(
pending.map(async (entry) => {
const name = await this._resolveCachedName(entry);
if (name) {
entry.placeName = name;
entry.placeNameLoading = false;
}
})
);
// 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)) {
entry.placeName = this._fallbackName(entry);
entry.placeNameLoading = false;
}
}
if (unresolved.length === 0) {
return;
}
// Build a stable signature so we only fire one batch request per unique set
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(() => {
this._pendingBatchPromise = null;
});
}
_fallbackName(entry) {
return `OSM ${entry.osmType} ${entry.osmId}`;
}
async _batchResolveNames(entries) {
const nameMap = new Map();
const toFetch = [];
// Re-check cache in case it was populated between the trigger and now
for (const entry of entries) {
const cached = await this._resolveCachedName(entry);
if (cached) {
nameMap.set(entry.placeIdentifier, cached);
} else {
toFetch.push({ osmType: entry.osmType, osmId: entry.osmId });
}
}
if (toFetch.length === 0) return nameMap;
const places = await this.osm.fetchOsmObjectsBatch(toFetch);
// Persist each newly-resolved name to the IndexedDB name cache as its own
// entry (per-placeIdentifier key) so we don't re-fetch next session.
const writePromises = [];
for (const entry of entries) {
if (nameMap.has(entry.placeIdentifier)) continue;
const cacheKey = `${entry.osmType}:${entry.osmId}`;
const place = places.get(cacheKey);
if (place?.title) {
nameMap.set(entry.placeIdentifier, place.title);
writePromises.push(
this.localForage.set(
NAME_CACHE_STORE,
entry.placeIdentifier,
place.title
)
);
}
}
await Promise.all(writePromises);
return nameMap;
}
/**
* Resets per-session state. Called when the owning service stops.
*/
reset() {
this._unresolvable.clear();
this._lastBatchSignature = '';
this._pendingBatchPromise = null;
}
}
+2
View File
@@ -61,6 +61,7 @@ export default class StorageService extends Service {
this.rs.on('connected', () => {
this.connected = true;
this.userAddress = this.rs.remote.userAddress;
this.initialSyncDone = false;
if (this.isNewConnection) {
this.toast.show('Remote storage connected', 3000);
@@ -71,6 +72,7 @@ export default class StorageService extends Service {
});
this.rs.on('not-connected', () => {
this.initialSyncDone = true;
this.loadLists();
});
+154
View File
@@ -2660,3 +2660,157 @@ button.create-place {
color: var(--body-text-color);
}
}
/* Activity Timeline — zap activity list rendered in the sidebar */
.activity-list {
list-style: none;
padding: 0;
margin: -1rem -1rem 0;
}
.activity-zap-item {
width: 100%;
text-align: left;
border: none;
border-bottom: 1px solid var(--divider-color);
background: var(--primary-background-color);
color: var(--body-text-color);
padding: 0.75rem 1rem;
cursor: pointer;
transition: background 0.2s;
font-family: inherit;
display: flex;
flex-direction: column;
gap: 4px;
&:hover {
background: var(--hover-bg);
}
& .zap-sender-avatar {
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
background: #f0f0f0;
}
& .zap-sender-avatar-placeholder {
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 50%;
background: #e0e0e0;
display: flex;
align-items: center;
justify-content: center;
color: #999;
}
& .zap-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
& .zap-sender-line {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.95rem;
& .zap-sender-name {
font-weight: bold;
}
& .zap-action {
color: #666;
font-weight: normal;
}
}
& .zap-amount {
flex-shrink: 0;
font-weight: bold;
font-size: 0.95rem;
color: var(--body-text-color);
white-space: nowrap;
}
& .zap-message {
color: var(--body-text-color);
font-size: 0.85rem;
font-style: italic;
position: relative;
background: var(--secondary-background-color);
border-radius: 8px;
padding: 0.5rem 0.75rem;
margin-top: 4px;
&::before {
content: '';
position: absolute;
top: -5px;
left: 11px;
width: 10px;
height: 10px;
background: var(--secondary-background-color);
transform: rotate(45deg);
}
}
& .zap-context {
display: flex;
align-items: center;
gap: 6px;
color: #999;
font-size: 0.8rem;
margin-top: 8px;
& .zap-context-images {
display: flex;
align-items: center;
gap: 0.5rem;
}
& .zap-context-thumb {
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 4px;
overflow: hidden;
background: #f0f0f0;
& img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
& .zap-context-text {
display: flex;
align-items: baseline;
gap: 6px;
flex: 1 1 auto;
min-width: 0;
& .zap-place-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 1 auto;
min-width: 0;
}
& .zap-date {
flex-shrink: 0;
white-space: nowrap;
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
import ActivityTimeline from '#components/activity-timeline';
<template>
{{#if @controller.mapUi.isSidebarVisible}}
<ActivityTimeline
@items={{@controller.items}}
@isLoading={{@controller.loadTask.isRunning}}
@isConnected={{@controller.isConnected}}
@scrollTop={{@controller.scrollTop}}
@onSelect={{@controller.selectItem}}
@onBack={{@controller.backToMenu}}
@onClose={{@controller.close}}
@onNostrConnected={{@controller.onNostrConnected}}
/>
{{/if}}
</template>
+3
View File
@@ -23,6 +23,7 @@ export default class ApplicationComponent extends Component {
name === 'search' ||
name === 'menu' ||
name === 'contributions' ||
name === 'activity' ||
name.startsWith('lists'))
);
}
@@ -51,6 +52,7 @@ export default class ApplicationComponent extends Component {
name === 'place' ||
name === 'menu' ||
name === 'contributions' ||
name === 'activity' ||
name.startsWith('lists')
) {
this.mapUi.clearSelection();
@@ -58,6 +60,7 @@ export default class ApplicationComponent extends Component {
if (
name === 'menu' ||
name === 'contributions' ||
name === 'activity' ||
name.startsWith('lists')
) {
this.router.transitionTo('index');
+142
View File
@@ -0,0 +1,142 @@
/**
* Utilities for parsing social activity events into timeline entries.
*
* An `ActivityEntry` represents a single social interaction related to the
* user's content (e.g. a zap received on one of their photos). Entries are
* ordered newest-first.
*/
import { tracked } from '@glimmer/tracking';
import {
getZapSender,
getZapRecipient,
getZapPayment,
getZapEventPointer,
getZapRequest,
} from 'applesauce-common/helpers';
import { parsePhotoFromEvent } from './contributions';
/**
* A single activity timeline entry.
*
* `senderName`, `senderAvatar`, `senderProfileLoading`, `placeName`,
* and `placeNameLoading` are tracked so the row re-renders when the sender's
* profile or place name is resolved asynchronously.
*/
export class ActivityEntry {
type = 'zap';
photoEventId;
photo;
placeIdentifier;
osmType;
osmId;
senderPubkey;
amountSats;
message;
createdAt;
@tracked senderName = null;
@tracked senderAvatar = null;
@tracked senderProfileLoading = true;
@tracked placeName = null;
@tracked placeNameLoading = true;
constructor({
photoEventId,
photo,
placeIdentifier,
senderPubkey,
amountSats,
message,
createdAt,
}) {
this.photoEventId = photoEventId;
this.photo = photo;
this.placeIdentifier = placeIdentifier;
this.senderPubkey = senderPubkey;
this.amountSats = amountSats;
this.message = message;
this.createdAt = createdAt;
}
}
// TODO: Place-name resolution. Add a shared place-name-resolver util co-used by
// services/contributions.js and this service. Each ActivityEntry should get
// @tracked placeName / placeNameLoading, resolved via the existing cascade
// (bookmarks → localForage cache → osm cache → batch OSM fetch). Extract from
// contributions.js rather than duplicating.
/**
* Parses a kind 9735 (Zap Receipt) event into an `ActivityEntry`, given the
* user's pubkey (to confirm they are the recipient).
*
* Returns `null` if the receipt is malformed, not directed at the user, or
* does not reference a zapped event (kind 360 photo).
*
* @param {object} receipt A NIP-57 zap receipt event (kind 9735)
* @param {string} userPubkey The logged-in user's pubkey
* @returns {ActivityEntry|null}
*/
export function parseZapReceipt(receipt, userPubkey) {
if (!receipt || receipt.kind !== 9735) return null;
const recipient = getZapRecipient(receipt);
if (!recipient || recipient !== userPubkey) return null;
const sender = getZapSender(receipt);
if (!sender) return null;
const payment = getZapPayment(receipt);
if (!payment || !payment.amount) return null;
const amountSats = Math.round(payment.amount / 1000);
const eventPointer = getZapEventPointer(receipt);
if (!eventPointer || !eventPointer.id) return null;
const zapRequest = getZapRequest(receipt);
const message = zapRequest?.content || null;
return new ActivityEntry({
photoEventId: eventPointer.id,
photo: null,
placeIdentifier: null,
senderPubkey: sender,
amountSats,
message,
createdAt: receipt.created_at,
});
}
/**
* Enriches an `ActivityEntry` with photo and place data from the zapped kind
* 360 event, if it is available in the store.
*
* Returns `true` if the entry was enriched (i.e. the zapped event was found
* and is a kind 360 authored by the user), or `false` if the entry should be
* discarded (the zapped event is not one of the user's photos).
*
* @param {ActivityEntry} entry
* @param {object} store The applesauce EventStore to look up the zapped event
* @param {string} userPubkey The logged-in user's pubkey
* @returns {boolean}
*/
export function enrichWithPhoto(entry, store, userPubkey) {
const event = store.getEvent?.(entry.photoEventId);
if (!event || event.kind !== 360 || event.pubkey !== userPubkey) {
return false;
}
const photo = parsePhotoFromEvent(event);
if (!photo) return false;
entry.photo = photo;
entry.placeIdentifier = photo.placeIdentifier || null;
// Parse osmType and osmId from placeIdentifier (e.g. "osm:node:123" → "node", "123")
if (entry.placeIdentifier) {
const [, osmType, osmId] = entry.placeIdentifier.split(':');
entry.osmType = osmType;
entry.osmId = osmId;
}
return true;
}
+1 -1
View File
@@ -54,7 +54,7 @@ export class ContributionEntry {
* @param {object} event A NIP-360 (kind 360) Nostr event
* @returns {object|null} A photo object, or null if no usable `imeta` was found
*/
function parsePhotoFromEvent(event) {
export function parsePhotoFromEvent(event) {
const tags = event.tags || [];
const eventTags = tags
+2 -1
View File
@@ -26,9 +26,10 @@ export function formatRelativeDate(timestamp) {
if (diffMin < 60) return `${diffMin} min ago`;
if (diffHr < 24) return `${diffHr} hr ago`;
if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
const isCurrentYear = date.getFullYear() === now.getFullYear();
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
...(isCurrentYear ? {} : { year: 'numeric' }),
});
}
+2
View File
@@ -39,6 +39,7 @@ import target from 'feather-icons/dist/icons/target.svg?raw';
import trash2 from 'feather-icons/dist/icons/trash-2.svg?raw';
import uploadCloud from 'feather-icons/dist/icons/upload-cloud.svg?raw';
import user from 'feather-icons/dist/icons/user.svg?raw';
import userCheck from 'feather-icons/dist/icons/user-check.svg?raw';
import x from 'feather-icons/dist/icons/x.svg?raw';
import check from 'feather-icons/dist/icons/check.svg?raw';
import alertCircle from 'feather-icons/dist/icons/alert-circle.svg?raw';
@@ -281,6 +282,7 @@ const ICONS = {
'upload-cloud': uploadCloud,
'tree-and-bench-with-backrest': treeAndBenchWithBackrest,
user,
'user-check': userCheck,
'village-buildings': villageBuildings,
'wall-hanging-with-mountains-and-sun': wallHangingWithMountainsAndSun,
'womens-and-mens-restroom-symbol': womensAndMensRestroomSymbol,
+141
View File
@@ -0,0 +1,141 @@
import { module, test } from 'qunit';
import { visit, currentURL, click, waitFor } from '@ember/test-helpers';
import { setupApplicationTest } from 'marco/tests/helpers';
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
class MockActivityService extends Service {
@tracked items = [
{
type: 'zap',
photoEventId: 'photo-1',
photo: {
url: 'https://x.com/photo.jpg',
thumbUrl: 'https://x.com/thumb.jpg',
},
placeIdentifier: 'osm:node:123',
senderPubkey: 'b'.repeat(64),
amountSats: 21,
message: 'Great photo!',
createdAt: Math.floor(Date.now() / 1000) - 3600,
senderName: 'Alice',
senderAvatar: 'https://x.com/avatar.jpg',
senderProfileLoading: false,
},
];
async load() {}
stop() {
this.items = [];
}
}
class MockNostrAuthService extends Service {
@tracked pubkey = 'test-pubkey';
get isConnected() {
return true;
}
}
class MockStorageService extends Service {
initialSyncDone = true;
savedPlaces = [];
findPlaceById() {
return null;
}
isPlaceSaved() {
return false;
}
loadPlacesInBounds() {
return [];
}
get placesInView() {
return [];
}
rs = {
on: () => {},
};
}
class MockOsmService extends Service {
async fetchOsmObject() {
return {
osmId: '123',
osmType: 'node',
lat: 1,
lon: 1,
osmTags: { name: 'Test Place', amenity: 'cafe' },
title: 'Test Place',
};
}
getCachedOsmObject() {
return Promise.resolve(null);
}
}
module('Acceptance | activity', function (hooks) {
setupApplicationTest(hooks);
hooks.beforeEach(function () {
this.owner.register('service:activity', MockActivityService);
this.owner.register('service:nostrAuth', MockNostrAuthService);
this.owner.register('service:storage', MockStorageService);
this.owner.register('service:osm', MockOsmService);
});
test('visiting /activity renders the sidebar with activity items', async function (assert) {
await visit('/activity');
assert.strictEqual(currentURL(), '/activity');
assert.dom('.sidebar').exists('Sidebar is rendered');
assert.dom('.sidebar-header-text-centered').includesText('Activity');
});
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!');
});
test('closing the sidebar returns to index', async function (assert) {
await visit('/activity');
assert.strictEqual(currentURL(), '/activity');
await click('.sidebar-header .close-btn');
assert.strictEqual(currentURL(), '/', 'Returns to index');
});
test('clicking back button returns to menu', async function (assert) {
await visit('/activity');
await click('.sidebar-header .back-btn');
assert.strictEqual(currentURL(), '/menu', 'Returns to menu');
});
test('clicking a zap item navigates to the place', async function (assert) {
const mapUi = this.owner.lookup('service:map-ui');
await visit('/activity');
await waitFor('.activity-zap-item');
await click('.activity-zap-item');
assert.ok(
currentURL().includes('/place/osm:node:123'),
'Transitions to place details'
);
assert.deepEqual(
mapUi.returnToRoute,
{ name: 'activity' },
'returnToRoute is set to activity'
);
});
});
@@ -0,0 +1,176 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'marco/tests/helpers';
import { render, click } from '@ember/test-helpers';
import ActivityTimeline from 'marco/components/activity-timeline';
import { setupNostrMocks } from 'marco/tests/helpers/mock-nostr';
function noop() {}
module('Integration | Component | activity-timeline', function (hooks) {
setupRenderingTest(hooks);
setupNostrMocks(hooks);
hooks.beforeEach(function () {
this.noop = noop;
this.emptyItems = [];
});
test('it renders a loading state', async function (assert) {
await render(
<template>
<ActivityTimeline
@items={{this.emptyItems}}
@isLoading={{true}}
@isConnected={{true}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@onNostrConnected={{this.noop}}
/>
</template>
);
assert.dom('.sidebar-loading').exists();
assert.dom('.activity-list').doesNotExist();
});
test('it renders a not-connected state', async function (assert) {
await render(
<template>
<ActivityTimeline
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{false}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@onNostrConnected={{this.noop}}
/>
</template>
);
assert.dom('.empty-state').includesText('Connect your Nostr account');
});
test('clicking "Connect your Nostr account" opens the Nostr connect modal', async function (assert) {
await render(
<template>
<div id="modal-portal"></div>
<ActivityTimeline
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{false}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@onNostrConnected={{this.noop}}
/>
</template>
);
assert.dom('.nostr-connect-modal').doesNotExist();
await click('.empty-state a');
assert.dom('.nostr-connect-modal').exists();
});
test('it renders an empty state when connected but no activity', async function (assert) {
await render(
<template>
<ActivityTimeline
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{true}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@onNostrConnected={{this.noop}}
/>
</template>
);
assert.dom('.empty-state').includesText('No activity yet');
});
test('it renders activity zap items', 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,
},
{
type: 'zap',
photoEventId: 'photo-2',
photo: {
url: 'https://x.com/photo2.jpg',
thumbUrl: 'https://x.com/thumb2.jpg',
},
placeIdentifier: 'osm:node:222',
senderPubkey: 'c'.repeat(64),
amountSats: 100,
message: null,
createdAt: 1000,
senderName: 'Bob',
senderAvatar: null,
senderProfileLoading: false,
},
];
await render(
<template>
<ActivityTimeline
@items={{this.items}}
@isLoading={{false}}
@isConnected={{true}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@onNostrConnected={{this.noop}}
/>
</template>
);
assert.dom('.activity-zap-item').exists({ count: 2 });
assert.dom(this.element).includesText('Alice');
assert.dom(this.element).includesText('21 ⚡');
assert.dom(this.element).includesText('Bob');
assert.dom(this.element).includesText('100 ⚡');
});
test('clicking the back button fires @onBack', async function (assert) {
let backClicked = false;
this.handleBack = () => {
backClicked = true;
};
await render(
<template>
<ActivityTimeline
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{true}}
@onSelect={{this.noop}}
@onBack={{this.handleBack}}
@onClose={{this.noop}}
@onNostrConnected={{this.noop}}
/>
</template>
);
await click('.sidebar-header .back-btn');
assert.true(backClicked);
});
});
@@ -0,0 +1,296 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'marco/tests/helpers';
import { render, click } from '@ember/test-helpers';
import ActivityZapItem from 'marco/components/activity-zap-item';
import { ActivityEntry } from 'marco/utils/activity';
function noop() {}
module('Integration | Component | activity-zap-item', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.noop = noop;
});
test('it renders sender name, action, amount, and message', async function (assert) {
this.item = new ActivityEntry({
photoEventId: 'photo-1',
photo: {
url: 'https://x.com/photo.jpg',
thumbUrl: 'https://x.com/thumb.jpg',
},
placeIdentifier: 'osm:node:12345',
senderPubkey: 'b'.repeat(64),
amountSats: 21,
message: 'Great shot!',
createdAt: Math.floor(Date.now() / 1000) - 3600,
});
this.item.senderName = 'Alice';
this.item.senderAvatar = 'https://x.com/avatar.jpg';
this.item.senderProfileLoading = false;
await render(
<template>
<ActivityZapItem @item={{this.item}} @onSelect={{this.noop}} />
</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('.zap-sender-avatar')
.hasAttribute('src', 'https://x.com/avatar.jpg');
assert
.dom('.zap-context-thumb img')
.hasAttribute('src', 'https://x.com/thumb.jpg');
});
test('it shows avatar placeholder when no avatar', async function (assert) {
this.item = new ActivityEntry({
photoEventId: 'photo-1',
photo: null,
placeIdentifier: 'osm:node:12345',
senderPubkey: 'b'.repeat(64),
amountSats: 100,
message: null,
createdAt: 1000,
});
this.item.senderName = 'Bob';
this.item.senderAvatar = null;
this.item.senderProfileLoading = false;
await render(
<template>
<ActivityZapItem @item={{this.item}} @onSelect={{this.noop}} />
</template>
);
assert.dom('.zap-sender-avatar-placeholder').exists();
assert.dom('.zap-sender-avatar').doesNotExist();
});
test('it omits the message line when there is no message', async function (assert) {
this.item = new ActivityEntry({
photoEventId: 'photo-1',
photo: null,
placeIdentifier: 'osm:node:12345',
senderPubkey: 'b'.repeat(64),
amountSats: 50,
message: null,
createdAt: 1000,
});
this.item.senderName = 'Alice';
this.item.senderProfileLoading = false;
await render(
<template>
<ActivityZapItem @item={{this.item}} @onSelect={{this.noop}} />
</template>
);
assert.dom('.zap-message').doesNotExist();
});
test('it omits the context thumbnail when there is no photo', async function (assert) {
this.item = new ActivityEntry({
photoEventId: 'photo-1',
photo: null,
placeIdentifier: 'osm:node:12345',
senderPubkey: 'b'.repeat(64),
amountSats: 50,
message: 'Hi',
createdAt: 1000,
});
this.item.senderName = 'Alice';
this.item.senderProfileLoading = false;
await render(
<template>
<ActivityZapItem @item={{this.item}} @onSelect={{this.noop}} />
</template>
);
assert.dom('.zap-context-thumb').doesNotExist();
assert.dom('.zap-context-text').exists();
});
test('clicking the item fires @onSelect with the item', async function (assert) {
this.item = new ActivityEntry({
photoEventId: 'photo-1',
photo: null,
placeIdentifier: 'osm:node:12345',
senderPubkey: 'b'.repeat(64),
amountSats: 21,
message: 'Test',
createdAt: 1000,
});
this.item.senderName = 'Alice';
this.item.senderProfileLoading = false;
let selected = null;
this.handleSelect = (item) => {
selected = item;
};
await render(
<template>
<ActivityZapItem @item={{this.item}} @onSelect={{this.handleSelect}} />
</template>
);
await click('.activity-zap-item');
assert.strictEqual(selected, this.item);
});
test('it shows resolved place name when placeName is set', async function (assert) {
this.item = new ActivityEntry({
photoEventId: 'photo-1',
photo: null,
placeIdentifier: 'osm:node:12345',
senderPubkey: 'b'.repeat(64),
amountSats: 21,
message: null,
createdAt: 1000,
});
this.item.senderName = 'Alice';
this.item.senderProfileLoading = false;
this.item.placeName = 'Café Central';
this.item.placeNameLoading = false;
await render(
<template>
<ActivityZapItem @item={{this.item}} @onSelect={{this.noop}} />
</template>
);
assert
.dom('.zap-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) {
this.item = new ActivityEntry({
photoEventId: 'photo-1',
photo: null,
placeIdentifier: 'osm:node:12345',
senderPubkey: 'b'.repeat(64),
amountSats: 21,
message: null,
createdAt: 1000,
});
this.item.senderName = 'Alice';
this.item.senderProfileLoading = false;
this.item.placeName = null;
this.item.placeNameLoading = true;
await render(
<template>
<ActivityZapItem @item={{this.item}} @onSelect={{this.noop}} />
</template>
);
assert
.dom('.zap-context-text .contribution-name-loading')
.hasText('Loading…', 'Loading state is displayed');
});
test('it shows "Unnamed place" when not loading and no placeName', async function (assert) {
this.item = new ActivityEntry({
photoEventId: 'photo-1',
photo: null,
placeIdentifier: 'osm:node:12345',
senderPubkey: 'b'.repeat(64),
amountSats: 21,
message: null,
createdAt: 1000,
});
this.item.senderName = 'Alice';
this.item.senderProfileLoading = false;
this.item.placeName = null;
this.item.placeNameLoading = false;
await render(
<template>
<ActivityZapItem @item={{this.item}} @onSelect={{this.noop}} />
</template>
);
assert
.dom('.zap-context-text .contribution-name-loading')
.hasText('Unnamed place', 'Fallback name is displayed');
});
test('it displays place name in zap-place-name and date in zap-date', async function (assert) {
this.item = new ActivityEntry({
photoEventId: 'photo-1',
photo: null,
placeIdentifier: 'osm:node:12345',
senderPubkey: 'b'.repeat(64),
amountSats: 21,
message: null,
createdAt: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago
});
this.item.senderName = 'Alice';
this.item.senderProfileLoading = false;
this.item.placeName = 'Café Central';
this.item.placeNameLoading = false;
await render(
<template>
<ActivityZapItem @item={{this.item}} @onSelect={{this.noop}} />
</template>
);
const placeNameEl = this.element.querySelector('.zap-place-name');
const dateEl = this.element.querySelector('.zap-date');
assert.ok(placeNameEl, 'zap-place-name element exists');
assert.ok(dateEl, 'zap-date element exists');
assert.ok(
placeNameEl.textContent.includes('Café Central'),
'Place name is in zap-place-name'
);
assert.ok(dateEl.textContent.includes('hr ago'), 'Date contains hr ago');
assert.notOk(
placeNameEl.textContent.includes('hr ago'),
'Place name does not contain date'
);
assert.notOk(
dateEl.textContent.includes('Café Central'),
'Date does not contain place name'
);
});
test('it truncates long place names with ellipsis while keeping date visible', async function (assert) {
this.item = new ActivityEntry({
photoEventId: 'photo-1',
photo: null,
placeIdentifier: 'osm:node:12345',
senderPubkey: 'b'.repeat(64),
amountSats: 21,
message: null,
createdAt: Math.floor(Date.now() / 1000) - 3600,
});
this.item.senderName = 'Alice';
this.item.senderProfileLoading = false;
this.item.placeName =
'A Very Long Place Name That Should Be Truncated With Ellipsis';
this.item.placeNameLoading = false;
await render(
<template>
<ActivityZapItem @item={{this.item}} @onSelect={{this.noop}} />
</template>
);
assert.dom('.zap-place-name').exists('zap-place-name element exists');
assert.dom('.zap-date').hasText('1 hr ago', 'Date is still visible');
});
});
+273
View File
@@ -0,0 +1,273 @@
import { module, test } from 'qunit';
import { setupTest } from 'marco/tests/helpers';
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { parseZapReceipt } from 'marco/utils/activity';
import {
setVerifyWrappedEventMethod,
fakeVerifyEvent,
} from 'applesauce-core/helpers/event';
setVerifyWrappedEventMethod(fakeVerifyEvent);
const USER_PUBKEY = 'a'.repeat(64);
const SENDER_PUBKEY = 'b'.repeat(64);
const PHOTO_EVENT_ID_1 = '1'.repeat(64);
const PHOTO_EVENT_ID_2 = '2'.repeat(64);
const PHOTO_EVENT_ID_OTHER = '3'.repeat(64);
const TEXT_EVENT_ID = '4'.repeat(64);
const RECEIPT_ID_1 = '5'.repeat(64);
const RECEIPT_ID_2 = '6'.repeat(64);
const ZAP_REQ_ID = '7'.repeat(64);
const BOLT11_INVOICE =
'lnbc20u1p3y0x3hpp5743k2g0fsqqxj7n8qzuhns5gmkk4djeejk3wkp64ppevgekvc0jsdqcve5kzar2v9nr5gpqd4hkuetesp5ez2g297jduwc20t6lmqlsg3man0vf2jfd8ar9fh8fhn2g8yttfkqxqy9gcqcqzys9qrsgqrzjqtx3k77yrrav9hye7zar2rtqlfkytl094dsp0ms5majzth6gt7ca6uhdkxl983uywgqqqqlgqqqvx5qqjqrzjqd98kxkpyw0l9tyy8r8q57k7zpy9zjmh6sez752wj6gcumqnj3yxzhdsmg6qq56utgqqqqqqqqqqqeqqjq7jd56882gtxhrjm03c93aacyfy306m4fq0tskf83c0nmet8zc2lxyyg3saz8x6vwcp26xnrlagf9semau3qm2glysp7sv95693fphvsp54l567';
function makeZapReceiptEvent(opts = {}) {
const recipient = opts.recipient || USER_PUBKEY;
const sender = opts.sender || SENDER_PUBKEY;
const zappedEventId = opts.zappedEventId || PHOTO_EVENT_ID_1;
const description = JSON.stringify({
kind: 9734,
pubkey: sender,
content: opts.message || '',
id: ZAP_REQ_ID,
created_at: 10000,
sig: 'fake',
tags: [
['p', recipient],
['relays', ['wss://relay.example.com']],
],
});
return {
id: opts.id || RECEIPT_ID_1,
kind: 9735,
pubkey: 'zap-service-pubkey',
created_at: opts.created_at || 10000,
tags: [
['p', recipient],
['P', sender],
['e', zappedEventId],
['bolt11', BOLT11_INVOICE],
['description', description],
],
content: '',
sig: 'sig',
};
}
function makePhotoEvent(opts = {}) {
return {
id: opts.id || PHOTO_EVENT_ID_1,
pubkey: opts.author || USER_PUBKEY,
kind: 360,
created_at: 5000,
tags: [
['i', opts.placeIdentifier || 'osm:node:123'],
['imeta', `url ${opts.url || 'https://x.com/photo.jpg'}`, 'dim 800x600'],
],
content: '',
sig: 'sig',
};
}
class MockNostrDataService extends Service {
@tracked profiles = {};
store = {
events: new Map(),
add(event) {
this.events.set(event.id, event);
},
getEvent(id) {
return this.events.get(id);
},
getReplaceable(kind, pubkey) {
// Find a replaceable event (kind 0 profile) by pubkey
for (const event of this.events.values()) {
if (event.kind === kind && event.pubkey === pubkey) {
return event;
}
}
return undefined;
},
timeline() {
return {
subscribe(callback) {
callback([]);
return { unsubscribe() {} };
},
};
},
model() {
return {
subscribe() {
return { unsubscribe() {} };
},
};
},
};
loadProfiles() {}
getProfile(pubkey) {
return this.profiles[pubkey];
}
async loadIncomingZaps() {}
}
module('Unit | Service | activity', function (hooks) {
setupTest(hooks);
hooks.beforeEach(function () {
this.owner.register('service:nostrData', MockNostrDataService);
});
test('_updateItems 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,
placeIdentifier: 'osm:node:42',
});
service.nostrData.store.add(photoEvent);
const receipt = makeZapReceiptEvent({
zappedEventId: PHOTO_EVENT_ID_1,
message: 'Love it!',
});
service._updateItems([receipt], USER_PUBKEY);
assert.strictEqual(service.items.length, 1);
assert.strictEqual(service.items[0].type, 'zap');
assert.strictEqual(service.items[0].senderPubkey, SENDER_PUBKEY);
assert.strictEqual(service.items[0].amountSats, 2000);
assert.strictEqual(service.items[0].message, 'Love it!');
assert.strictEqual(service.items[0].placeIdentifier, 'osm:node:42');
assert.ok(service.items[0].photo, 'photo is populated');
});
test('_updateItems filters out zaps for non-photo events', function (assert) {
const service = this.owner.lookup('service:activity');
service.nostrData.store.add({
id: TEXT_EVENT_ID,
pubkey: USER_PUBKEY,
kind: 1,
created_at: 5000,
tags: [],
});
const receipt = makeZapReceiptEvent({ zappedEventId: TEXT_EVENT_ID });
service._updateItems([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) {
const service = this.owner.lookup('service:activity');
const otherUserPhoto = makePhotoEvent({
id: PHOTO_EVENT_ID_OTHER,
author: 'z'.repeat(64),
});
service.nostrData.store.add(otherUserPhoto);
const receipt = makeZapReceiptEvent({
zappedEventId: PHOTO_EVENT_ID_OTHER,
});
service._updateItems([receipt], USER_PUBKEY);
assert.strictEqual(
service.items.length,
0,
"zap for someone else's photo filtered out"
);
});
test('_updateItems 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);
const receipt = makeZapReceiptEvent({
recipient: 'c'.repeat(64),
zappedEventId: PHOTO_EVENT_ID_1,
});
service._updateItems([receipt], USER_PUBKEY);
assert.strictEqual(
service.items.length,
0,
'zap directed at someone else filtered out'
);
});
test('_updateItems 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 }));
const oldReceipt = makeZapReceiptEvent({
id: RECEIPT_ID_1,
zappedEventId: PHOTO_EVENT_ID_1,
created_at: 1000,
});
const newReceipt = makeZapReceiptEvent({
id: RECEIPT_ID_2,
zappedEventId: PHOTO_EVENT_ID_2,
created_at: 9000,
});
service._updateItems([oldReceipt, newReceipt], USER_PUBKEY);
assert.strictEqual(service.items.length, 2);
assert.strictEqual(service.items[0].createdAt, 9000, 'newest first');
assert.strictEqual(service.items[1].createdAt, 1000, 'oldest second');
});
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(
[makeZapReceiptEvent({ zappedEventId: PHOTO_EVENT_ID_1 })],
USER_PUBKEY
);
assert.strictEqual(service.items.length, 1);
service.stop();
assert.strictEqual(service.items.length, 0, 'items cleared');
assert.strictEqual(service._userPubkey, null);
});
test('load with no pubkey clears 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);
});
test('_resolveSender applies profile when available', function (assert) {
const service = this.owner.lookup('service:activity');
service.nostrData.profiles[SENDER_PUBKEY] = {
name: 'Alice',
picture: 'https://x.com/avatar.jpg',
};
const entry = parseZapReceipt(makeZapReceiptEvent(), USER_PUBKEY);
service._resolveSender(entry);
assert.strictEqual(entry.senderName, 'Alice');
assert.strictEqual(entry.senderAvatar, 'https://x.com/avatar.jpg');
assert.false(entry.senderProfileLoading);
});
});
+27 -188
View File
@@ -51,12 +51,6 @@ class MockOsmService extends Service {
}
}
const NAME_CACHE_STORE = 'contributions-name-cache';
function flushPromises() {
return new Promise((resolve) => setTimeout(resolve, 50));
}
module('Unit | Service | contributions', function (hooks) {
setupTest(hooks);
@@ -66,212 +60,57 @@ module('Unit | Service | contributions', function (hooks) {
this.owner.register('service:osm', MockOsmService);
});
test('_updateItems resolves names from bookmarks immediately', function (assert) {
test('_updateItems groups events into entries and resolves bookmark names', function (assert) {
const events = [makePhotoEvent({ id: 'e1', created_at: 1000 })];
const service = this.owner.lookup('service:contributions');
service.storage.savedPlaces = [{ id: '123', title: 'Bookmarked Café' }];
service.storage.findPlaceById = (id) =>
const storage = this.owner.lookup('service:storage');
storage.savedPlaces = [{ id: '123', title: 'Bookmarked Café' }];
storage.findPlaceById = (id) =>
id === '123' ? { id: '123', title: 'Bookmarked Café' } : null;
service._updateItems(events);
assert.strictEqual(service.items.length, 1);
assert.false(service.items[0].placeNameLoading, 'Not loading');
assert.strictEqual(
service.items[0].placeName,
'Bookmarked Café',
'Resolved from bookmark'
);
});
test('_updateItems resolves names from the persistent name cache', async function (assert) {
const events = [
makePhotoEvent({
id: 'e1',
created_at: 1000,
placeIdentifier: 'osm:node:999',
}),
];
const service = this.owner.lookup('service:contributions');
await service.localForage.set(
NAME_CACHE_STORE,
'osm:node:999',
'Cached Park'
);
service._updateItems(events);
await flushPromises();
assert.strictEqual(service.items[0].placeName, 'Cached Park');
assert.false(service.items[0].placeNameLoading);
assert.strictEqual(service.items[0].placeName, 'Bookmarked Café');
});
test('resolved names are persisted to the IndexedDB name cache', async function (assert) {
const events = [
makePhotoEvent({
id: 'e1',
created_at: 1000,
placeIdentifier: 'osm:node:42',
}),
];
test('stop clears items and resets resolver state', async function (assert) {
const service = this.owner.lookup('service:contributions');
service.osm.fetchOsmObjectsBatch = async () => {
const map = new Map();
map.set('node:42', { title: 'Test Place' });
return map;
};
const events = [makePhotoEvent({ id: 'e1', created_at: 1000 })];
service._updateItems(events);
await flushPromises();
assert.strictEqual(service.items.length, 1);
service.stop();
assert.strictEqual(service.items.length, 0, 'items cleared');
const resolver = this.owner.lookup('service:place-name-resolver');
assert.strictEqual(
await service.localForage.get(NAME_CACHE_STORE, 'osm:node:42'),
'Test Place',
'Name is persisted to the IndexedDB name cache'
resolver._lastBatchSignature,
'',
'resolver state reset'
);
});
test('names stored in a previous session are resolved from the IndexedDB cache', async function (assert) {
const events = [
makePhotoEvent({
id: 'e1',
created_at: 1000,
placeIdentifier: 'osm:node:77',
}),
];
test('multiple _updateItems calls re-group events correctly', function (assert) {
const service = this.owner.lookup('service:contributions');
await service.localForage.set(
NAME_CACHE_STORE,
'osm:node:77',
'Persisted Place'
);
const events1 = [makePhotoEvent({ id: 'e1', created_at: 1000 })];
const events2 = [makePhotoEvent({ id: 'e2', created_at: 2000 })];
service._updateItems(events);
await flushPromises();
assert.strictEqual(service.items[0].placeName, 'Persisted Place');
assert.false(service.items[0].placeNameLoading);
});
test('batch fetch failure applies a fallback name so items do not stay loading', async function (assert) {
const events = [
makePhotoEvent({
id: 'e1',
created_at: 1000,
placeIdentifier: 'osm:node:555',
}),
];
const service = this.owner.lookup('service:contributions');
// OSM batch returns empty (simulating all-failed fetch)
service.osm.fetchOsmObjectsBatch = async () => new Map();
service._updateItems(events);
// Wait for the background batch to complete
await flushPromises();
assert.false(
service.items[0].placeNameLoading,
'Item is no longer loading after batch failure'
);
service._updateItems(events1);
assert.strictEqual(
service.items[0].placeName,
'OSM node 555',
'Fallback name is applied'
service.items.length,
1,
'first update creates one entry'
);
});
test('fallback names are NOT persisted to the name cache (so they can be re-fetched next session)', async function (assert) {
const events = [
makePhotoEvent({
id: 'e1',
created_at: 1000,
placeIdentifier: 'osm:node:666',
}),
];
const service = this.owner.lookup('service:contributions');
service.osm.fetchOsmObjectsBatch = async () => new Map();
service._updateItems(events);
await flushPromises();
// Fallback should be shown but NOT cached
assert.strictEqual(service.items[0].placeName, 'OSM node 666');
assert.notOk(
await service.localForage.get(NAME_CACHE_STORE, 'osm:node:666'),
'No fallback name persisted in the IndexedDB name cache'
);
assert.true(
service._unresolvable.has('osm:node:666'),
'Item is marked unresolvable for this session'
);
});
test('unresolvable items are not re-fetched within the same session', async function (assert) {
const events = [
makePhotoEvent({
id: 'e1',
created_at: 1000,
placeIdentifier: 'osm:node:777',
}),
];
let fetchCount = 0;
const service = this.owner.lookup('service:contributions');
service.osm.fetchOsmObjectsBatch = async () => {
fetchCount++;
return new Map();
};
// First update triggers a fetch
service._updateItems(events);
await flushPromises();
assert.strictEqual(fetchCount, 1, 'First update triggers a fetch');
assert.strictEqual(service.items[0].placeName, 'OSM node 777');
// Second update should NOT trigger another fetch (already unresolvable)
service._updateItems(events);
await flushPromises();
assert.strictEqual(fetchCount, 1, 'Second update does not re-fetch');
service._updateItems(events2);
assert.strictEqual(
service.items[0].placeName,
'OSM node 777',
'Fallback is shown immediately'
);
});
test('successful batch resolution stores names in the name cache', async function (assert) {
const events = [
makePhotoEvent({
id: 'e1',
created_at: 1000,
placeIdentifier: 'osm:node:111',
}),
];
const service = this.owner.lookup('service:contributions');
service.osm.fetchOsmObjectsBatch = async () => {
const map = new Map();
map.set('node:111', { title: 'Resolved Café' });
return map;
};
service._updateItems(events);
await flushPromises();
assert.strictEqual(service.items[0].placeName, 'Resolved Café');
assert.strictEqual(
await service.localForage.get(NAME_CACHE_STORE, 'osm:node:111'),
'Resolved Café',
'Name is stored in the name cache'
service.items.length,
1,
'second update replaces with new group'
);
});
});
+30 -5
View File
@@ -329,7 +329,7 @@ module('Unit | Service | osm', function (hooks) {
);
});
test('fetchOsmObjectsBatch does not write to the general OSM cache', async function (assert) {
test('fetchOsmObjectsBatch writes complete results to the general OSM cache', async function (assert) {
let service = this.owner.lookup('service:osm');
service.fetchWithRetry = async () => ({
@@ -343,13 +343,38 @@ module('Unit | Service | osm', function (hooks) {
await service.fetchOsmObjectsBatch([{ osmType: 'node', osmId: '100' }]);
assert.false(
assert.true(
service.cachedPlaces.has('node:100'),
'Batch fetch does not write to the in-memory OSM cache'
'Batch fetch writes complete results (with lat/lon) to the in-memory OSM cache'
);
assert.ok(
await service.localForage.get('osm-cache', 'node:100'),
'Batch fetch writes complete results to the persistent OSM cache'
);
});
test('fetchOsmObjectsBatch does NOT cache incomplete results without lat/lon', async function (assert) {
let service = this.owner.lookup('service:osm');
// Way without child nodes in the response -> no lat/lon in normalized result
service.fetchWithRetry = async () => ({
ok: true,
json: async () => ({
elements: [
{ id: 200, type: 'way', nodes: [], tags: { name: 'Way 200' } },
],
}),
});
await service.fetchOsmObjectsBatch([{ osmType: 'way', osmId: '200' }]);
assert.false(
service.cachedPlaces.has('way:200'),
'Batch fetch does NOT cache incomplete results (ways without child nodes)'
);
assert.notOk(
await service.localForage.get('osm-cache', 'node:100'),
'Batch fetch does not write to the persistent OSM cache'
await service.localForage.get('osm-cache', 'way:200'),
'Batch fetch does NOT write incomplete results to persistent cache'
);
});
@@ -0,0 +1,210 @@
import { module, test } from 'qunit';
import { setupTest } from 'marco/tests/helpers';
import Service from '@ember/service';
function makeEntry(opts = {}) {
const placeIdentifier = opts.placeIdentifier || 'osm:node:123';
const [, osmType, osmId] = placeIdentifier.split(':');
return {
placeIdentifier,
osmType: opts.osmType || osmType,
osmId: opts.osmId || osmId,
placeName: opts.placeName || null,
placeNameLoading: opts.placeNameLoading ?? true,
};
}
class MockStorageService extends Service {
savedPlaces = [];
findPlaceById() {
return null;
}
}
class MockOsmService extends Service {
async getCachedOsmObject() {
return null;
}
async fetchOsmObjectsBatch() {
return new Map();
}
}
function flushPromises() {
return new Promise((resolve) => setTimeout(resolve, 50));
}
module('Unit | Service | place-name-resolver', function (hooks) {
setupTest(hooks);
hooks.beforeEach(function () {
this.owner.register('service:storage', MockStorageService);
this.owner.register('service:osm', MockOsmService);
});
test('resolveBookmark returns bookmark title when found', function (assert) {
const resolver = this.owner.lookup('service:place-name-resolver');
const storage = this.owner.lookup('service:storage');
storage.savedPlaces = [{ id: '123', title: 'Bookmarked Café' }];
storage.findPlaceById = (id) =>
id === '123' ? { id: '123', title: 'Bookmarked Café' } : null;
const name = resolver.resolveBookmark('123');
assert.strictEqual(name, 'Bookmarked Café');
});
test('resolveBookmark returns null when no bookmark', function (assert) {
const resolver = this.owner.lookup('service:place-name-resolver');
const name = resolver.resolveBookmark('999');
assert.notOk(name);
});
test('resolveInBackground resolves names from the persistent name cache', async function (assert) {
const resolver = this.owner.lookup('service:place-name-resolver');
const localForage = this.owner.lookup('service:localForage');
const entry = makeEntry({ placeIdentifier: 'osm:node:999' });
await localForage.set('place-name-cache', 'osm:node:999', 'Cached Park');
await resolver.resolveInBackground([entry]);
assert.strictEqual(entry.placeName, 'Cached Park');
assert.false(entry.placeNameLoading);
});
test('resolved names are persisted to the IndexedDB name cache', async function (assert) {
const resolver = this.owner.lookup('service:place-name-resolver');
const localForage = this.owner.lookup('service:localForage');
const entry = makeEntry({ placeIdentifier: 'osm:node:42' });
resolver.osm.fetchOsmObjectsBatch = async () => {
const map = new Map();
map.set('node:42', { title: 'Test Place' });
return map;
};
await resolver.resolveInBackground([entry]);
await flushPromises();
assert.strictEqual(
await localForage.get('place-name-cache', 'osm:node:42'),
'Test Place',
'Name is persisted to the IndexedDB name cache'
);
});
test('names stored in a previous session are resolved from the IndexedDB cache', async function (assert) {
const resolver = this.owner.lookup('service:place-name-resolver');
const localForage = this.owner.lookup('service:localForage');
const entry = makeEntry({ placeIdentifier: 'osm:node:77' });
await localForage.set('place-name-cache', 'osm:node:77', 'Persisted Place');
await resolver.resolveInBackground([entry]);
assert.strictEqual(entry.placeName, 'Persisted Place');
assert.false(entry.placeNameLoading);
});
test('batch fetch failure applies a fallback name so items do not stay loading', async function (assert) {
const resolver = this.owner.lookup('service:place-name-resolver');
const entry = makeEntry({ placeIdentifier: 'osm:node:555' });
resolver.osm.fetchOsmObjectsBatch = async () => new Map();
await resolver.resolveInBackground([entry]);
await flushPromises();
assert.false(
entry.placeNameLoading,
'Item is no longer loading after batch failure'
);
assert.strictEqual(
entry.placeName,
'OSM node 555',
'Fallback name is applied'
);
});
test('fallback names are NOT persisted to the name cache (so they can be re-fetched next session)', async function (assert) {
const resolver = this.owner.lookup('service:place-name-resolver');
const localForage = this.owner.lookup('service:localForage');
const entry = makeEntry({ placeIdentifier: 'osm:node:666' });
resolver.osm.fetchOsmObjectsBatch = async () => new Map();
await resolver.resolveInBackground([entry]);
await flushPromises();
assert.strictEqual(entry.placeName, 'OSM node 666');
assert.notOk(
await localForage.get('place-name-cache', 'osm:node:666'),
'No fallback name persisted in the IndexedDB name cache'
);
assert.true(
resolver._unresolvable.has('osm:node:666'),
'Item is marked unresolvable for this session'
);
});
test('unresolvable items are not re-fetched within the same session', async function (assert) {
const resolver = this.owner.lookup('service:place-name-resolver');
const entry = makeEntry({ placeIdentifier: 'osm:node:777' });
let fetchCount = 0;
resolver.osm.fetchOsmObjectsBatch = async () => {
fetchCount++;
return new Map();
};
// First call triggers a fetch
await resolver.resolveInBackground([entry]);
await flushPromises();
assert.strictEqual(fetchCount, 1, 'First call triggers a fetch');
assert.strictEqual(entry.placeName, 'OSM node 777');
// Second call should NOT trigger another fetch (already unresolvable)
await resolver.resolveInBackground([entry]);
await flushPromises();
assert.strictEqual(fetchCount, 1, 'Second call does not re-fetch');
assert.strictEqual(
entry.placeName,
'OSM node 777',
'Fallback is shown immediately'
);
});
test('successful batch resolution stores names in the name cache', async function (assert) {
const resolver = this.owner.lookup('service:place-name-resolver');
const localForage = this.owner.lookup('service:localForage');
const entry = makeEntry({ placeIdentifier: 'osm:node:111' });
resolver.osm.fetchOsmObjectsBatch = async () => {
const map = new Map();
map.set('node:111', { title: 'Resolved Café' });
return map;
};
await resolver.resolveInBackground([entry]);
await flushPromises();
assert.strictEqual(entry.placeName, 'Resolved Café');
assert.strictEqual(
await localForage.get('place-name-cache', 'osm:node:111'),
'Resolved Café',
'Name is stored in the name cache'
);
});
test('reset clears per-session state', function (assert) {
const resolver = this.owner.lookup('service:place-name-resolver');
resolver._unresolvable.add('test-id');
resolver._lastBatchSignature = 'sig';
resolver._pendingBatchPromise = Promise.resolve();
resolver.reset();
assert.strictEqual(resolver._unresolvable.size, 0);
assert.strictEqual(resolver._lastBatchSignature, '');
assert.strictEqual(resolver._pendingBatchPromise, null);
});
});
+211
View File
@@ -0,0 +1,211 @@
import { module, test } from 'qunit';
import {
ActivityEntry,
parseZapReceipt,
enrichWithPhoto,
} from 'marco/utils/activity';
import {
setVerifyWrappedEventMethod,
fakeVerifyEvent,
} from 'applesauce-core/helpers/event';
setVerifyWrappedEventMethod(fakeVerifyEvent);
const USER_PUBKEY = 'a'.repeat(64);
const SENDER_PUBKEY = 'b'.repeat(64);
const PHOTO_EVENT_ID = '1'.repeat(64);
const RECEIPT_ID = '2'.repeat(64);
const ZAP_REQ_ID_1 = '3'.repeat(64);
const ZAP_REQ_ID_2 = '4'.repeat(64);
const PHOTO_XYZ_ID = '5'.repeat(64);
const OTHER_EVENT_ID = '6'.repeat(64);
const BOLT11_INVOICE =
'lnbc20u1p3y0x3hpp5743k2g0fsqqxj7n8qzuhns5gmkk4djeejk3wkp64ppevgekvc0jsdqcve5kzar2v9nr5gpqd4hkuetesp5ez2g297jduwc20t6lmqlsg3man0vf2jfd8ar9fh8fhn2g8yttfkqxqy9gcqcqzys9qrsgqrzjqtx3k77yrrav9hye7zar2rtqlfkytl094dsp0ms5majzth6gt7ca6uhdkxl983uywgqqqqlgqqqvx5qqjqrzjqd98kxkpyw0l9tyy8r8q57k7zpy9zjmh6sez752wj6gcumqnj3yxzhdsmg6qq56utgqqqqqqqqqqqeqqjq7jd56882gtxhrjm03c93aacyfy306m4fq0tskf83c0nmet8zc2lxyyg3saz8x6vwcp26xnrlagf9semau3qm2glysp7sv95693fphvsp54l567';
function makeZapReceiptEvent(opts = {}) {
const recipient = opts.recipient || USER_PUBKEY;
const sender = opts.sender || SENDER_PUBKEY;
const zappedEventId = opts.zappedEventId || PHOTO_EVENT_ID;
const description = JSON.stringify({
kind: 9734,
pubkey: sender,
content: opts.message || '',
id: ZAP_REQ_ID_1,
created_at: 10000,
sig: 'fake',
tags: [
['p', recipient],
['relays', ['wss://relay.example.com']],
],
});
return {
id: opts.id || RECEIPT_ID,
kind: 9735,
pubkey: opts.receiptAuthor || 'zap-service-pubkey',
created_at: opts.created_at || 10000,
tags: [
['p', recipient],
['P', sender],
['e', zappedEventId],
['bolt11', BOLT11_INVOICE],
['description', description],
],
content: '',
sig: 'sig',
};
}
function makePhotoEvent(opts = {}) {
return {
id: opts.id || PHOTO_EVENT_ID,
pubkey: opts.author || USER_PUBKEY,
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', function () {
test('ActivityEntry has type "zap" and tracked sender fields', function (assert) {
const entry = new ActivityEntry({
photoEventId: '1'.repeat(64),
photo: null,
placeIdentifier: 'osm:node:1',
senderPubkey: SENDER_PUBKEY,
amountSats: 21,
message: 'Nice!',
createdAt: 10000,
});
assert.strictEqual(entry.type, 'zap');
assert.strictEqual(entry.senderPubkey, SENDER_PUBKEY);
assert.strictEqual(entry.amountSats, 21);
assert.strictEqual(entry.message, 'Nice!');
assert.true(entry.senderProfileLoading, 'senderProfileLoading starts true');
});
test('parseZapReceipt extracts sender, amount, and zapped event id', function (assert) {
const receipt = makeZapReceiptEvent({ message: 'Great photo!' });
const entry = parseZapReceipt(receipt, USER_PUBKEY);
assert.ok(entry, 'returns an entry');
assert.strictEqual(entry.senderPubkey, SENDER_PUBKEY);
assert.strictEqual(entry.photoEventId, PHOTO_EVENT_ID);
assert.strictEqual(entry.amountSats, 2000, '2000000 msat → 2000 sats');
assert.strictEqual(entry.message, 'Great photo!');
assert.strictEqual(entry.createdAt, 10000);
});
test('parseZapReceipt returns null when recipient is not the user', function (assert) {
const receipt = makeZapReceiptEvent({
recipient: 'c'.repeat(64),
});
const entry = parseZapReceipt(receipt, USER_PUBKEY);
assert.notOk(entry, 'not directed at the user');
});
test('parseZapReceipt returns null for wrong kind', function (assert) {
const receipt = makeZapReceiptEvent();
receipt.kind = 1;
const entry = parseZapReceipt(receipt, USER_PUBKEY);
assert.notOk(entry);
});
test('parseZapReceipt returns null when no event pointer', function (assert) {
const receipt = makeZapReceiptEvent();
receipt.tags = receipt.tags.filter((t) => t[0] !== 'e');
const entry = parseZapReceipt(receipt, USER_PUBKEY);
assert.notOk(entry);
});
test('parseZapReceipt handles null message', function (assert) {
const description = JSON.stringify({
kind: 9734,
pubkey: SENDER_PUBKEY,
content: '',
id: ZAP_REQ_ID_2,
created_at: 10000,
sig: 'fake',
tags: [
['p', USER_PUBKEY],
['relays', ['wss://relay.example.com']],
],
});
const receipt = makeZapReceiptEvent({ description });
const entry = parseZapReceipt(receipt, USER_PUBKEY);
assert.ok(entry);
assert.strictEqual(entry.message, null, 'empty content → null message');
});
test('enrichWithPhoto populates photo and placeIdentifier from the store', function (assert) {
const photoEvent = makePhotoEvent({
id: PHOTO_XYZ_ID,
placeIdentifier: 'osm:way:456',
});
const mockStore = {
getEvent: (id) => (id === PHOTO_XYZ_ID ? photoEvent : undefined),
};
const entry = parseZapReceipt(
makeZapReceiptEvent({ zappedEventId: PHOTO_XYZ_ID }),
USER_PUBKEY
);
const result = enrichWithPhoto(entry, mockStore, USER_PUBKEY);
assert.true(result, 'enrichment succeeded');
assert.ok(entry.photo, 'photo is populated');
assert.strictEqual(
entry.placeIdentifier,
'osm:way:456',
'placeIdentifier extracted from photo'
);
assert.strictEqual(entry.photo.url, 'https://x.com/photo.jpg');
});
test('enrichWithPhoto returns false when zapped event is not a kind 360', function (assert) {
const nonPhotoEvent = { id: OTHER_EVENT_ID, pubkey: USER_PUBKEY, kind: 1 };
const mockStore = { getEvent: () => nonPhotoEvent };
const entry = parseZapReceipt(
makeZapReceiptEvent({ zappedEventId: OTHER_EVENT_ID }),
USER_PUBKEY
);
const result = enrichWithPhoto(entry, mockStore, USER_PUBKEY);
assert.false(result, 'not a kind 360');
assert.notOk(entry.photo);
});
test('enrichWithPhoto returns false when zapped event is not authored by the user', function (assert) {
const otherUserPhoto = makePhotoEvent({ author: 'z'.repeat(64) });
const mockStore = { getEvent: () => otherUserPhoto };
const entry = parseZapReceipt(
makeZapReceiptEvent({ zappedEventId: PHOTO_EVENT_ID }),
USER_PUBKEY
);
const result = enrichWithPhoto(entry, mockStore, USER_PUBKEY);
assert.false(result, 'not authored by the user');
});
test('enrichWithPhoto returns false when zapped event not in store', function (assert) {
const mockStore = { getEvent: () => undefined };
const entry = parseZapReceipt(makeZapReceiptEvent(), USER_PUBKEY);
const result = enrichWithPhoto(entry, mockStore, USER_PUBKEY);
assert.false(result, 'event not in store');
});
});
+25 -3
View File
@@ -55,12 +55,34 @@ module('Unit | Utility | format-text', function () {
test('formatRelativeDate returns an absolute date for timestamps older than a week', function (assert) {
const now = Math.floor(Date.now() / 1000);
const oldTimestamp = now - 86400 * 30; // 30 days ago
const oldTimestamp = now - 86400 * 400; // 400 days ago (previous year)
const result = formatRelativeDate(oldTimestamp);
// The exact format depends on the locale, but it should contain a year
assert.ok(
result.includes(String(new Date().getFullYear())),
'Contains the year'
result.includes(String(new Date().getFullYear() - 1)),
'Contains the previous year'
);
});
test('formatRelativeDate omits year for same-year dates older than a week', function (assert) {
const now = Math.floor(Date.now() / 1000);
const sameYearTimestamp = now - 86400 * 30; // 30 days ago (same year)
const result = formatRelativeDate(sameYearTimestamp);
// Should NOT contain the current year
assert.notOk(
result.includes(String(new Date().getFullYear())),
'Does not contain the current year'
);
// Should contain month and day
const expectedMonth = new Date(sameYearTimestamp * 1000).toLocaleDateString(
undefined,
{ month: 'short' }
);
const expectedDay = new Date(sameYearTimestamp * 1000).toLocaleDateString(
undefined,
{ day: 'numeric' }
);
assert.ok(result.includes(expectedMonth), 'Contains month');
assert.ok(result.includes(expectedDay), 'Contains day');
});
});