Add Activity timeline, show zaps received #91
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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' }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user