Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f6bd5267f
|
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,7 @@ export default class NostrDataService extends Service {
|
||||
_zapReceiptsNetworkSub = null;
|
||||
_zapRefreshTimer = null;
|
||||
_lastPhotoIds = new Set();
|
||||
_incomingZapsNetworkSub = null;
|
||||
|
||||
_requestSub = null;
|
||||
_cachePromise = null;
|
||||
@@ -551,6 +552,54 @@ export default class NostrDataService extends Service {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrates the store with incoming zap receipts (kind 9735) where the user is
|
||||
* the recipient (`#p` filter). Loads from the IDB cache first (instant), then
|
||||
* fires a network request. Called by the `activity` service.
|
||||
*
|
||||
* @param {string} pubkey The user's Nostr pubkey
|
||||
*/
|
||||
async loadIncomingZaps(pubkey) {
|
||||
if (!pubkey) return;
|
||||
|
||||
const filters = [{ kinds: [9735], '#p': [pubkey] }];
|
||||
|
||||
console.debug('[nostr-data] Requesting incoming zap receipts from relays', {
|
||||
filters,
|
||||
relays: this.activeReadRelays,
|
||||
});
|
||||
|
||||
if (this._incomingZapsNetworkSub) {
|
||||
this._incomingZapsNetworkSub.unsubscribe();
|
||||
this._incomingZapsNetworkSub = null;
|
||||
}
|
||||
|
||||
// 1. Populate the store from the local Nostr IDB cache (instant)
|
||||
try {
|
||||
await this._cachePromise;
|
||||
|
||||
const cachedEvents = await this.cache.query(filters);
|
||||
|
||||
if (cachedEvents && cachedEvents.length > 0) {
|
||||
for (const event of cachedEvents) {
|
||||
this.store.add(event);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
'[nostr-data] Failed to read incoming zap receipts from local Nostr IDB cache',
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Request fresh events from the network in the background
|
||||
this._incomingZapsNetworkSub = this._requestContentWithProvenance(
|
||||
this.activeReadRelays,
|
||||
filters,
|
||||
'[nostr-data] Error fetching incoming zap receipts:'
|
||||
);
|
||||
}
|
||||
|
||||
loadProfiles(pubkeys) {
|
||||
const newPubkeys = pubkeys.filter(
|
||||
(pk) => pk && !this._profileModelSubs.has(pk)
|
||||
@@ -843,6 +892,10 @@ export default class NostrDataService extends Service {
|
||||
}
|
||||
this._cleanupZapReceiptSubs();
|
||||
this._clearZapRefreshTimer();
|
||||
if (this._incomingZapsNetworkSub) {
|
||||
this._incomingZapsNetworkSub.unsubscribe();
|
||||
this._incomingZapsNetworkSub = null;
|
||||
}
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
|
||||
@@ -2660,3 +2660,142 @@ button.create-place {
|
||||
color: var(--body-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
/* Activity Timeline — zap activity list rendered in the sidebar */
|
||||
.activity-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: -1rem -1rem 0;
|
||||
}
|
||||
|
||||
.activity-zap-item {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
background: var(--primary-background-color);
|
||||
color: var(--body-text-color);
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
font-family: inherit;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
& .zap-sender-avatar {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
& .zap-sender-avatar-placeholder {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #e0e0e0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
& .zap-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
& .zap-sender-line {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.95rem;
|
||||
|
||||
& .zap-sender-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
& .zap-action {
|
||||
color: #666;
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
|
||||
& .zap-amount {
|
||||
flex-shrink: 0;
|
||||
font-weight: bold;
|
||||
font-size: 0.95rem;
|
||||
color: var(--body-text-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
& .zap-message {
|
||||
color: var(--body-text-color);
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
position: relative;
|
||||
background: var(--secondary-background-color);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-top: 4px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
left: 11px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: var(--secondary-background-color);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
|
||||
& .zap-context {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #999;
|
||||
font-size: 0.8rem;
|
||||
margin-top: 8px;
|
||||
|
||||
& .zap-context-images {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
& .zap-context-thumb {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #f0f0f0;
|
||||
|
||||
& img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
& .zap-context-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,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