Compare commits

..
Author SHA1 Message Date
raucao 3f6bd5267f WIP Add activity timeline route/sidebar showing incoming zaps 2026-08-29 12:01:48 -06:00
20 changed files with 1752 additions and 2 deletions
+85
View File
@@ -0,0 +1,85 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
import Icon from './icon';
import ActivityZapItem from './activity-zap-item';
import Modal from './modal';
import NostrConnect from './nostr-connect';
import not from 'ember-truth-helpers/helpers/not';
import eq from 'ember-truth-helpers/helpers/eq';
import restoreScroll from '../modifiers/restore-scroll';
export default class ActivityTimelineComponent extends Component {
@tracked isNostrConnectModalOpen = false;
@action
openNostrConnectModal(event) {
event.preventDefault();
this.isNostrConnectModalOpen = true;
}
@action
closeNostrConnectModal() {
this.isNostrConnectModalOpen = false;
}
@action
onNostrConnected() {
this.closeNostrConnectModal();
this.args.onNostrConnected?.();
}
<template>
<div class="sidebar">
<div class="sidebar-header has-back-btn">
<button type="button" class="back-btn" {{on "click" @onBack}}>
<Icon @name="arrow-left" @size={{20}} @color="#333" />
</button>
<h2 class="sidebar-header-text-centered">
<span class="sidebar-header-icon-wrapper">
<Icon @name="activity" @size={{20}} @color="#898989" />
</span>
Activity
</h2>
<button type="button" class="close-btn" {{on "click" @onClose}}>
<Icon @name="x" @size={{20}} @color="#333" />
</button>
</div>
<div class="sidebar-content" {{restoreScroll @scrollTop}}>
{{#if @isLoading}}
<div class="sidebar-loading">
<Icon @name="loading-ring" @size={{24}} @color="#898989" />
</div>
{{else if (not @isConnected)}}
<p class="empty-state">
<a
href="#"
role="button"
tabindex="0"
{{on "click" this.openNostrConnectModal}}
>Connect your Nostr account</a>
to see your activity.
</p>
{{else if (not @items.length)}}
<p class="empty-state">No activity yet.</p>
{{else}}
<ul class="activity-list">
{{#each @items as |item|}}
{{#if (eq item.type "zap")}}
<ActivityZapItem @item={{item}} @onSelect={{@onSelect}} />
{{/if}}
{{/each}}
</ul>
{{/if}}
</div>
</div>
{{#if this.isNostrConnectModalOpen}}
<Modal @onClose={{this.closeNostrConnectModal}}>
<NostrConnect @onConnect={{this.onNostrConnected}} />
</Modal>
{{/if}}
</template>
}
+96
View File
@@ -0,0 +1,96 @@
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 placeIdentifier() {
return this.item?.placeIdentifier;
}
<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>
<span class="zap-context-text">
{{#if this.placeIdentifier}}
{{this.placeIdentifier}}
{{/if}}
·
{{formatRelativeDate @item.createdAt}}
</span>
</div>
{{#if this.item.message}}
<div class="zap-message">{{this.item.message}}</div>
{{/if}}
</button>
</li>
</template>
}
+7 -1
View File
@@ -26,8 +26,14 @@ import iconRounded from '../../icons/icon-rounded.svg?raw';
</button>
</li>
<li>
<button type="button" {{on "click" @onContributions}}>
<button type="button" {{on "click" @onActivity}}>
<Icon @name="activity" @size={{20}} />
<span>Activity</span>
</button>
</li>
<li>
<button type="button" {{on "click" @onContributions}}>
<Icon @name="user-check" @size={{20}} />
<span>My Contributions</span>
</button>
</li>
+6
View File
@@ -28,6 +28,11 @@ export default class AppMenu extends Component {
this.router.transitionTo('contributions');
}
@action
goToActivity() {
this.router.transitionTo('activity');
}
<template>
<div class="sidebar app-menu-pane">
{{#if (eq this.currentView "menu")}}
@@ -36,6 +41,7 @@ export default class AppMenu extends Component {
@onClose={{@onClose}}
@onSavedPlaces={{this.goToSavedPlaces}}
@onContributions={{this.goToContributions}}
@onActivity={{this.goToActivity}}
/>
{{else if (eq this.currentView "settings")}}
+59
View File
@@ -0,0 +1,59 @@
import Controller from '@ember/controller';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { task } from 'ember-concurrency';
export default class ActivityController extends Controller {
@service router;
@service mapUi;
@service nostrAuth;
@service activity;
loadTask = task({ restartable: true }, async (pubkey) => {
if (!pubkey) return;
await this.activity.load(pubkey);
});
get scrollTop() {
return this.mapUi.getScrollPosition('activity');
}
get items() {
return this.activity.items;
}
get isConnected() {
return this.nostrAuth.isConnected;
}
@action
selectItem(item) {
if (!item || !item.placeIdentifier) return;
const sidebarContent = document.querySelector('.sidebar-content');
if (sidebarContent) {
this.mapUi.saveScrollPosition('activity', sidebarContent.scrollTop);
}
this.mapUi.returnToRoute = { name: 'activity' };
this.mapUi.showSidebar();
this.mapUi.preventNextZoom = true;
this.router.transitionTo(`/place/${item.placeIdentifier}`);
}
@action
onNostrConnected() {
this.loadTask.perform(this.nostrAuth.pubkey);
}
@action
backToMenu() {
this.router.transitionTo('menu');
}
@action
close() {
this.router.transitionTo('index');
}
}
+1
View File
@@ -15,6 +15,7 @@ Router.map(function () {
this.route('list', { path: '/:list_id' });
});
this.route('contributions');
this.route('activity');
this.route('oauth', function () {
this.route('osm-callback', { path: '/osm/callback' });
});
+23
View File
@@ -0,0 +1,23 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class ActivityRoute extends Route {
@service mapUi;
@service nostrAuth;
@service activity;
activate() {
this.mapUi.showSidebar();
}
setupController(controller, model) {
super.setupController(controller, model);
if (controller && controller.loadTask) {
controller.loadTask.perform(this.nostrAuth.pubkey);
}
}
deactivate() {
this.activity.stop();
}
}
+183
View File
@@ -0,0 +1,183 @@
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. Update `@tracked items` so the UI renders progressively.
*/
export default class ActivityService extends Service {
@service nostrData;
@service nostrAuth;
@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;
}
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,
});
this.items = entries;
}
_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 nostr-data) 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();
}
}
+53
View File
@@ -95,6 +95,7 @@ export default class NostrDataService extends Service {
_zapReceiptsNetworkSub = null;
_zapRefreshTimer = null;
_lastPhotoIds = new Set();
_incomingZapsNetworkSub = null;
_requestSub = null;
_cachePromise = null;
@@ -551,6 +552,54 @@ export default class NostrDataService extends Service {
);
}
/**
* Hydrates the store with incoming zap receipts (kind 9735) where the user is
* the recipient (`#p` filter). Loads from the IDB cache first (instant), then
* fires a network request. Called by the `activity` service.
*
* @param {string} pubkey The user's Nostr pubkey
*/
async loadIncomingZaps(pubkey) {
if (!pubkey) return;
const filters = [{ kinds: [9735], '#p': [pubkey] }];
console.debug('[nostr-data] Requesting incoming zap receipts from relays', {
filters,
relays: this.activeReadRelays,
});
if (this._incomingZapsNetworkSub) {
this._incomingZapsNetworkSub.unsubscribe();
this._incomingZapsNetworkSub = null;
}
// 1. Populate the store from the local Nostr IDB cache (instant)
try {
await this._cachePromise;
const cachedEvents = await this.cache.query(filters);
if (cachedEvents && cachedEvents.length > 0) {
for (const event of cachedEvents) {
this.store.add(event);
}
}
} catch (e) {
console.warn(
'[nostr-data] Failed to read incoming zap receipts from local Nostr IDB cache',
e
);
}
// 2. Request fresh events from the network in the background
this._incomingZapsNetworkSub = this._requestContentWithProvenance(
this.activeReadRelays,
filters,
'[nostr-data] Error fetching incoming zap receipts:'
);
}
loadProfiles(pubkeys) {
const newPubkeys = pubkeys.filter(
(pk) => pk && !this._profileModelSubs.has(pk)
@@ -843,6 +892,10 @@ export default class NostrDataService extends Service {
}
this._cleanupZapReceiptSubs();
this._clearZapRefreshTimer();
if (this._incomingZapsNetworkSub) {
this._incomingZapsNetworkSub.unsubscribe();
this._incomingZapsNetworkSub = null;
}
}
willDestroy() {
+139
View File
@@ -2660,3 +2660,142 @@ 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 {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
+16
View File
@@ -0,0 +1,16 @@
import ActivityTimeline from '#components/activity-timeline';
<template>
{{#if @controller.mapUi.isSidebarVisible}}
<ActivityTimeline
@items={{@controller.items}}
@isLoading={{@controller.loadTask.isRunning}}
@isConnected={{@controller.isConnected}}
@scrollTop={{@controller.scrollTop}}
@onSelect={{@controller.selectItem}}
@onBack={{@controller.backToMenu}}
@onClose={{@controller.close}}
@onNostrConnected={{@controller.onNostrConnected}}
/>
{{/if}}
</template>
+3
View File
@@ -23,6 +23,7 @@ export default class ApplicationComponent extends Component {
name === 'search' ||
name === 'menu' ||
name === 'contributions' ||
name === 'activity' ||
name.startsWith('lists'))
);
}
@@ -51,6 +52,7 @@ export default class ApplicationComponent extends Component {
name === 'place' ||
name === 'menu' ||
name === 'contributions' ||
name === 'activity' ||
name.startsWith('lists')
) {
this.mapUi.clearSelection();
@@ -58,6 +60,7 @@ export default class ApplicationComponent extends Component {
if (
name === 'menu' ||
name === 'contributions' ||
name === 'activity' ||
name.startsWith('lists')
) {
this.router.transitionTo('index');
+129
View File
@@ -0,0 +1,129 @@
/**
* 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`, and `senderProfileLoading` are tracked so the
* row re-renders when the sender's profile is resolved asynchronously.
*/
export class ActivityEntry {
type = 'zap';
photoEventId;
photo;
placeIdentifier;
senderPubkey;
amountSats;
message;
createdAt;
@tracked senderName = null;
@tracked senderAvatar = null;
@tracked senderProfileLoading = 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;
return true;
}
+1 -1
View File
@@ -54,7 +54,7 @@ export class ContributionEntry {
* @param {object} event A NIP-360 (kind 360) Nostr event
* @returns {object|null} A photo object, or null if no usable `imeta` was found
*/
function parsePhotoFromEvent(event) {
export function parsePhotoFromEvent(event) {
const tags = event.tags || [];
const eventTags = tags
+2
View File
@@ -39,6 +39,7 @@ import target from 'feather-icons/dist/icons/target.svg?raw';
import trash2 from 'feather-icons/dist/icons/trash-2.svg?raw';
import uploadCloud from 'feather-icons/dist/icons/upload-cloud.svg?raw';
import user from 'feather-icons/dist/icons/user.svg?raw';
import userCheck from 'feather-icons/dist/icons/user-check.svg?raw';
import x from 'feather-icons/dist/icons/x.svg?raw';
import check from 'feather-icons/dist/icons/check.svg?raw';
import alertCircle from 'feather-icons/dist/icons/alert-circle.svg?raw';
@@ -281,6 +282,7 @@ const ICONS = {
'upload-cloud': uploadCloud,
'tree-and-bench-with-backrest': treeAndBenchWithBackrest,
user,
'user-check': userCheck,
'village-buildings': villageBuildings,
'wall-hanging-with-mountains-and-sun': wallHangingWithMountainsAndSun,
'womens-and-mens-restroom-symbol': womensAndMensRestroomSymbol,
+141
View File
@@ -0,0 +1,141 @@
import { module, test } from 'qunit';
import { visit, currentURL, click, waitFor } from '@ember/test-helpers';
import { setupApplicationTest } from 'marco/tests/helpers';
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
class MockActivityService extends Service {
@tracked items = [
{
type: 'zap',
photoEventId: 'photo-1',
photo: {
url: 'https://x.com/photo.jpg',
thumbUrl: 'https://x.com/thumb.jpg',
},
placeIdentifier: 'osm:node:123',
senderPubkey: 'b'.repeat(64),
amountSats: 21,
message: 'Great photo!',
createdAt: Math.floor(Date.now() / 1000) - 3600,
senderName: 'Alice',
senderAvatar: 'https://x.com/avatar.jpg',
senderProfileLoading: false,
},
];
async load() {}
stop() {
this.items = [];
}
}
class MockNostrAuthService extends Service {
@tracked pubkey = 'test-pubkey';
get isConnected() {
return true;
}
}
class MockStorageService extends Service {
initialSyncDone = true;
savedPlaces = [];
findPlaceById() {
return null;
}
isPlaceSaved() {
return false;
}
loadPlacesInBounds() {
return [];
}
get placesInView() {
return [];
}
rs = {
on: () => {},
};
}
class MockOsmService extends Service {
async fetchOsmObject() {
return {
osmId: '123',
osmType: 'node',
lat: 1,
lon: 1,
osmTags: { name: 'Test Place', amenity: 'cafe' },
title: 'Test Place',
};
}
getCachedOsmObject() {
return Promise.resolve(null);
}
}
module('Acceptance | activity', function (hooks) {
setupApplicationTest(hooks);
hooks.beforeEach(function () {
this.owner.register('service:activity', MockActivityService);
this.owner.register('service:nostrAuth', MockNostrAuthService);
this.owner.register('service:storage', MockStorageService);
this.owner.register('service:osm', MockOsmService);
});
test('visiting /activity renders the sidebar with activity items', async function (assert) {
await visit('/activity');
assert.strictEqual(currentURL(), '/activity');
assert.dom('.sidebar').exists('Sidebar is rendered');
assert.dom('.sidebar-header-text-centered').includesText('Activity');
});
test('activity items are rendered as zap rows', async function (assert) {
await visit('/activity');
await waitFor('.activity-zap-item');
assert.dom('.activity-zap-item').exists({ count: 1 });
assert.dom('.zap-sender-name').hasText('Alice');
assert.dom('.zap-action').includesText('zapped your photo');
assert.dom('.zap-amount').includesText('21 ⚡');
assert.dom('.zap-message').includesText('Great photo!');
});
test('closing the sidebar returns to index', async function (assert) {
await visit('/activity');
assert.strictEqual(currentURL(), '/activity');
await click('.sidebar-header .close-btn');
assert.strictEqual(currentURL(), '/', 'Returns to index');
});
test('clicking back button returns to menu', async function (assert) {
await visit('/activity');
await click('.sidebar-header .back-btn');
assert.strictEqual(currentURL(), '/menu', 'Returns to menu');
});
test('clicking a zap item navigates to the place', async function (assert) {
const mapUi = this.owner.lookup('service:map-ui');
await visit('/activity');
await waitFor('.activity-zap-item');
await click('.activity-zap-item');
assert.ok(
currentURL().includes('/place/osm:node:123'),
'Transitions to place details'
);
assert.deepEqual(
mapUi.returnToRoute,
{ name: 'activity' },
'returnToRoute is set to activity'
);
});
});
@@ -0,0 +1,176 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'marco/tests/helpers';
import { render, click } from '@ember/test-helpers';
import ActivityTimeline from 'marco/components/activity-timeline';
import { setupNostrMocks } from 'marco/tests/helpers/mock-nostr';
function noop() {}
module('Integration | Component | activity-timeline', function (hooks) {
setupRenderingTest(hooks);
setupNostrMocks(hooks);
hooks.beforeEach(function () {
this.noop = noop;
this.emptyItems = [];
});
test('it renders a loading state', async function (assert) {
await render(
<template>
<ActivityTimeline
@items={{this.emptyItems}}
@isLoading={{true}}
@isConnected={{true}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@onNostrConnected={{this.noop}}
/>
</template>
);
assert.dom('.sidebar-loading').exists();
assert.dom('.activity-list').doesNotExist();
});
test('it renders a not-connected state', async function (assert) {
await render(
<template>
<ActivityTimeline
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{false}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@onNostrConnected={{this.noop}}
/>
</template>
);
assert.dom('.empty-state').includesText('Connect your Nostr account');
});
test('clicking "Connect your Nostr account" opens the Nostr connect modal', async function (assert) {
await render(
<template>
<div id="modal-portal"></div>
<ActivityTimeline
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{false}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@onNostrConnected={{this.noop}}
/>
</template>
);
assert.dom('.nostr-connect-modal').doesNotExist();
await click('.empty-state a');
assert.dom('.nostr-connect-modal').exists();
});
test('it renders an empty state when connected but no activity', async function (assert) {
await render(
<template>
<ActivityTimeline
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{true}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@onNostrConnected={{this.noop}}
/>
</template>
);
assert.dom('.empty-state').includesText('No activity yet');
});
test('it renders activity zap items', async function (assert) {
this.items = [
{
type: 'zap',
photoEventId: 'photo-1',
photo: {
url: 'https://x.com/photo.jpg',
thumbUrl: 'https://x.com/thumb.jpg',
},
placeIdentifier: 'osm:node:111',
senderPubkey: 'b'.repeat(64),
amountSats: 21,
message: 'Nice!',
createdAt: 2000,
senderName: 'Alice',
senderAvatar: 'https://x.com/avatar.jpg',
senderProfileLoading: false,
},
{
type: 'zap',
photoEventId: 'photo-2',
photo: {
url: 'https://x.com/photo2.jpg',
thumbUrl: 'https://x.com/thumb2.jpg',
},
placeIdentifier: 'osm:node:222',
senderPubkey: 'c'.repeat(64),
amountSats: 100,
message: null,
createdAt: 1000,
senderName: 'Bob',
senderAvatar: null,
senderProfileLoading: false,
},
];
await render(
<template>
<ActivityTimeline
@items={{this.items}}
@isLoading={{false}}
@isConnected={{true}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@onNostrConnected={{this.noop}}
/>
</template>
);
assert.dom('.activity-zap-item').exists({ count: 2 });
assert.dom(this.element).includesText('Alice');
assert.dom(this.element).includesText('21 ⚡');
assert.dom(this.element).includesText('Bob');
assert.dom(this.element).includesText('100 ⚡');
});
test('clicking the back button fires @onBack', async function (assert) {
let backClicked = false;
this.handleBack = () => {
backClicked = true;
};
await render(
<template>
<ActivityTimeline
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{true}}
@onSelect={{this.noop}}
@onBack={{this.handleBack}}
@onClose={{this.noop}}
@onNostrConnected={{this.noop}}
/>
</template>
);
await click('.sidebar-header .back-btn');
assert.true(backClicked);
});
});
@@ -0,0 +1,148 @@
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);
});
});
+273
View File
@@ -0,0 +1,273 @@
import { module, test } from 'qunit';
import { setupTest } from 'marco/tests/helpers';
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { parseZapReceipt } from 'marco/utils/activity';
import {
setVerifyWrappedEventMethod,
fakeVerifyEvent,
} from 'applesauce-core/helpers/event';
setVerifyWrappedEventMethod(fakeVerifyEvent);
const USER_PUBKEY = 'a'.repeat(64);
const SENDER_PUBKEY = 'b'.repeat(64);
const PHOTO_EVENT_ID_1 = '1'.repeat(64);
const PHOTO_EVENT_ID_2 = '2'.repeat(64);
const PHOTO_EVENT_ID_OTHER = '3'.repeat(64);
const TEXT_EVENT_ID = '4'.repeat(64);
const RECEIPT_ID_1 = '5'.repeat(64);
const RECEIPT_ID_2 = '6'.repeat(64);
const ZAP_REQ_ID = '7'.repeat(64);
const BOLT11_INVOICE =
'lnbc20u1p3y0x3hpp5743k2g0fsqqxj7n8qzuhns5gmkk4djeejk3wkp64ppevgekvc0jsdqcve5kzar2v9nr5gpqd4hkuetesp5ez2g297jduwc20t6lmqlsg3man0vf2jfd8ar9fh8fhn2g8yttfkqxqy9gcqcqzys9qrsgqrzjqtx3k77yrrav9hye7zar2rtqlfkytl094dsp0ms5majzth6gt7ca6uhdkxl983uywgqqqqlgqqqvx5qqjqrzjqd98kxkpyw0l9tyy8r8q57k7zpy9zjmh6sez752wj6gcumqnj3yxzhdsmg6qq56utgqqqqqqqqqqqeqqjq7jd56882gtxhrjm03c93aacyfy306m4fq0tskf83c0nmet8zc2lxyyg3saz8x6vwcp26xnrlagf9semau3qm2glysp7sv95693fphvsp54l567';
function makeZapReceiptEvent(opts = {}) {
const recipient = opts.recipient || USER_PUBKEY;
const sender = opts.sender || SENDER_PUBKEY;
const zappedEventId = opts.zappedEventId || PHOTO_EVENT_ID_1;
const description = JSON.stringify({
kind: 9734,
pubkey: sender,
content: opts.message || '',
id: ZAP_REQ_ID,
created_at: 10000,
sig: 'fake',
tags: [
['p', recipient],
['relays', ['wss://relay.example.com']],
],
});
return {
id: opts.id || RECEIPT_ID_1,
kind: 9735,
pubkey: 'zap-service-pubkey',
created_at: opts.created_at || 10000,
tags: [
['p', recipient],
['P', sender],
['e', zappedEventId],
['bolt11', BOLT11_INVOICE],
['description', description],
],
content: '',
sig: 'sig',
};
}
function makePhotoEvent(opts = {}) {
return {
id: opts.id || PHOTO_EVENT_ID_1,
pubkey: opts.author || USER_PUBKEY,
kind: 360,
created_at: 5000,
tags: [
['i', opts.placeIdentifier || 'osm:node:123'],
['imeta', `url ${opts.url || 'https://x.com/photo.jpg'}`, 'dim 800x600'],
],
content: '',
sig: 'sig',
};
}
class MockNostrDataService extends Service {
@tracked profiles = {};
store = {
events: new Map(),
add(event) {
this.events.set(event.id, event);
},
getEvent(id) {
return this.events.get(id);
},
getReplaceable(kind, pubkey) {
// Find a replaceable event (kind 0 profile) by pubkey
for (const event of this.events.values()) {
if (event.kind === kind && event.pubkey === pubkey) {
return event;
}
}
return undefined;
},
timeline() {
return {
subscribe(callback) {
callback([]);
return { unsubscribe() {} };
},
};
},
model() {
return {
subscribe() {
return { unsubscribe() {} };
},
};
},
};
loadProfiles() {}
getProfile(pubkey) {
return this.profiles[pubkey];
}
async loadIncomingZaps() {}
}
module('Unit | Service | activity', function (hooks) {
setupTest(hooks);
hooks.beforeEach(function () {
this.owner.register('service:nostrData', MockNostrDataService);
});
test('_updateItems parses receipts and enriches with photos from the store', function (assert) {
const service = this.owner.lookup('service:activity');
const photoEvent = makePhotoEvent({
id: PHOTO_EVENT_ID_1,
placeIdentifier: 'osm:node:42',
});
service.nostrData.store.add(photoEvent);
const receipt = makeZapReceiptEvent({
zappedEventId: PHOTO_EVENT_ID_1,
message: 'Love it!',
});
service._updateItems([receipt], USER_PUBKEY);
assert.strictEqual(service.items.length, 1);
assert.strictEqual(service.items[0].type, 'zap');
assert.strictEqual(service.items[0].senderPubkey, SENDER_PUBKEY);
assert.strictEqual(service.items[0].amountSats, 2000);
assert.strictEqual(service.items[0].message, 'Love it!');
assert.strictEqual(service.items[0].placeIdentifier, 'osm:node:42');
assert.ok(service.items[0].photo, 'photo is populated');
});
test('_updateItems filters out zaps for non-photo events', function (assert) {
const service = this.owner.lookup('service:activity');
service.nostrData.store.add({
id: TEXT_EVENT_ID,
pubkey: USER_PUBKEY,
kind: 1,
created_at: 5000,
tags: [],
});
const receipt = makeZapReceiptEvent({ zappedEventId: TEXT_EVENT_ID });
service._updateItems([receipt], USER_PUBKEY);
assert.strictEqual(service.items.length, 0, 'non-photo zap filtered out');
});
test('_updateItems filters out zaps for photos not authored by the user', function (assert) {
const service = this.owner.lookup('service:activity');
const otherUserPhoto = makePhotoEvent({
id: PHOTO_EVENT_ID_OTHER,
author: 'z'.repeat(64),
});
service.nostrData.store.add(otherUserPhoto);
const receipt = makeZapReceiptEvent({
zappedEventId: PHOTO_EVENT_ID_OTHER,
});
service._updateItems([receipt], USER_PUBKEY);
assert.strictEqual(
service.items.length,
0,
"zap for someone else's photo filtered out"
);
});
test('_updateItems filters out zaps not directed at the user', function (assert) {
const service = this.owner.lookup('service:activity');
const photoEvent = makePhotoEvent({ id: PHOTO_EVENT_ID_1 });
service.nostrData.store.add(photoEvent);
const receipt = makeZapReceiptEvent({
recipient: 'c'.repeat(64),
zappedEventId: PHOTO_EVENT_ID_1,
});
service._updateItems([receipt], USER_PUBKEY);
assert.strictEqual(
service.items.length,
0,
'zap directed at someone else filtered out'
);
});
test('_updateItems sorts entries newest-first', function (assert) {
const service = this.owner.lookup('service:activity');
service.nostrData.store.add(makePhotoEvent({ id: PHOTO_EVENT_ID_1 }));
service.nostrData.store.add(makePhotoEvent({ id: PHOTO_EVENT_ID_2 }));
const oldReceipt = makeZapReceiptEvent({
id: RECEIPT_ID_1,
zappedEventId: PHOTO_EVENT_ID_1,
created_at: 1000,
});
const newReceipt = makeZapReceiptEvent({
id: RECEIPT_ID_2,
zappedEventId: PHOTO_EVENT_ID_2,
created_at: 9000,
});
service._updateItems([oldReceipt, newReceipt], USER_PUBKEY);
assert.strictEqual(service.items.length, 2);
assert.strictEqual(service.items[0].createdAt, 9000, 'newest first');
assert.strictEqual(service.items[1].createdAt, 1000, 'oldest second');
});
test('stop clears items and resets state', function (assert) {
const service = this.owner.lookup('service:activity');
service.nostrData.store.add(makePhotoEvent({ id: PHOTO_EVENT_ID_1 }));
service._updateItems(
[makeZapReceiptEvent({ zappedEventId: PHOTO_EVENT_ID_1 })],
USER_PUBKEY
);
assert.strictEqual(service.items.length, 1);
service.stop();
assert.strictEqual(service.items.length, 0, 'items cleared');
assert.strictEqual(service._userPubkey, null);
});
test('load with no pubkey clears items', async function (assert) {
const service = this.owner.lookup('service:activity');
service.items = [{ fake: true }];
await service.load(null);
assert.strictEqual(service.items.length, 0);
});
test('_resolveSender applies profile when available', function (assert) {
const service = this.owner.lookup('service:activity');
service.nostrData.profiles[SENDER_PUBKEY] = {
name: 'Alice',
picture: 'https://x.com/avatar.jpg',
};
const entry = parseZapReceipt(makeZapReceiptEvent(), USER_PUBKEY);
service._resolveSender(entry);
assert.strictEqual(entry.senderName, 'Alice');
assert.strictEqual(entry.senderAvatar, 'https://x.com/avatar.jpg');
assert.false(entry.senderProfileLoading);
});
});
+211
View File
@@ -0,0 +1,211 @@
import { module, test } from 'qunit';
import {
ActivityEntry,
parseZapReceipt,
enrichWithPhoto,
} from 'marco/utils/activity';
import {
setVerifyWrappedEventMethod,
fakeVerifyEvent,
} from 'applesauce-core/helpers/event';
setVerifyWrappedEventMethod(fakeVerifyEvent);
const USER_PUBKEY = 'a'.repeat(64);
const SENDER_PUBKEY = 'b'.repeat(64);
const PHOTO_EVENT_ID = '1'.repeat(64);
const RECEIPT_ID = '2'.repeat(64);
const ZAP_REQ_ID_1 = '3'.repeat(64);
const ZAP_REQ_ID_2 = '4'.repeat(64);
const PHOTO_XYZ_ID = '5'.repeat(64);
const OTHER_EVENT_ID = '6'.repeat(64);
const BOLT11_INVOICE =
'lnbc20u1p3y0x3hpp5743k2g0fsqqxj7n8qzuhns5gmkk4djeejk3wkp64ppevgekvc0jsdqcve5kzar2v9nr5gpqd4hkuetesp5ez2g297jduwc20t6lmqlsg3man0vf2jfd8ar9fh8fhn2g8yttfkqxqy9gcqcqzys9qrsgqrzjqtx3k77yrrav9hye7zar2rtqlfkytl094dsp0ms5majzth6gt7ca6uhdkxl983uywgqqqqlgqqqvx5qqjqrzjqd98kxkpyw0l9tyy8r8q57k7zpy9zjmh6sez752wj6gcumqnj3yxzhdsmg6qq56utgqqqqqqqqqqqeqqjq7jd56882gtxhrjm03c93aacyfy306m4fq0tskf83c0nmet8zc2lxyyg3saz8x6vwcp26xnrlagf9semau3qm2glysp7sv95693fphvsp54l567';
function makeZapReceiptEvent(opts = {}) {
const recipient = opts.recipient || USER_PUBKEY;
const sender = opts.sender || SENDER_PUBKEY;
const zappedEventId = opts.zappedEventId || PHOTO_EVENT_ID;
const description = JSON.stringify({
kind: 9734,
pubkey: sender,
content: opts.message || '',
id: ZAP_REQ_ID_1,
created_at: 10000,
sig: 'fake',
tags: [
['p', recipient],
['relays', ['wss://relay.example.com']],
],
});
return {
id: opts.id || RECEIPT_ID,
kind: 9735,
pubkey: opts.receiptAuthor || 'zap-service-pubkey',
created_at: opts.created_at || 10000,
tags: [
['p', recipient],
['P', sender],
['e', zappedEventId],
['bolt11', BOLT11_INVOICE],
['description', description],
],
content: '',
sig: 'sig',
};
}
function makePhotoEvent(opts = {}) {
return {
id: opts.id || PHOTO_EVENT_ID,
pubkey: opts.author || USER_PUBKEY,
kind: 360,
created_at: opts.created_at || 5000,
tags: [
['i', opts.placeIdentifier || 'osm:node:123'],
['imeta', `url ${opts.url || 'https://x.com/photo.jpg'}`, 'dim 800x600'],
],
content: '',
sig: 'sig',
};
}
module('Unit | Utility | activity', function () {
test('ActivityEntry has type "zap" and tracked sender fields', function (assert) {
const entry = new ActivityEntry({
photoEventId: '1'.repeat(64),
photo: null,
placeIdentifier: 'osm:node:1',
senderPubkey: SENDER_PUBKEY,
amountSats: 21,
message: 'Nice!',
createdAt: 10000,
});
assert.strictEqual(entry.type, 'zap');
assert.strictEqual(entry.senderPubkey, SENDER_PUBKEY);
assert.strictEqual(entry.amountSats, 21);
assert.strictEqual(entry.message, 'Nice!');
assert.true(entry.senderProfileLoading, 'senderProfileLoading starts true');
});
test('parseZapReceipt extracts sender, amount, and zapped event id', function (assert) {
const receipt = makeZapReceiptEvent({ message: 'Great photo!' });
const entry = parseZapReceipt(receipt, USER_PUBKEY);
assert.ok(entry, 'returns an entry');
assert.strictEqual(entry.senderPubkey, SENDER_PUBKEY);
assert.strictEqual(entry.photoEventId, PHOTO_EVENT_ID);
assert.strictEqual(entry.amountSats, 2000, '2000000 msat → 2000 sats');
assert.strictEqual(entry.message, 'Great photo!');
assert.strictEqual(entry.createdAt, 10000);
});
test('parseZapReceipt returns null when recipient is not the user', function (assert) {
const receipt = makeZapReceiptEvent({
recipient: 'c'.repeat(64),
});
const entry = parseZapReceipt(receipt, USER_PUBKEY);
assert.notOk(entry, 'not directed at the user');
});
test('parseZapReceipt returns null for wrong kind', function (assert) {
const receipt = makeZapReceiptEvent();
receipt.kind = 1;
const entry = parseZapReceipt(receipt, USER_PUBKEY);
assert.notOk(entry);
});
test('parseZapReceipt returns null when no event pointer', function (assert) {
const receipt = makeZapReceiptEvent();
receipt.tags = receipt.tags.filter((t) => t[0] !== 'e');
const entry = parseZapReceipt(receipt, USER_PUBKEY);
assert.notOk(entry);
});
test('parseZapReceipt handles null message', function (assert) {
const description = JSON.stringify({
kind: 9734,
pubkey: SENDER_PUBKEY,
content: '',
id: ZAP_REQ_ID_2,
created_at: 10000,
sig: 'fake',
tags: [
['p', USER_PUBKEY],
['relays', ['wss://relay.example.com']],
],
});
const receipt = makeZapReceiptEvent({ description });
const entry = parseZapReceipt(receipt, USER_PUBKEY);
assert.ok(entry);
assert.strictEqual(entry.message, null, 'empty content → null message');
});
test('enrichWithPhoto populates photo and placeIdentifier from the store', function (assert) {
const photoEvent = makePhotoEvent({
id: PHOTO_XYZ_ID,
placeIdentifier: 'osm:way:456',
});
const mockStore = {
getEvent: (id) => (id === PHOTO_XYZ_ID ? photoEvent : undefined),
};
const entry = parseZapReceipt(
makeZapReceiptEvent({ zappedEventId: PHOTO_XYZ_ID }),
USER_PUBKEY
);
const result = enrichWithPhoto(entry, mockStore, USER_PUBKEY);
assert.true(result, 'enrichment succeeded');
assert.ok(entry.photo, 'photo is populated');
assert.strictEqual(
entry.placeIdentifier,
'osm:way:456',
'placeIdentifier extracted from photo'
);
assert.strictEqual(entry.photo.url, 'https://x.com/photo.jpg');
});
test('enrichWithPhoto returns false when zapped event is not a kind 360', function (assert) {
const nonPhotoEvent = { id: OTHER_EVENT_ID, pubkey: USER_PUBKEY, kind: 1 };
const mockStore = { getEvent: () => nonPhotoEvent };
const entry = parseZapReceipt(
makeZapReceiptEvent({ zappedEventId: OTHER_EVENT_ID }),
USER_PUBKEY
);
const result = enrichWithPhoto(entry, mockStore, USER_PUBKEY);
assert.false(result, 'not a kind 360');
assert.notOk(entry.photo);
});
test('enrichWithPhoto returns false when zapped event is not authored by the user', function (assert) {
const otherUserPhoto = makePhotoEvent({ author: 'z'.repeat(64) });
const mockStore = { getEvent: () => otherUserPhoto };
const entry = parseZapReceipt(
makeZapReceiptEvent({ zappedEventId: PHOTO_EVENT_ID }),
USER_PUBKEY
);
const result = enrichWithPhoto(entry, mockStore, USER_PUBKEY);
assert.false(result, 'not authored by the user');
});
test('enrichWithPhoto returns false when zapped event not in store', function (assert) {
const mockStore = { getEvent: () => undefined };
const entry = parseZapReceipt(makeZapReceiptEvent(), USER_PUBKEY);
const result = enrichWithPhoto(entry, mockStore, USER_PUBKEY);
assert.false(result, 'event not in store');
});
});