Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f6bd5267f
|
||
|
|
fd1b570717
|
||
|
|
6d68a6240b
|
||
|
|
0ebe565cbc
|
||
|
|
e7020469c3
|
||
|
|
bf3011115b
|
||
|
|
8c98e0757b
|
||
|
|
2ae9a78f5d
|
||
|
|
252f41f897
|
||
|
|
572213eda2
|
||
|
|
ac7a4eb262
|
@@ -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,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>
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -16,30 +16,17 @@ export default modifier((element) => {
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
const tipRect = tooltipEl.getBoundingClientRect();
|
||||
const arrowSize = 6;
|
||||
|
||||
// Vertical placement: above trigger, flip below if no room
|
||||
let placement = 'top';
|
||||
let top = rect.top - tipRect.height - arrowSize;
|
||||
if (top < arrowSize) {
|
||||
placement = 'bottom';
|
||||
top = rect.bottom + arrowSize;
|
||||
}
|
||||
|
||||
// Horizontal: center on trigger, clamp to viewport
|
||||
const margin = 6;
|
||||
let top = rect.top - tipRect.height - margin;
|
||||
if (top < margin) top = rect.bottom + margin;
|
||||
let left = rect.left + rect.width / 2 - tipRect.width / 2;
|
||||
left = Math.max(
|
||||
arrowSize,
|
||||
Math.min(left, window.innerWidth - tipRect.width - arrowSize)
|
||||
margin,
|
||||
Math.min(left, window.innerWidth - tipRect.width - margin)
|
||||
);
|
||||
|
||||
// Arrow always points at trigger center, even when clamped
|
||||
const arrowLeft = rect.left + rect.width / 2 - left;
|
||||
|
||||
tooltipEl.dataset.placement = placement;
|
||||
tooltipEl.style.top = `${top}px`;
|
||||
tooltipEl.style.left = `${left}px`;
|
||||
tooltipEl.style.setProperty('--arrow-left', `${arrowLeft}px`);
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
|
||||
@@ -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,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();
|
||||
}
|
||||
}
|
||||
+98
-17
@@ -5,7 +5,8 @@ import { EMPTY, from, timeout, filter, connect, take, takeUntil } from 'rxjs';
|
||||
import { EventStore } from 'applesauce-core/event-store';
|
||||
import { ProfileModel } from 'applesauce-core/models/profile';
|
||||
import { MailboxesModel } from 'applesauce-core/models/mailboxes';
|
||||
import { npubEncode } from 'applesauce-core/helpers/pointers';
|
||||
import { ContactsModel } from 'applesauce-core/models/contacts';
|
||||
import { isEventPointer, npubEncode } from 'applesauce-core/helpers/pointers';
|
||||
import { persistEventsToCache } from 'applesauce-core/helpers/event-cache';
|
||||
import { createEventLoaderForStore } from 'applesauce-loaders/loaders';
|
||||
import { RelayGroup } from 'applesauce-relay';
|
||||
@@ -56,6 +57,7 @@ export default class NostrDataService extends Service {
|
||||
|
||||
@tracked profile = null;
|
||||
@tracked mailboxes = null;
|
||||
@tracked contacts = null;
|
||||
@tracked blossomServers = [];
|
||||
@tracked placePhotos = [];
|
||||
@tracked myContributionEvents = [];
|
||||
@@ -70,6 +72,10 @@ export default class NostrDataService extends Service {
|
||||
_eventRelays = new Map();
|
||||
_provenanceReady = null;
|
||||
|
||||
// Set of pubkeys from the user's follow list (kind 3 contacts) for O(1)
|
||||
// trust lookups. Rebuilt whenever contacts change.
|
||||
_contactPubkeys = null;
|
||||
|
||||
// Session-only reveal toggle for untrusted content. Not persisted.
|
||||
@tracked showUntrustedContent = false;
|
||||
// Count of currently-hidden (untrusted) place photos for the selected place.
|
||||
@@ -78,6 +84,7 @@ export default class NostrDataService extends Service {
|
||||
|
||||
_profileSub = null;
|
||||
_mailboxesSub = null;
|
||||
_contactsSub = null;
|
||||
_blossomSub = null;
|
||||
_photosSub = null;
|
||||
_contributionsSub = null;
|
||||
@@ -88,6 +95,7 @@ export default class NostrDataService extends Service {
|
||||
_zapReceiptsNetworkSub = null;
|
||||
_zapRefreshTimer = null;
|
||||
_lastPhotoIds = new Set();
|
||||
_incomingZapsNetworkSub = null;
|
||||
|
||||
_requestSub = null;
|
||||
_cachePromise = null;
|
||||
@@ -123,10 +131,11 @@ export default class NostrDataService extends Service {
|
||||
this._stopPersisting = persistEventsToCache(
|
||||
this.store,
|
||||
async (events) => {
|
||||
// Only cache profiles, mailboxes, blossom servers, and place photos, and deletions
|
||||
// Only cache profiles, mailboxes, contacts, blossom servers, place photos, and deletions
|
||||
const toCache = events.filter(
|
||||
(e) =>
|
||||
e.kind === 0 ||
|
||||
e.kind === 3 ||
|
||||
e.kind === 5 ||
|
||||
e.kind === 10002 ||
|
||||
e.kind === 10063 ||
|
||||
@@ -149,20 +158,17 @@ export default class NostrDataService extends Service {
|
||||
// instantly without waiting for relay connections.
|
||||
this._provenanceReady = this._hydrateProvenance();
|
||||
|
||||
// Centralized kind-5 deletion handling: when a deletion event enters the
|
||||
// store, drop the provenance entries for the events it deletes so the
|
||||
// trust map and IDB don't accumulate dead entries.
|
||||
this._deletionsSub = this.store
|
||||
.timeline([{ kinds: [5] }])
|
||||
.subscribe((events) => {
|
||||
for (const event of events) {
|
||||
for (const tag of event.tags || []) {
|
||||
if (tag[0] === 'e' && tag[1]) {
|
||||
this._removeProvenance(tag[1]);
|
||||
}
|
||||
}
|
||||
// Centralized kind-5 deletion handling: the store routes kind-5 events to
|
||||
// its DeleteManager (never into the event database), so listen to the
|
||||
// deletion stream rather than a timeline. Drop provenance entries for
|
||||
// deleted events so the trust map and IDB don't accumulate dead entries.
|
||||
this._deletionsSub = this.store.deletes.deleted$.subscribe(
|
||||
({ pointer }) => {
|
||||
if (isEventPointer(pointer)) {
|
||||
this._removeProvenance(pointer.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Feed events from the relay pool into the event store
|
||||
this.nostrRelay.pool.relays$.subscribe(() => {
|
||||
@@ -241,6 +247,7 @@ export default class NostrDataService extends Service {
|
||||
/**
|
||||
* Returns true if an event should be considered trusted:
|
||||
* - authored by the connected user (own uploads), OR
|
||||
* - authored by a pubkey the user follows (kind 3 contacts), OR
|
||||
* - seen on at least one trusted (moderated) relay.
|
||||
*/
|
||||
isTrustedEvent(event) {
|
||||
@@ -248,6 +255,8 @@ export default class NostrDataService extends Service {
|
||||
const myPubkey = this.nostrAuth?.pubkey;
|
||||
if (myPubkey && event.pubkey === myPubkey) return true;
|
||||
|
||||
if (this._contactPubkeys?.has(event.pubkey)) return true;
|
||||
|
||||
const relays = this._eventRelays.get(event.id);
|
||||
if (!relays || relays.size === 0) return false;
|
||||
const trusted = this.trustedRelays;
|
||||
@@ -543,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)
|
||||
@@ -576,6 +633,8 @@ export default class NostrDataService extends Service {
|
||||
// Reset state
|
||||
this.profile = null;
|
||||
this.mailboxes = null;
|
||||
this.contacts = null;
|
||||
this._contactPubkeys = null;
|
||||
this.blossomServers = [];
|
||||
|
||||
this._cleanupSubscriptions();
|
||||
@@ -594,6 +653,14 @@ export default class NostrDataService extends Service {
|
||||
this.mailboxes = mailboxesData;
|
||||
});
|
||||
|
||||
this._contactsSub = this.store
|
||||
.model(ContactsModel, pubkey)
|
||||
.subscribe((contacts) => {
|
||||
this.contacts = contacts;
|
||||
this._contactPubkeys = new Set(contacts.map((c) => c.pubkey));
|
||||
this._updatePlacePhotos();
|
||||
});
|
||||
|
||||
this._blossomSub = this.store
|
||||
.replaceable(10063, pubkey)
|
||||
.subscribe((event) => {
|
||||
@@ -623,7 +690,7 @@ export default class NostrDataService extends Service {
|
||||
const cachedEvents = await this.cache.query([
|
||||
{
|
||||
authors: [pubkey],
|
||||
kinds: [0, 10002, 10063],
|
||||
kinds: [0, 3, 10002, 10063],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -644,7 +711,7 @@ export default class NostrDataService extends Service {
|
||||
.request(profileRelays, [
|
||||
{
|
||||
authors: [pubkey],
|
||||
kinds: [0, 10002, 10063],
|
||||
kinds: [0, 3, 10002, 10063],
|
||||
},
|
||||
])
|
||||
.subscribe({
|
||||
@@ -806,6 +873,11 @@ export default class NostrDataService extends Service {
|
||||
this._mailboxesSub.unsubscribe();
|
||||
this._mailboxesSub = null;
|
||||
}
|
||||
if (this._contactsSub) {
|
||||
this._contactsSub.unsubscribe();
|
||||
this._contactsSub = null;
|
||||
this._contactPubkeys = null;
|
||||
}
|
||||
if (this._blossomSub) {
|
||||
this._blossomSub.unsubscribe();
|
||||
this._blossomSub = null;
|
||||
@@ -820,6 +892,10 @@ export default class NostrDataService extends Service {
|
||||
}
|
||||
this._cleanupZapReceiptSubs();
|
||||
this._clearZapRefreshTimer();
|
||||
if (this._incomingZapsNetworkSub) {
|
||||
this._incomingZapsNetworkSub.unsubscribe();
|
||||
this._incomingZapsNetworkSub = null;
|
||||
}
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
@@ -827,6 +903,11 @@ export default class NostrDataService extends Service {
|
||||
this._cleanupSubscriptions();
|
||||
this._clearProfileSubs();
|
||||
|
||||
if (this._deletionsSub) {
|
||||
this._deletionsSub.unsubscribe();
|
||||
this._deletionsSub = null;
|
||||
}
|
||||
|
||||
if (this._stopPersisting) {
|
||||
this._stopPersisting();
|
||||
}
|
||||
|
||||
+139
-20
@@ -728,26 +728,6 @@ body {
|
||||
animation: tooltip-fade-in 0.15s ease;
|
||||
}
|
||||
|
||||
.tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border: 6px solid transparent;
|
||||
left: var(--arrow-left, 50%);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.tooltip[data-placement='top']::after {
|
||||
top: 100%;
|
||||
border-top-color: var(--body-text-color);
|
||||
}
|
||||
|
||||
.tooltip[data-placement='bottom']::after {
|
||||
bottom: 100%;
|
||||
border-bottom-color: var(--body-text-color);
|
||||
}
|
||||
|
||||
@keyframes tooltip-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -2680,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
<meta name="description" content="Unhosted maps app that respects your privacy and choices.">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<!-- Fediverse account -->
|
||||
<link rel="me" href="https://kosmos.social/@marco">
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="Marco">
|
||||
<meta property="og:description" content="Unhosted maps app that respects your privacy and choices.">
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "marco",
|
||||
"version": "1.30.1",
|
||||
"version": "1.31.1",
|
||||
"private": true,
|
||||
"description": "Unhosted maps app",
|
||||
"repository": {
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+5
-2
@@ -6,6 +6,9 @@
|
||||
<meta name="description" content="Unhosted maps app that respects your privacy and choices.">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<!-- Fediverse account -->
|
||||
<link rel="me" href="https://kosmos.social/@marco">
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="Marco">
|
||||
<meta property="og:description" content="Unhosted maps app that respects your privacy and choices.">
|
||||
@@ -39,8 +42,8 @@
|
||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||
|
||||
<script type="module" crossorigin src="/assets/main-DeiUGvCJ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-DWxZH-9E.css">
|
||||
<script type="module" crossorigin src="/assets/main-D65ZMp0t.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-DjaQtV1_.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="modal-portal"></div>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -293,30 +293,9 @@ module('Integration | Component | app-menu/settings/nostr', function (hooks) {
|
||||
removeBtn.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
await settled();
|
||||
|
||||
const tooltip = document.body.querySelector('.tooltip');
|
||||
assert.dom('.tooltip', document.body).exists('tooltip appears on hover');
|
||||
assert.dom('.tooltip', document.body).hasText('Remove relay');
|
||||
|
||||
// Verify arrow is rendered
|
||||
assert.ok(tooltip.dataset.placement, 'tooltip has placement attribute');
|
||||
const arrowStyle = getComputedStyle(tooltip, '::after');
|
||||
assert.strictEqual(arrowStyle.content, '""', 'arrow pseudo-element exists');
|
||||
|
||||
// Verify arrow points in correct direction based on placement
|
||||
if (tooltip.dataset.placement === 'top') {
|
||||
assert.notStrictEqual(
|
||||
arrowStyle.borderTopColor,
|
||||
'rgba(0, 0, 0, 0)',
|
||||
'arrow points down when tooltip is above trigger'
|
||||
);
|
||||
} else {
|
||||
assert.notStrictEqual(
|
||||
arrowStyle.borderBottomColor,
|
||||
'rgba(0, 0, 0, 0)',
|
||||
'arrow points up when tooltip is below trigger'
|
||||
);
|
||||
}
|
||||
|
||||
removeBtn.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
|
||||
await settled();
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,912 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'marco/tests/helpers';
|
||||
import { Subject, EMPTY } from 'rxjs';
|
||||
import Service from '@ember/service';
|
||||
import NostrDataService from 'marco/services/nostr-data';
|
||||
import { getGeohashPrefixesInBbox } from 'marco/utils/geohash-coverage';
|
||||
|
||||
function makePubkey(n) {
|
||||
return n.toString(16).padStart(64, '0');
|
||||
}
|
||||
|
||||
function makeEventId(n) {
|
||||
return `e${n.toString(16).padStart(63, '0')}`;
|
||||
}
|
||||
|
||||
function makeContactsEvent(pubkey, contactPubkeys, opts = {}) {
|
||||
const id = opts.id || makeEventId(1);
|
||||
const createdAt = opts.created_at || 1000;
|
||||
return {
|
||||
id,
|
||||
pubkey,
|
||||
kind: 3,
|
||||
created_at: createdAt,
|
||||
tags: contactPubkeys.map((pk) => ['p', pk, 'wss://relay.example']),
|
||||
content: '',
|
||||
sig: 'sig',
|
||||
};
|
||||
}
|
||||
|
||||
function makePhotoEvent(pubkey, placeId, opts = {}) {
|
||||
const id = opts.id || makeEventId(100);
|
||||
const createdAt = opts.created_at || 2000;
|
||||
return {
|
||||
id,
|
||||
pubkey,
|
||||
kind: 360,
|
||||
created_at: createdAt,
|
||||
tags: [
|
||||
['i', placeId],
|
||||
['imeta', 'url https://example.com/photo.jpg', 'dim 800x600'],
|
||||
],
|
||||
content: '',
|
||||
sig: 'sig',
|
||||
};
|
||||
}
|
||||
|
||||
function makePhotoEventWithGeohash(pubkey, placeId, geohash, opts = {}) {
|
||||
const event = makePhotoEvent(pubkey, placeId, opts);
|
||||
event.tags.push(['g', geohash]);
|
||||
return event;
|
||||
}
|
||||
|
||||
function makeDeletionEvent(pubkey, eventIds, opts = {}) {
|
||||
const id = opts.id || makeEventId(50);
|
||||
return {
|
||||
id,
|
||||
pubkey,
|
||||
kind: 5,
|
||||
created_at: opts.created_at || 5000,
|
||||
tags: eventIds.map((eid) => ['e', eid]),
|
||||
content: '',
|
||||
sig: 'sig',
|
||||
};
|
||||
}
|
||||
|
||||
function makeZapReceiptEvent(pubkey, photoEventIds, opts = {}) {
|
||||
const id = opts.id || makeEventId(600);
|
||||
return {
|
||||
id,
|
||||
pubkey,
|
||||
kind: 9735,
|
||||
created_at: opts.created_at || 6000,
|
||||
tags: photoEventIds.map((eid) => ['e', eid]),
|
||||
content: '',
|
||||
sig: 'sig',
|
||||
};
|
||||
}
|
||||
|
||||
function wait(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function setupNostrDataService(hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
const requestedFilters = [];
|
||||
const reqCalls = [];
|
||||
const reqMessages = new Subject();
|
||||
|
||||
class StubNostrRelayService extends Service {
|
||||
pool = {
|
||||
relays$: new Subject(),
|
||||
request: (_relays, filters) => {
|
||||
requestedFilters.push(...filters);
|
||||
return EMPTY;
|
||||
},
|
||||
req: (_relays, filters) => {
|
||||
reqCalls.push(true);
|
||||
requestedFilters.push(...filters);
|
||||
return reqMessages;
|
||||
},
|
||||
publish: () => Promise.resolve([{ ok: true }]),
|
||||
};
|
||||
}
|
||||
|
||||
this.owner.register('service:nostrRelay', StubNostrRelayService);
|
||||
this.owner.register('service:nostrData', NostrDataService);
|
||||
|
||||
this.requestedFilters = requestedFilters;
|
||||
this.reqCalls = reqCalls;
|
||||
this.reqMessages = reqMessages;
|
||||
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
service.store.verifyEvent = undefined;
|
||||
});
|
||||
|
||||
hooks.afterEach(async function () {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
await service.clearCache();
|
||||
await service.localForage.clear('event-relay-provenance');
|
||||
});
|
||||
}
|
||||
|
||||
module('Unit | Service | nostr-data | contacts', function (hooks) {
|
||||
setupNostrDataService(hooks);
|
||||
|
||||
test('loadProfile populates contacts from store via ContactsModel', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
const pubkey = makePubkey(1);
|
||||
const contactA = makePubkey(2);
|
||||
const contactB = makePubkey(3);
|
||||
|
||||
service.store.add(makeContactsEvent(pubkey, [contactA, contactB]));
|
||||
|
||||
await service.loadProfile(pubkey);
|
||||
|
||||
assert.ok(service.contacts, 'contacts is populated');
|
||||
assert.strictEqual(service.contacts.length, 2, 'two contacts');
|
||||
const pubkeys = service.contacts.map((c) => c.pubkey).sort();
|
||||
assert.deepEqual(
|
||||
pubkeys,
|
||||
[contactA, contactB].sort(),
|
||||
'contact pubkeys match'
|
||||
);
|
||||
});
|
||||
|
||||
test('loadProfile tears down previous contacts subscription when called with a different pubkey', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
const pubkeyA = makePubkey(1);
|
||||
const pubkeyB = makePubkey(4);
|
||||
const contactA = makePubkey(2);
|
||||
|
||||
service.store.add(makeContactsEvent(pubkeyA, [contactA]));
|
||||
await service.loadProfile(pubkeyA);
|
||||
|
||||
assert.strictEqual(
|
||||
service.contacts.length,
|
||||
1,
|
||||
'contacts populated for pubkeyA'
|
||||
);
|
||||
|
||||
// Switch to a different pubkey — this should tear down pubkeyA's subscription
|
||||
await service.loadProfile(pubkeyB);
|
||||
|
||||
assert.deepEqual(
|
||||
service.contacts,
|
||||
[],
|
||||
'contacts is empty for pubkeyB (no kind 3 event)'
|
||||
);
|
||||
|
||||
// Add a new contacts event for pubkeyA after the switch
|
||||
const newerEvent = makeContactsEvent(pubkeyA, [makePubkey(9)], {
|
||||
created_at: 2000,
|
||||
});
|
||||
service.store.add(newerEvent);
|
||||
|
||||
// Give the subscriptions a tick to propagate
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
assert.deepEqual(
|
||||
service.contacts,
|
||||
[],
|
||||
'contacts remains empty — old subscription was torn down'
|
||||
);
|
||||
});
|
||||
|
||||
test('loadProfile network request includes kind 3', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
const pubkey = makePubkey(1);
|
||||
await service.loadProfile(pubkey);
|
||||
|
||||
const kindsFilter = this.requestedFilters.find((f) =>
|
||||
f.authors?.includes(pubkey)
|
||||
);
|
||||
assert.ok(kindsFilter, 'filter with authors was requested');
|
||||
assert.ok(kindsFilter.kinds.includes(3), 'kinds includes 3 (contacts)');
|
||||
assert.deepEqual(
|
||||
kindsFilter.kinds.sort(),
|
||||
[0, 3, 10002, 10063].sort(),
|
||||
'kinds match expected set'
|
||||
);
|
||||
});
|
||||
|
||||
test('kind 3 events are persisted to IDB cache', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
// Wait for the IDB cache to be ready before adding events
|
||||
await service._cachePromise;
|
||||
|
||||
const pubkey = makePubkey(1);
|
||||
const contactA = makePubkey(2);
|
||||
const event = makeContactsEvent(pubkey, [contactA], { id: makeEventId(5) });
|
||||
|
||||
service.store.add(event);
|
||||
|
||||
// Wait for the persistEventsToCache batch (1s) plus some margin
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
|
||||
const cached = await service.cache.query([
|
||||
{ kinds: [3], authors: [pubkey] },
|
||||
]);
|
||||
assert.strictEqual(cached.length, 1, 'kind 3 event is in IDB cache');
|
||||
assert.strictEqual(cached[0].id, event.id, 'cached event id matches');
|
||||
});
|
||||
});
|
||||
|
||||
module('Unit | Service | nostr-data | relay configuration', function (hooks) {
|
||||
setupNostrDataService(hooks);
|
||||
|
||||
test('mailboxReadRelays normalizes mailbox inbox URLs', function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
assert.deepEqual(service.mailboxReadRelays, [], 'empty when no mailboxes');
|
||||
|
||||
service.mailboxes = {
|
||||
inboxes: ['WSS://Relay.Example.COM/', 'relay.two.example'],
|
||||
outboxes: [],
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
service.mailboxReadRelays,
|
||||
['wss://relay.example.com', 'wss://relay.two.example'],
|
||||
'normalizes and filters invalid URLs'
|
||||
);
|
||||
});
|
||||
|
||||
test('mailboxWriteRelays returns empty array without mailboxes', function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
assert.deepEqual(service.mailboxWriteRelays, [], 'empty when no mailboxes');
|
||||
|
||||
service.mailboxes = {
|
||||
inboxes: [],
|
||||
outboxes: ['WSS://Outbox.Example.COM/'],
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
service.mailboxWriteRelays,
|
||||
['wss://outbox.example.com'],
|
||||
'normalizes outbox URLs'
|
||||
);
|
||||
});
|
||||
|
||||
test('configuredReadRelays merges mailbox and custom relays with dedupe and exclusions', function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
service.mailboxes = { inboxes: ['wss://a.example'], outboxes: [] };
|
||||
service.settings.nostrReadRelays = ['wss://a.example/', 'wss://b.example'];
|
||||
|
||||
assert.deepEqual(
|
||||
service.configuredReadRelays,
|
||||
['wss://a.example', 'wss://b.example'],
|
||||
'merges and deduplicates (normalized)'
|
||||
);
|
||||
|
||||
service.settings.nostrReadRelayExclusions = ['wss://a.example'];
|
||||
|
||||
assert.deepEqual(
|
||||
service.configuredReadRelays,
|
||||
['wss://b.example'],
|
||||
'exclusions remove mailbox relays'
|
||||
);
|
||||
});
|
||||
|
||||
test('configuredWriteRelays merges mailbox outboxes with custom write relays', function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
service.mailboxes = { inboxes: [], outboxes: ['wss://out.example'] };
|
||||
service.settings.nostrWriteRelays = ['wss://custom.example'];
|
||||
|
||||
assert.deepEqual(
|
||||
service.configuredWriteRelays,
|
||||
['wss://out.example', 'wss://custom.example'],
|
||||
'merges mailbox and custom write relays'
|
||||
);
|
||||
|
||||
service.settings.nostrWriteRelayExclusions = ['wss://out.example'];
|
||||
|
||||
assert.deepEqual(
|
||||
service.configuredWriteRelays,
|
||||
['wss://custom.example'],
|
||||
'exclusions apply to write relays'
|
||||
);
|
||||
});
|
||||
|
||||
test('activeReadRelays puts required relays first and appends custom', function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
assert.deepEqual(
|
||||
service.activeReadRelays,
|
||||
['wss://nostr.kosmos.org'],
|
||||
'default is required only'
|
||||
);
|
||||
|
||||
service.settings.nostrReadRelays = ['wss://custom.example'];
|
||||
|
||||
assert.deepEqual(
|
||||
service.activeReadRelays,
|
||||
['wss://nostr.kosmos.org', 'wss://custom.example'],
|
||||
'required first, custom appended'
|
||||
);
|
||||
});
|
||||
|
||||
test('activeWriteRelays returns empty when nothing configured', function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
assert.deepEqual(
|
||||
service.activeWriteRelays,
|
||||
[],
|
||||
'no required write relays by default'
|
||||
);
|
||||
});
|
||||
|
||||
test('trustedRelays includes required read relays plus user-marked trusted relays', function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
assert.deepEqual(
|
||||
service.trustedRelays,
|
||||
['wss://nostr.kosmos.org'],
|
||||
'default is required read relays only'
|
||||
);
|
||||
|
||||
service.settings.nostrTrustedRelays = ['wss://custom.example'];
|
||||
|
||||
assert.deepEqual(
|
||||
service.trustedRelays,
|
||||
['wss://nostr.kosmos.org', 'wss://custom.example'],
|
||||
'merges custom trusted relays'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
module('Unit | Service | nostr-data | provenance', function (hooks) {
|
||||
setupNostrDataService(hooks);
|
||||
|
||||
test('_recordProvenance accumulates relays for an event and persists them', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const eventId = makeEventId(900);
|
||||
|
||||
service._recordProvenance(eventId, 'wss://one.example');
|
||||
service._recordProvenance(eventId, 'wss://two.example');
|
||||
service._recordProvenance(eventId, 'wss://one.example'); // duplicate
|
||||
|
||||
const relays = service._eventRelays.get(eventId);
|
||||
assert.strictEqual(relays.size, 2, 'accumulates unique relays');
|
||||
assert.true(relays.has('wss://one.example'), 'has first relay');
|
||||
assert.true(relays.has('wss://two.example'), 'has second relay');
|
||||
|
||||
const persisted = await service.localForage.get(
|
||||
'event-relay-provenance',
|
||||
eventId
|
||||
);
|
||||
assert.deepEqual(
|
||||
persisted.sort(),
|
||||
['wss://one.example', 'wss://two.example'],
|
||||
'persists to localForage'
|
||||
);
|
||||
});
|
||||
|
||||
test('kind 5 deletion events remove provenance for referenced events', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const deadId = makeEventId(901);
|
||||
const author = makePubkey(50);
|
||||
|
||||
service._recordProvenance(deadId, 'wss://one.example');
|
||||
|
||||
// The store routes kind-5 events to its DeleteManager, which emits
|
||||
// deleted$ synchronously from add() — provenance is dropped immediately.
|
||||
service.store.add(makeDeletionEvent(author, [deadId]));
|
||||
|
||||
assert.false(
|
||||
service._eventRelays.has(deadId),
|
||||
'provenance removed from memory'
|
||||
);
|
||||
const persisted = await service.localForage.get(
|
||||
'event-relay-provenance',
|
||||
deadId
|
||||
);
|
||||
assert.strictEqual(persisted, null, 'provenance removed from localForage');
|
||||
});
|
||||
|
||||
test('_hydrateProvenance restores persisted provenance', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const eventId = makeEventId(902);
|
||||
|
||||
await service.localForage.set('event-relay-provenance', eventId, [
|
||||
'wss://x.example',
|
||||
]);
|
||||
|
||||
await service._hydrateProvenance();
|
||||
|
||||
const relays = service._eventRelays.get(eventId);
|
||||
assert.ok(relays, 'provenance restored');
|
||||
assert.true(relays.has('wss://x.example'), 'contains the relay');
|
||||
});
|
||||
|
||||
test('_requestContentWithProvenance records provenance and adds events to the store', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const eventId = makeEventId(903);
|
||||
const pubkey = makePubkey(60);
|
||||
|
||||
service._requestContentWithProvenance(
|
||||
['wss://relay.test'],
|
||||
[{ kinds: [360] }],
|
||||
'test'
|
||||
);
|
||||
|
||||
const photoEvent = makePhotoEvent(pubkey, 'osm:node:1', { id: eventId });
|
||||
this.reqMessages.next({
|
||||
type: 'EVENT',
|
||||
event: photoEvent,
|
||||
from: 'wss://relay.test',
|
||||
});
|
||||
|
||||
await wait(50);
|
||||
|
||||
const relays = service._eventRelays.get(eventId);
|
||||
assert.ok(relays, 'provenance recorded');
|
||||
assert.true(
|
||||
relays.has('wss://relay.test'),
|
||||
'contains the relay the event came from'
|
||||
);
|
||||
assert.true(service.store.hasEvent(eventId), 'event added to store');
|
||||
});
|
||||
});
|
||||
|
||||
module('Unit | Service | nostr-data | trust evaluation', function (hooks) {
|
||||
setupNostrDataService(hooks);
|
||||
|
||||
test('isTrustedEvent trusts content from followed contacts', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
const userPubkey = makePubkey(1);
|
||||
const followedPubkey = makePubkey(2);
|
||||
|
||||
// User follows followedPubkey
|
||||
service.store.add(makeContactsEvent(userPubkey, [followedPubkey]));
|
||||
await service.loadProfile(userPubkey);
|
||||
|
||||
// Photo from followed contact (no trusted relay provenance)
|
||||
const photoEvent = makePhotoEvent(followedPubkey, 'osm:node:123', {
|
||||
id: makeEventId(200),
|
||||
});
|
||||
service.store.add(photoEvent);
|
||||
|
||||
assert.true(
|
||||
service.isTrustedEvent(photoEvent),
|
||||
'photo from followed contact is trusted'
|
||||
);
|
||||
});
|
||||
|
||||
test('isTrustedEvent does not trust content from unfollowed pubkeys', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
const userPubkey = makePubkey(1);
|
||||
const followedPubkey = makePubkey(2);
|
||||
const unfollowedPubkey = makePubkey(3);
|
||||
|
||||
// User follows followedPubkey (but NOT unfollowedPubkey)
|
||||
service.store.add(makeContactsEvent(userPubkey, [followedPubkey]));
|
||||
await service.loadProfile(userPubkey);
|
||||
|
||||
// Photo from unfollowed pubkey (no trusted relay provenance)
|
||||
const photoEvent = makePhotoEvent(unfollowedPubkey, 'osm:node:123', {
|
||||
id: makeEventId(201),
|
||||
});
|
||||
service.store.add(photoEvent);
|
||||
|
||||
assert.false(
|
||||
service.isTrustedEvent(photoEvent),
|
||||
'photo from unfollowed pubkey is not trusted'
|
||||
);
|
||||
});
|
||||
|
||||
test('isTrustedEvent still trusts own content (regression)', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
const userPubkey = makePubkey(1);
|
||||
service.nostrAuth = { pubkey: userPubkey };
|
||||
|
||||
await service.loadProfile(userPubkey);
|
||||
|
||||
// Photo from the user themselves
|
||||
const photoEvent = makePhotoEvent(userPubkey, 'osm:node:123', {
|
||||
id: makeEventId(202),
|
||||
});
|
||||
service.store.add(photoEvent);
|
||||
|
||||
assert.true(service.isTrustedEvent(photoEvent), 'own photo is trusted');
|
||||
});
|
||||
|
||||
test('isTrustedEvent still trusts content from trusted relays (regression)', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
const userPubkey = makePubkey(1);
|
||||
const randomPubkey = makePubkey(99);
|
||||
|
||||
await service.loadProfile(userPubkey);
|
||||
|
||||
// Photo from random pubkey
|
||||
const photoEvent = makePhotoEvent(randomPubkey, 'osm:node:123', {
|
||||
id: makeEventId(203),
|
||||
});
|
||||
service.store.add(photoEvent);
|
||||
|
||||
// Simulate provenance: photo was seen on a trusted relay
|
||||
const trustedRelay = 'wss://nostr.kosmos.org';
|
||||
service._recordProvenance(photoEvent.id, trustedRelay);
|
||||
|
||||
assert.true(
|
||||
service.isTrustedEvent(photoEvent),
|
||||
'photo from trusted relay is trusted'
|
||||
);
|
||||
});
|
||||
|
||||
test('isTrustedEvent re-evaluates when contacts change', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
const userPubkey = makePubkey(1);
|
||||
const followedPubkey = makePubkey(2);
|
||||
|
||||
// Load profile first (no contacts yet)
|
||||
await service.loadProfile(userPubkey);
|
||||
|
||||
// Create a photo from followedPubkey (who is not yet followed)
|
||||
const photoEvent = makePhotoEvent(followedPubkey, 'osm:node:456', {
|
||||
id: makeEventId(204),
|
||||
});
|
||||
|
||||
// Photo should be untrusted initially (no contacts loaded yet)
|
||||
assert.false(
|
||||
service.isTrustedEvent(photoEvent),
|
||||
'photo is untrusted before contacts load'
|
||||
);
|
||||
|
||||
// Now load contacts (user follows followedPubkey)
|
||||
service.store.add(
|
||||
makeContactsEvent(userPubkey, [followedPubkey], {
|
||||
id: makeEventId(300),
|
||||
created_at: 3000,
|
||||
})
|
||||
);
|
||||
|
||||
// Give the contacts subscription a tick to propagate
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// Photo should now be trusted (contacts loaded)
|
||||
assert.true(
|
||||
service.isTrustedEvent(photoEvent),
|
||||
'photo is trusted after contacts load'
|
||||
);
|
||||
});
|
||||
|
||||
test('partitionByTrust always passes kind 5 deletions through as trusted', function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const untrustedPk = makePubkey(70);
|
||||
const authorPk = makePubkey(71);
|
||||
|
||||
const untrustedPhoto = makePhotoEvent(untrustedPk, 'osm:node:1', {
|
||||
id: makeEventId(904),
|
||||
});
|
||||
const deletion = makeDeletionEvent(authorPk, [untrustedPhoto.id], {
|
||||
id: makeEventId(905),
|
||||
});
|
||||
|
||||
const { trusted, untrusted } = service.partitionByTrust([
|
||||
untrustedPhoto,
|
||||
deletion,
|
||||
]);
|
||||
|
||||
assert.strictEqual(trusted.length, 1, 'one trusted event');
|
||||
assert.strictEqual(trusted[0].kind, 5, 'deletion is trusted');
|
||||
assert.strictEqual(untrusted.length, 1, 'one untrusted event');
|
||||
assert.strictEqual(untrusted[0].kind, 360, 'photo is untrusted');
|
||||
});
|
||||
});
|
||||
|
||||
module('Unit | Service | nostr-data | geohash loading', function (hooks) {
|
||||
setupNostrDataService(hooks);
|
||||
|
||||
const BERLIN_BBOX = {
|
||||
minLat: 52.5,
|
||||
minLon: 13.4,
|
||||
maxLat: 52.55,
|
||||
maxLon: 13.45,
|
||||
};
|
||||
|
||||
test('requests kind 360 events for missing geohash prefixes and marks them loaded', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const expected = getGeohashPrefixesInBbox(BERLIN_BBOX);
|
||||
assert.ok(expected.length > 0, 'bbox produces prefixes');
|
||||
|
||||
await service.loadPlacesInBounds(BERLIN_BBOX);
|
||||
|
||||
assert.strictEqual(this.reqCalls.length, 1, 'one network request made');
|
||||
const filter = this.requestedFilters.find((f) => f['#g']);
|
||||
assert.ok(filter, 'geohash filter was requested');
|
||||
assert.deepEqual(filter.kinds, [360], 'requests kind 360');
|
||||
assert.deepEqual(
|
||||
filter['#g'].sort(),
|
||||
expected.sort(),
|
||||
'covers all prefixes'
|
||||
);
|
||||
for (const p of expected) {
|
||||
assert.true(
|
||||
service.loadedGeohashPrefixes.has(p),
|
||||
`prefix ${p} marked loaded`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('skips prefixes that were already loaded', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
await service.loadPlacesInBounds(BERLIN_BBOX);
|
||||
const firstReqCalls = this.reqCalls.length;
|
||||
|
||||
await service.loadPlacesInBounds(BERLIN_BBOX);
|
||||
|
||||
assert.strictEqual(
|
||||
this.reqCalls.length,
|
||||
firstReqCalls,
|
||||
'no additional network request'
|
||||
);
|
||||
});
|
||||
|
||||
test('hydrates matching cached photos into the store', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
await service._cachePromise;
|
||||
|
||||
const pk = makePubkey(80);
|
||||
const geohash = getGeohashPrefixesInBbox(BERLIN_BBOX)[0];
|
||||
const cached = makePhotoEventWithGeohash(pk, 'osm:node:700', geohash, {
|
||||
id: makeEventId(700),
|
||||
created_at: 4000,
|
||||
});
|
||||
await service.cache.add(cached);
|
||||
|
||||
await service.loadPlacesInBounds(BERLIN_BBOX);
|
||||
|
||||
assert.true(
|
||||
service.store.hasEvent(cached.id),
|
||||
'cached photo added to store'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
module('Unit | Service | nostr-data | place photos', function (hooks) {
|
||||
setupNostrDataService(hooks);
|
||||
|
||||
test('sets entity id and streams photos for the place', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const pk = makePubkey(81);
|
||||
const photoId = makeEventId(800);
|
||||
|
||||
await service.loadPhotosForPlace({ osmId: '800', osmType: 'node' });
|
||||
assert.strictEqual(
|
||||
service._currentPlaceEntityId,
|
||||
'osm:node:800',
|
||||
'entity id set'
|
||||
);
|
||||
|
||||
service._recordProvenance(photoId, 'wss://nostr.kosmos.org');
|
||||
service.store.add(makePhotoEvent(pk, 'osm:node:800', { id: photoId }));
|
||||
|
||||
assert.strictEqual(service.placePhotos.length, 1, 'photo visible');
|
||||
});
|
||||
|
||||
test('calling with the same place twice does not tear down subscriptions', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
await service.loadPhotosForPlace({ osmId: '800', osmType: 'node' });
|
||||
const firstSub = service._photosSub;
|
||||
|
||||
await service.loadPhotosForPlace({ osmId: '800', osmType: 'node' });
|
||||
|
||||
assert.strictEqual(
|
||||
service._photosSub,
|
||||
firstSub,
|
||||
'same subscription retained'
|
||||
);
|
||||
});
|
||||
|
||||
test('switching places resets state and re-subscribes', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const pk = makePubkey(82);
|
||||
const idA = makeEventId(801);
|
||||
const idB = makeEventId(802);
|
||||
|
||||
await service.loadPhotosForPlace({ osmId: '800', osmType: 'node' });
|
||||
service._recordProvenance(idA, 'wss://nostr.kosmos.org');
|
||||
service.store.add(makePhotoEvent(pk, 'osm:node:800', { id: idA }));
|
||||
assert.strictEqual(service.placePhotos.length, 1, 'place 800 has photo');
|
||||
|
||||
await service.loadPhotosForPlace({ osmId: '801', osmType: 'node' });
|
||||
assert.deepEqual(service.placePhotos, [], 'photos cleared');
|
||||
assert.strictEqual(
|
||||
service._currentPlaceEntityId,
|
||||
'osm:node:801',
|
||||
'entity id updated'
|
||||
);
|
||||
|
||||
service._recordProvenance(idB, 'wss://nostr.kosmos.org');
|
||||
service.store.add(makePhotoEvent(pk, 'osm:node:801', { id: idB }));
|
||||
assert.strictEqual(service.placePhotos.length, 1, 'new place has photo');
|
||||
assert.strictEqual(service.placePhotos[0].id, idB);
|
||||
});
|
||||
|
||||
test('null place clears state and tears down subscription', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const pk = makePubkey(83);
|
||||
const idA = makeEventId(803);
|
||||
|
||||
await service.loadPhotosForPlace({ osmId: '800', osmType: 'node' });
|
||||
service._recordProvenance(idA, 'wss://nostr.kosmos.org');
|
||||
service.store.add(makePhotoEvent(pk, 'osm:node:800', { id: idA }));
|
||||
assert.strictEqual(service.placePhotos.length, 1);
|
||||
|
||||
await service.loadPhotosForPlace(null);
|
||||
|
||||
assert.deepEqual(service.placePhotos, [], 'photos cleared');
|
||||
assert.strictEqual(
|
||||
service._currentPlaceEntityId,
|
||||
null,
|
||||
'entity id cleared'
|
||||
);
|
||||
assert.strictEqual(service._photosSub, null, 'subscription torn down');
|
||||
});
|
||||
|
||||
test('placePhotos hides untrusted photos by default and counts them', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const pkA = makePubkey(84);
|
||||
const pkB = makePubkey(85);
|
||||
const trustedId = makeEventId(900);
|
||||
const untrustedId = makeEventId(901);
|
||||
|
||||
await service.loadPhotosForPlace({ osmId: '900', osmType: 'node' });
|
||||
|
||||
service._recordProvenance(trustedId, 'wss://nostr.kosmos.org');
|
||||
service.store.add(makePhotoEvent(pkA, 'osm:node:900', { id: trustedId }));
|
||||
service.store.add(makePhotoEvent(pkB, 'osm:node:900', { id: untrustedId }));
|
||||
|
||||
assert.deepEqual(
|
||||
service.placePhotos.map((e) => e.id),
|
||||
[trustedId],
|
||||
'only trusted shown'
|
||||
);
|
||||
assert.strictEqual(service.untrustedContentCount, 1, 'untrusted counted');
|
||||
});
|
||||
|
||||
test('toggleShowUntrustedContent reveals all photos and toggles back', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const pkA = makePubkey(86);
|
||||
const pkB = makePubkey(87);
|
||||
const trustedId = makeEventId(902);
|
||||
const untrustedId = makeEventId(903);
|
||||
|
||||
await service.loadPhotosForPlace({ osmId: '901', osmType: 'node' });
|
||||
|
||||
service._recordProvenance(trustedId, 'wss://nostr.kosmos.org');
|
||||
service.store.add(makePhotoEvent(pkA, 'osm:node:901', { id: trustedId }));
|
||||
service.store.add(makePhotoEvent(pkB, 'osm:node:901', { id: untrustedId }));
|
||||
|
||||
service.toggleShowUntrustedContent();
|
||||
assert.strictEqual(service.placePhotos.length, 2, 'all photos shown');
|
||||
assert.strictEqual(service.untrustedContentCount, 1, 'count unchanged');
|
||||
|
||||
service.toggleShowUntrustedContent();
|
||||
assert.strictEqual(service.placePhotos.length, 1, 'back to trusted only');
|
||||
});
|
||||
|
||||
test('own photos are shown without provenance', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const myPubkey = makePubkey(95);
|
||||
service.nostrAuth = { pubkey: myPubkey };
|
||||
const ownId = makeEventId(950);
|
||||
|
||||
await service.loadPhotosForPlace({ osmId: '950', osmType: 'node' });
|
||||
service.store.add(makePhotoEvent(myPubkey, 'osm:node:950', { id: ownId }));
|
||||
|
||||
assert.strictEqual(
|
||||
service.placePhotos.length,
|
||||
1,
|
||||
'own photo shown without provenance'
|
||||
);
|
||||
assert.strictEqual(service.untrustedContentCount, 0);
|
||||
});
|
||||
});
|
||||
|
||||
module('Unit | Service | nostr-data | my contributions', function (hooks) {
|
||||
setupNostrDataService(hooks);
|
||||
|
||||
test('returns early when pubkey is null', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
await service.loadMyContributions(null);
|
||||
|
||||
assert.deepEqual(service.myContributionEvents, [], 'no events loaded');
|
||||
assert.strictEqual(
|
||||
service._contributionsSub,
|
||||
null,
|
||||
'no subscription created'
|
||||
);
|
||||
});
|
||||
|
||||
test('loads own photos from store into myContributionEvents', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const myPk = makePubkey(60);
|
||||
const photoId = makeEventId(600);
|
||||
|
||||
service.store.add(makePhotoEvent(myPk, 'osm:node:600', { id: photoId }));
|
||||
|
||||
await service.loadMyContributions(myPk);
|
||||
|
||||
assert.strictEqual(
|
||||
service.myContributionEvents.length,
|
||||
1,
|
||||
'photo in contributions'
|
||||
);
|
||||
assert.strictEqual(service.myContributionEvents[0].id, photoId);
|
||||
});
|
||||
|
||||
test('requests own contributions from network with correct filter', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const myPk = makePubkey(61);
|
||||
|
||||
await service.loadMyContributions(myPk);
|
||||
|
||||
const filter = this.requestedFilters.find((f) => f.authors?.includes(myPk));
|
||||
assert.ok(filter, 'filter for own pubkey requested');
|
||||
assert.deepEqual(filter.kinds.sort(), [360, 5].sort(), 'kinds 360 and 5');
|
||||
});
|
||||
});
|
||||
|
||||
module('Unit | Service | nostr-data | zap receipts', function (hooks) {
|
||||
setupNostrDataService(hooks);
|
||||
|
||||
test('_refreshZapReceiptSubscription batches >100 IDs into multiple filters', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const photoIds = Array.from({ length: 250 }, (_, i) =>
|
||||
makeEventId(1000 + i)
|
||||
);
|
||||
|
||||
service._refreshZapReceiptSubscription(photoIds);
|
||||
|
||||
const filters = this.requestedFilters.filter((f) =>
|
||||
f.kinds?.includes(9735)
|
||||
);
|
||||
assert.strictEqual(filters.length, 3, '250 IDs → 3 filters (100+100+50)');
|
||||
assert.strictEqual(filters[0]['#e'].length, 100);
|
||||
assert.strictEqual(filters[1]['#e'].length, 100);
|
||||
assert.strictEqual(filters[2]['#e'].length, 50);
|
||||
});
|
||||
|
||||
test('_updateZapReceipts groups receipts by photo event id', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const photoA = makeEventId(2000);
|
||||
const photoB = makeEventId(2001);
|
||||
|
||||
const receipt1 = makeZapReceiptEvent(makePubkey(70), [photoA], {
|
||||
id: makeEventId(2100),
|
||||
});
|
||||
const receipt2 = makeZapReceiptEvent(makePubkey(71), [photoA, photoB], {
|
||||
id: makeEventId(2101),
|
||||
});
|
||||
const receipt3 = makeZapReceiptEvent(makePubkey(72), [photoB], {
|
||||
id: makeEventId(2102),
|
||||
});
|
||||
|
||||
service._updateZapReceipts([receipt1, receipt2, receipt3]);
|
||||
|
||||
assert.strictEqual(
|
||||
service.zapReceipts[photoA].length,
|
||||
2,
|
||||
'photoA has 2 receipts'
|
||||
);
|
||||
assert.strictEqual(
|
||||
service.zapReceipts[photoB].length,
|
||||
2,
|
||||
'photoB has 2 receipts'
|
||||
);
|
||||
assert.deepEqual(
|
||||
service.zapReceipts[photoA].map((r) => r.id).sort(),
|
||||
[makeEventId(2100), makeEventId(2101)],
|
||||
'receipts for photoA match'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user