Merge pull request 'Zap the planet!' (#81) from feature/zaps into master
CI / Test (push) Failing after 6s
CI / Lint (push) Successful in 41s

Reviewed-on: #81
This commit was merged in pull request #81.
This commit is contained in:
2026-08-20 22:43:32 +00:00
12 changed files with 1533 additions and 24 deletions
-6
View File
@@ -20,13 +20,10 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
with:
version: 11
- name: Install Node - name: Install Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
- name: Install Dependencies - name: Install Dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Lint - name: Lint
@@ -42,9 +39,6 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
with:
version: 11
- name: Install Dependencies - name: Install Dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Run Tests - name: Run Tests
+13 -9
View File
@@ -30,6 +30,10 @@ export default class Modal extends Component {
return config.environment === 'test'; return config.environment === 'test';
} }
get shouldPortal() {
return !this.isTesting && !this.args.inline;
}
get destinationElement() { get destinationElement() {
return document.getElementById('modal-portal') || document.body; return document.getElementById('modal-portal') || document.body;
} }
@@ -48,15 +52,7 @@ export default class Modal extends Component {
} }
<template> <template>
{{#if this.isTesting}} {{#if this.shouldPortal}}
<ModalContent
@close={{this.close}}
@stopProp={{this.stopProp}}
@disableClose={{@disableClose}}
>
{{yield}}
</ModalContent>
{{else}}
{{#in-element this.destinationElement}} {{#in-element this.destinationElement}}
<ModalContent <ModalContent
@close={{this.close}} @close={{this.close}}
@@ -66,6 +62,14 @@ export default class Modal extends Component {
{{yield}} {{yield}}
</ModalContent> </ModalContent>
{{/in-element}} {{/in-element}}
{{else}}
<ModalContent
@close={{this.close}}
@stopProp={{this.stopProp}}
@disableClose={{@disableClose}}
>
{{yield}}
</ModalContent>
{{/if}} {{/if}}
</template> </template>
} }
+44 -1
View File
@@ -11,6 +11,7 @@ import or from 'ember-truth-helpers/helpers/or';
import config from 'marco/config/environment'; import config from 'marco/config/environment';
import DropdownMenu from './dropdown-menu'; import DropdownMenu from './dropdown-menu';
import PhotoCarousel from './photo-carousel'; import PhotoCarousel from './photo-carousel';
import ZapPhotoModal from './zap-photo-modal';
import Icon from './icon'; import Icon from './icon';
import formatRelativeDate from '../helpers/format-relative-date'; import formatRelativeDate from '../helpers/format-relative-date';
@@ -40,6 +41,13 @@ const GalleryContent = <template>
type="button" type="button"
{{on "click" (fn @copyEventId closeMenu)}} {{on "click" (fn @copyEventId closeMenu)}}
>Copy Photo Event ID</button> >Copy Photo Event ID</button>
{{#if @canZapPhoto}}
<button
class="dropdown-item"
type="button"
{{on "click" (fn @openZap closeMenu)}}
>Zap this photo</button>
{{/if}}
{{#if @canDeletePhoto}} {{#if @canDeletePhoto}}
<button <button
class="dropdown-item text-danger" class="dropdown-item text-danger"
@@ -94,6 +102,10 @@ const GalleryContent = <template>
/> />
</div> </div>
</div> </div>
{{#if @zapModalOpen}}
<ZapPhotoModal @photo={{@currentPhoto}} @onClose={{@closeZap}} />
{{/if}}
</div> </div>
</template>; </template>;
@@ -110,10 +122,12 @@ export default class PhotoGallery extends Component {
@service nostrAuth; @service nostrAuth;
@service nostrData; @service nostrData;
@service nostrRelay; @service nostrRelay;
@service nostrZap;
@service blossom; @service blossom;
@service settings; @service settings;
@tracked currentPhoto = this.args.selectedPhoto || this.args.photos?.[0]; @tracked currentPhoto = this.args.selectedPhoto || this.args.photos?.[0];
@tracked zapModalOpen = false;
get triggerIcon() { get triggerIcon() {
if (typeof window !== 'undefined' && window.innerWidth <= 768) { if (typeof window !== 'undefined' && window.innerWidth <= 768) {
@@ -136,6 +150,14 @@ export default class PhotoGallery extends Component {
); );
} }
get canZapPhoto() {
return (
!this.isCreator &&
this.nostrAuth.isConnected &&
!!this.nostrZap.getLightningAddress(this.currentPhoto?.pubkey)
);
}
get uploaderName() { get uploaderName() {
const pubkey = this.currentPhoto?.pubkey; const pubkey = this.currentPhoto?.pubkey;
if (!pubkey) return null; if (!pubkey) return null;
@@ -168,7 +190,8 @@ export default class PhotoGallery extends Component {
e.target.closest('.thumbnail-strip-container') || e.target.closest('.thumbnail-strip-container') ||
e.target.closest('.carousel-nav-btn') || e.target.closest('.carousel-nav-btn') ||
e.target.closest('.close-btn') || e.target.closest('.close-btn') ||
e.target.closest('.photo-gallery-header') e.target.closest('.photo-gallery-header') ||
e.target.closest('.modal-overlay')
) { ) {
return; return;
} }
@@ -224,6 +247,18 @@ export default class PhotoGallery extends Component {
closeMenu(); closeMenu();
} }
@action
openZap(closeMenu, e) {
e?.stopPropagation();
this.zapModalOpen = true;
if (closeMenu) closeMenu();
}
@action
closeZap() {
this.zapModalOpen = false;
}
deletePhotoTask = task(async (closeMenu) => { deletePhotoTask = task(async (closeMenu) => {
if ( if (
!confirm( !confirm(
@@ -308,6 +343,10 @@ export default class PhotoGallery extends Component {
@uploaderName={{this.uploaderName}} @uploaderName={{this.uploaderName}}
@photoDate={{this.photoDate}} @photoDate={{this.photoDate}}
@triggerIcon={{this.triggerIcon}} @triggerIcon={{this.triggerIcon}}
@openZap={{this.openZap}}
@closeZap={{this.closeZap}}
@zapModalOpen={{this.zapModalOpen}}
@canZapPhoto={{this.canZapPhoto}}
/> />
{{else}} {{else}}
{{#in-element this.destinationElement}} {{#in-element this.destinationElement}}
@@ -327,6 +366,10 @@ export default class PhotoGallery extends Component {
@uploaderName={{this.uploaderName}} @uploaderName={{this.uploaderName}}
@photoDate={{this.photoDate}} @photoDate={{this.photoDate}}
@triggerIcon={{this.triggerIcon}} @triggerIcon={{this.triggerIcon}}
@openZap={{this.openZap}}
@closeZap={{this.closeZap}}
@zapModalOpen={{this.zapModalOpen}}
@canZapPhoto={{this.canZapPhoto}}
/> />
{{/in-element}} {{/in-element}}
{{/if}} {{/if}}
+499
View File
@@ -0,0 +1,499 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
import { service } from '@ember/service';
import { task } from 'ember-concurrency';
import { eq } from 'ember-truth-helpers';
import qrCode from '../modifiers/qr-code';
import Icon from './icon';
import Modal from './modal';
const SLIDER_MIN_SATS = 10;
const SLIDER_MAX_SATS = 100_000;
const SLIDER_STEPS = 200;
function sliderToSats(position) {
const t = position / SLIDER_STEPS;
return Math.round(
Math.exp(
Math.log(SLIDER_MIN_SATS) +
(Math.log(SLIDER_MAX_SATS) - Math.log(SLIDER_MIN_SATS)) * t
)
);
}
function satsToSlider(sats) {
return Math.round(
((Math.log(sats) - Math.log(SLIDER_MIN_SATS)) /
(Math.log(SLIDER_MAX_SATS) - Math.log(SLIDER_MIN_SATS))) *
SLIDER_STEPS
);
}
const DEFAULT_SLIDER_POSITION = satsToSlider(100);
export default class ZapPhotoModal extends Component {
@service nostrZap;
@service nostrAuth;
@service nostrData;
@service toast;
@tracked step = 'select-amount';
@tracked sliderPosition = DEFAULT_SLIDER_POSITION;
@tracked message = '';
@tracked error = null;
@tracked invoice = null;
@tracked lightningAddress = null;
@tracked lnurlEndpoint = null;
@tracked lnurlLoading = false;
@tracked lnurlError = null;
@tracked paymentNotDetected = false;
sliderSteps = SLIDER_STEPS;
sliderTicks = ['10', '100', '1k', '10k', '100k'];
_receiptSub = null;
_receiptTimeout = null;
_zapRequest = null;
RECEIPT_TIMEOUT_MS = 5 * 60 * 1000;
constructor() {
super(...arguments);
this._loadLightningInfo();
}
get photo() {
return this.args.photo;
}
get hasWebLN() {
return this.nostrZap.hasWebLN();
}
get effectiveAmount() {
return sliderToSats(this.sliderPosition);
}
get amountMsats() {
return this.effectiveAmount * 1000;
}
get formattedAmount() {
return this.effectiveAmount.toLocaleString();
}
get cannotZap() {
return !this.canZap;
}
get canZap() {
if (!this.lnurlEndpoint) return false;
if (this.lnurlError) return false;
if (this.effectiveAmount <= 0) return false;
return (
this.amountMsats >= this.lnurlEndpoint.minSendable &&
this.amountMsats <= this.lnurlEndpoint.maxSendable
);
}
get minSats() {
return this.lnurlEndpoint
? Math.ceil(this.lnurlEndpoint.minSendable / 1000)
: 1;
}
get maxSats() {
return this.lnurlEndpoint
? Math.floor(this.lnurlEndpoint.maxSendable / 1000)
: SLIDER_MAX_SATS;
}
get lightningUri() {
return this.invoice ? `lightning:${this.invoice}` : '';
}
get parsedInvoice() {
if (!this.invoice) return null;
try {
return this.nostrZap.parseInvoice(this.invoice);
} catch {
return null;
}
}
async _loadLightningInfo() {
const pubkey = this.photo?.pubkey;
if (!pubkey) return;
// Pre-warm the recipient relay cache so the zap request
// uses the recipient's NIP-65 inbox relays (where they'll
// see the zap receipt) instead of the sender's relays
this.nostrZap.loadRecipientRelays(pubkey).catch(() => {});
const address = this.nostrZap.getLightningAddress(pubkey);
if (!address) {
this.lnurlError =
'This user has no lightning address set in their profile';
return;
}
this.lightningAddress = address;
this.lnurlLoading = true;
this.lnurlError = null;
try {
this.lnurlEndpoint = await this.nostrZap.resolveLnurl(address);
} catch (err) {
this.lnurlError =
err instanceof Error ? err.message : 'Failed to fetch lightning info';
} finally {
this.lnurlLoading = false;
}
}
@action
updateSlider(e) {
this.sliderPosition = Number(e.target.value);
}
@action
updateMessage(e) {
this.message = e.target.value;
}
@action
handleClose() {
if (this.args.onClose) {
this.args.onClose();
}
}
@action
resetAndClose() {
this._cleanupReceiptSub();
this.step = 'select-amount';
this.error = null;
this.invoice = null;
this.paymentNotDetected = false;
this.handleClose();
}
@action
retry() {
this.step = 'select-amount';
this.error = null;
}
@action
showInvoice() {
this.step = 'awaiting-payment';
this.error = null;
}
@action
performZap() {
this.zapTask.perform();
}
@action
performWebLNPay() {
this.webLNPayTask.perform();
}
@action
copyInvoice() {
if (!this.invoice) return;
try {
navigator.clipboard.writeText(this.invoice);
this.toast.show('Invoice copied to clipboard');
} catch {
// no-op
}
}
_startReceiptSubscription(zapRequest) {
this._cleanupReceiptSub();
this._zapRequest = zapRequest;
this.paymentNotDetected = false;
this._receiptSub = this.nostrZap.subscribeForZapReceipt(
zapRequest,
this.photo,
() => {
this.step = 'success';
this._cleanupReceiptSub();
}
);
this._receiptTimeout = setTimeout(() => {
this.paymentNotDetected = true;
}, this.RECEIPT_TIMEOUT_MS);
}
_cleanupReceiptSub() {
if (this._receiptSub) {
this._receiptSub.unsubscribe();
this._receiptSub = null;
}
if (this._receiptTimeout) {
clearTimeout(this._receiptTimeout);
this._receiptTimeout = null;
}
}
zapTask = task(async () => {
if (!this.canZap) return;
this.step = 'fetching-invoice';
this.error = null;
try {
const { invoice, zapRequest } = await this.nostrZap.zap(
this.photo,
this.amountMsats,
this.message
);
this.invoice = invoice;
this.step = 'awaiting-payment';
this._startReceiptSubscription(zapRequest);
} catch (err) {
console.error('Zap failed:', err);
this.error = err instanceof Error ? err.message : 'Failed to create zap';
this.step = 'error';
}
});
webLNPayTask = task(async () => {
if (!this.invoice) return;
this.step = 'paying-with-wallet';
this.error = null;
try {
await this.nostrZap.payWithWebln(this.invoice);
this._cleanupReceiptSub();
this.step = 'success';
} catch (err) {
console.error('WebLN pay failed:', err);
this.error =
err instanceof Error ? err.message : 'Failed to pay with wallet';
this.step = 'wallet-pay-error';
}
});
willDestroy() {
this._cleanupReceiptSub();
super.willDestroy(...arguments);
}
<template>
<Modal @inline={{true}} @onClose={{this.resetAndClose}}>
<div class="zap-photo-modal">
<h2>Zap Photo</h2>
{{! Lightning address status }}
<section class="status">
{{#if this.lnurlLoading}}
<p class="meta-info">Loading lightning address...</p>
{{else if this.lightningAddress}}
<div class="lightning-address">
<span class="lightning-badge">Lightning</span>
<span
class="lightning-address-text"
>{{this.lightningAddress}}</span>
</div>
{{/if}}
{{#if this.lnurlError}}
<div class="alert alert-error">{{this.lnurlError}}</div>
{{/if}}
</section>
{{! Step: Select Amount }}
{{#if (eq this.step "select-amount")}}
<section class="slider">
<div class="amount-display">
<span
class="amount-value zap-amount"
>{{this.formattedAmount}}</span>
<span class="amount-unit">sats</span>
</div>
<input
type="range"
class="zap-slider"
min="0"
max={{this.sliderSteps}}
value={{this.sliderPosition}}
aria-label="Zap amount in sats"
{{on "input" this.updateSlider}}
/>
<div class="slider-ticks">
{{#each this.sliderTicks as |label|}}
<span>{{label}}</span>
{{/each}}
</div>
</section>
<div class="form-group">
<label>Message (optional)</label>
<input
type="text"
class="form-control"
placeholder="Say something nice..."
aria-label="Zap message"
value={{this.message}}
{{on "input" this.updateMessage}}
/>
</div>
<div class="edit-actions">
<button
type="button"
class="btn btn-outline"
{{on "click" this.resetAndClose}}
>
Cancel
</button>
<button
type="button"
class="btn btn-primary"
disabled={{this.cannotZap}}
{{on "click" this.performZap}}
>
Zap
{{this.formattedAmount}}
sats
</button>
</div>
{{/if}}
{{! Step: Fetching Invoice }}
{{#if (eq this.step "fetching-invoice")}}
<div class="centered">
<Icon @name="loading-ring" @size={{32}} class="spin-animation" />
<p>Creating zap request and fetching invoice...</p>
</div>
{{/if}}
{{! Step: Awaiting Payment }}
{{#if (eq this.step "awaiting-payment")}}
<p class="zap-pay-prompt">
Scan to pay
<strong>{{this.formattedAmount}} sats</strong>
</p>
<div class="qr-code-container">
<canvas {{qrCode this.invoice}}></canvas>
</div>
{{#if this.hasWebLN}}
<button
type="button"
class="btn btn-primary btn-full webln-pay-btn"
{{on "click" this.performWebLNPay}}
>
Open in Lightning wallet
</button>
{{else}}
<a href={{this.lightningUri}} class="btn btn-primary btn-full">
Open in Lightning wallet
</a>
{{/if}}
<button
type="button"
class="btn btn-outline btn-full copy-invoice-btn"
{{on "click" this.copyInvoice}}
>
Copy payment request
</button>
{{#if this.paymentNotDetected}}
<p class="meta-info">
Payment not detected yet. Keep the QR code open and try paying
again, or use a different Lightning wallet.
</p>
{{/if}}
{{/if}}
{{! Step: Paying with Wallet }}
{{#if (eq this.step "paying-with-wallet")}}
<div class="centered">
<Icon @name="loading-ring" @size={{32}} class="spin-animation" />
<p>Paying invoice with WebLN...</p>
</div>
{{/if}}
{{! Step: Wallet Pay Error }}
{{#if (eq this.step "wallet-pay-error")}}
<div class="alert alert-error">{{this.error}}</div>
<p class="meta-info">
The WebLN payment failed. You can still scan the QR code with a
Lightning wallet.
</p>
<div class="edit-actions">
<button
type="button"
class="btn btn-outline"
{{on "click" this.resetAndClose}}
>
Cancel
</button>
<button
type="button"
class="btn btn-primary"
{{on "click" this.showInvoice}}
>
Show Invoice
</button>
</div>
{{/if}}
{{! Step: Success }}
{{#if (eq this.step "success")}}
<div class="success zap-success">
<div class="success-icon">&#9889;</div>
<h4>Zap Sent!</h4>
<p>
You zapped
<strong>{{this.formattedAmount}} sats</strong>
{{#if this.message}}
with message: "{{this.message}}"
{{/if}}
</p>
<button
type="button"
class="btn btn-primary"
{{on "click" this.resetAndClose}}
>
Done
</button>
</div>
{{/if}}
{{! Step: Error }}
{{#if (eq this.step "error")}}
<div class="alert alert-error">{{this.error}}</div>
<div class="edit-actions">
<button
type="button"
class="btn btn-outline"
{{on "click" this.resetAndClose}}
>
Close
</button>
<button
type="button"
class="btn btn-primary"
{{on "click" this.retry}}
>
Try Again
</button>
</div>
{{/if}}
</div>
</Modal>
</template>
}
+119 -1
View File
@@ -38,6 +38,7 @@ export default class NostrDataService extends Service {
@tracked placePhotos = []; @tracked placePhotos = [];
@tracked myContributionEvents = []; @tracked myContributionEvents = [];
@tracked profiles = {}; @tracked profiles = {};
@tracked zapReceipts = {};
_profileSub = null; _profileSub = null;
_mailboxesSub = null; _mailboxesSub = null;
@@ -46,6 +47,11 @@ export default class NostrDataService extends Service {
_contributionsSub = null; _contributionsSub = null;
_profileModelSubs = new Map(); _profileModelSubs = new Map();
_zapReceiptsSub = null;
_zapReceiptsNetworkSub = null;
_zapRefreshTimer = null;
_lastPhotoIds = new Set();
_requestSub = null; _requestSub = null;
_cachePromise = null; _cachePromise = null;
_currentPlaceEntityId = null; _currentPlaceEntityId = null;
@@ -87,7 +93,8 @@ export default class NostrDataService extends Service {
e.kind === 5 || e.kind === 5 ||
e.kind === 10002 || e.kind === 10002 ||
e.kind === 10063 || e.kind === 10063 ||
e.kind === 360 e.kind === 360 ||
e.kind === 9735
); );
if (toCache.length > 0) { if (toCache.length > 0) {
@@ -247,7 +254,12 @@ export default class NostrDataService extends Service {
this._photosSub = null; this._photosSub = null;
} }
this._cleanupZapReceiptSubs();
this._clearZapRefreshTimer();
this.placePhotos = []; this.placePhotos = [];
this.zapReceipts = {};
this._lastPhotoIds = new Set();
this._clearProfileSubs(); this._clearProfileSubs();
this._currentPlaceEntityId = entityId; this._currentPlaceEntityId = entityId;
@@ -267,6 +279,7 @@ export default class NostrDataService extends Service {
this.placePhotos = events; this.placePhotos = events;
const pubkeys = [...new Set(events.map((e) => e.pubkey))]; const pubkeys = [...new Set(events.map((e) => e.pubkey))];
this.loadProfiles(pubkeys); this.loadProfiles(pubkeys);
this._scheduleZapReceiptRefresh(events);
}); });
try { try {
@@ -500,6 +513,109 @@ export default class NostrDataService extends Service {
} }
} }
_scheduleZapReceiptRefresh(events) {
const newIds = new Set(events.map((e) => e.id));
let changed = false;
if (newIds.size !== this._lastPhotoIds.size) {
changed = true;
} else {
for (const id of newIds) {
if (!this._lastPhotoIds.has(id)) {
changed = true;
break;
}
}
}
if (!changed) return;
this._lastPhotoIds = newIds;
this._clearZapRefreshTimer();
this._zapRefreshTimer = setTimeout(() => {
this._zapRefreshTimer = null;
this._refreshZapReceiptSubscription([...newIds]);
}, 100);
}
_clearZapRefreshTimer() {
if (this._zapRefreshTimer) {
clearTimeout(this._zapRefreshTimer);
this._zapRefreshTimer = null;
}
}
_refreshZapReceiptSubscription(photoIds) {
this._cleanupZapReceiptSubs();
if (!photoIds || photoIds.length === 0) return;
// Batch IDs into filters of <=100 to stay under relay REQ limits
const BATCH_SIZE = 100;
const filters = [];
for (let i = 0; i < photoIds.length; i += BATCH_SIZE) {
filters.push({
kinds: [9735],
'#e': photoIds.slice(i, i + BATCH_SIZE),
});
}
// Reactive local query — emits whenever matching events are in the store
this._zapReceiptsSub = this.store.timeline(filters).subscribe((events) => {
this._updateZapReceipts(events);
});
// Load from IDB cache (fire-and-forget — store.add triggers timeline)
this._cachePromise
.then(() => this.cache.query(filters))
.then((cachedEvents) => {
if (cachedEvents && cachedEvents.length > 0) {
for (const event of cachedEvents) {
this.store.add(event);
}
}
})
.catch((e) => {
console.warn('[nostr-data] Failed to read zap receipts from cache', e);
});
// Network request (fire-and-forget)
this._zapReceiptsNetworkSub = this.nostrRelay.pool
.request(this.activeReadRelays, filters)
.subscribe({
next: (event) => {
this.store.add(event);
},
error: (err) => {
console.error('[nostr-data] Error fetching zap receipts:', err);
},
});
}
_updateZapReceipts(events) {
const grouped = {};
for (const receipt of events) {
for (const tag of receipt.tags) {
if (tag[0] === 'e' && tag[1]) {
if (!grouped[tag[1]]) grouped[tag[1]] = [];
grouped[tag[1]].push(receipt);
}
}
}
this.zapReceipts = { ...this.zapReceipts, ...grouped };
}
_cleanupZapReceiptSubs() {
if (this._zapReceiptsSub) {
this._zapReceiptsSub.unsubscribe();
this._zapReceiptsSub = null;
}
if (this._zapReceiptsNetworkSub) {
this._zapReceiptsNetworkSub.unsubscribe();
this._zapReceiptsNetworkSub = null;
}
}
_cleanupSubscriptions() { _cleanupSubscriptions() {
if (this._requestSub) { if (this._requestSub) {
this._requestSub.unsubscribe(); this._requestSub.unsubscribe();
@@ -525,6 +641,8 @@ export default class NostrDataService extends Service {
this._contributionsSub.unsubscribe(); this._contributionsSub.unsubscribe();
this._contributionsSub = null; this._contributionsSub = null;
} }
this._cleanupZapReceiptSubs();
this._clearZapRefreshTimer();
} }
willDestroy() { willDestroy() {
+244
View File
@@ -0,0 +1,244 @@
import Service, { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { ZapRequestFactory } from 'applesauce-common/factories';
import {
getInvoice,
parseBolt11,
parseLNURLOrAddress,
} from 'applesauce-common/helpers';
import { getInboxes } from 'applesauce-core/helpers/mailboxes';
import { firstValueFrom, timeout, catchError, of } from 'rxjs';
const DEFAULT_ZAP_RELAYS = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.primal.net',
];
const DIRECTORY_RELAYS = [
'wss://relay.primal.net',
'wss://nos.lol',
'wss://relay.damus.io',
];
const RELAY_FETCH_TIMEOUT = 10_000;
export default class NostrZapService extends Service {
@service nostrAuth;
@service nostrData;
@service nostrRelay;
@tracked recipientRelays = null;
getLightningAddress(pubkey) {
const profile = this.nostrData.getProfile(pubkey);
return profile?.lud16 || profile?.lud06 || null;
}
async resolveLnurl(address) {
const url = parseLNURLOrAddress(address);
if (!url) throw new Error('Invalid lightning address or LNURL');
// eslint-disable-next-line warp-drive/no-external-request-patterns
const res = await fetch(url.toString());
if (!res.ok) {
throw new Error(`Failed to fetch LNURL pay endpoint: ${res.statusText}`);
}
const data = await res.json();
if (!data.callback) {
throw new Error('Invalid LNURL pay endpoint: missing callback');
}
if (!data.allowsNostr) {
throw new Error('This lightning address does not support Nostr zaps');
}
return {
callback: data.callback,
minSendable: data.minSendable ?? 1000,
maxSendable: data.maxSendable ?? 100_000_000_000,
allowsNostr: !!data.allowsNostr,
nostrPubkey: data.nostrPubkey,
};
}
async loadRecipientRelays(pubkey) {
if (!pubkey) return this._fallbackRelays();
// Check the store first — the event may already be cached
const fromStore = await this._getMailboxesFromStore(pubkey);
if (fromStore?.length) {
this.recipientRelays = fromStore;
return fromStore;
}
// Fetch from network if not in the store
const fetched = await this._fetchMailboxesFromNetwork(pubkey);
if (fetched?.length) {
this.recipientRelays = fetched;
return fetched;
}
// Fallback: sender's inbox relays + popular defaults
const fallback = this._fallbackRelays();
this.recipientRelays = fallback;
return fallback;
}
async _getMailboxesFromStore(pubkey) {
try {
const event = await firstValueFrom(
this.nostrData.store.replaceable(10002, pubkey).pipe(
timeout(500),
catchError(() => of(null))
)
);
if (event) return getInboxes(event);
} catch {
// Event not in store yet
}
return null;
}
async _fetchMailboxesFromNetwork(pubkey) {
const relays = this.nostrData.activeReadRelays?.length
? this.nostrData.activeReadRelays
: DIRECTORY_RELAYS;
try {
const event = await firstValueFrom(
this.nostrRelay.pool
.request(relays, [{ kinds: [10002], authors: [pubkey] }])
.pipe(
timeout(RELAY_FETCH_TIMEOUT),
catchError(() => of(null))
)
);
if (event) {
this.nostrData.store.add(event);
return getInboxes(event);
}
} catch {
// Network fetch failed
}
return null;
}
_fallbackRelays() {
const senderRelays = this.nostrData.mailboxReadRelays || [];
return [...new Set([...senderRelays, ...DEFAULT_ZAP_RELAYS])].slice(0, 5);
}
getZapRelays() {
if (this.recipientRelays?.length) {
return this.recipientRelays.slice(0, 5);
}
return this._fallbackRelays();
}
async createZapRequest(photo, amountMsats, message) {
const event = this.nostrData.store.getEvent(photo.eventId);
if (!event) {
throw new Error('Photo event not found in store');
}
const relays = this.getZapRelays();
const signer = this.nostrAuth.signer;
if (!signer) {
throw new Error('Nostr signer not available. Please connect Nostr.');
}
return await ZapRequestFactory.event(event, amountMsats, relays)
.message(message || '')
.as(signer)
.sign();
}
async fetchInvoice(callbackUrl, zapRequest, amountMsats) {
const url = new URL(callbackUrl);
url.searchParams.set('amount', amountMsats.toString());
url.searchParams.set('nostr', JSON.stringify(zapRequest));
return await getInvoice(url);
}
parseInvoice(invoice) {
return parseBolt11(invoice);
}
subscribeForZapReceipt(zapRequest, photo, onReceipt) {
const eventId = photo.eventId;
const relays = this.getZapRelays();
const since = Math.floor(Date.now() / 1000) - 10;
return this.nostrRelay.pool
.subscription(relays, {
kinds: [9735],
'#e': [eventId],
since,
})
.subscribe({
next: (event) => {
this.nostrData.store.add(event);
const descTag = event.tags.find((t) => t[0] === 'description');
if (!descTag) return;
try {
const req = JSON.parse(descTag[1]);
if (req.id === zapRequest.id) {
onReceipt(event);
}
} catch {
// Invalid JSON in description tag
}
},
error: (err) => {
console.error('[nostr-zap] Receipt subscription error:', err);
},
});
}
hasWebLN() {
return typeof window !== 'undefined' && typeof window.webln !== 'undefined';
}
async payWithWebln(invoice) {
if (!this.hasWebLN()) throw new Error('WebLN not available');
await window.webln.enable();
return await window.webln.sendPayment(invoice);
}
async zap(photo, amountMsats, message) {
const pubkey = photo?.pubkey;
if (!pubkey) throw new Error('Photo has no pubkey');
// Ensure recipient relays are loaded before building the zap request
if (!this.recipientRelays) {
await this.loadRecipientRelays(pubkey);
}
const address = this.getLightningAddress(pubkey);
if (!address) {
throw new Error(
'This user has no lightning address set in their profile'
);
}
const lnurl = await this.resolveLnurl(address);
if (amountMsats < lnurl.minSendable || amountMsats > lnurl.maxSendable) {
throw new Error(
`Amount must be between ${lnurl.minSendable} and ${lnurl.maxSendable} millisats`
);
}
const zapRequest = await this.createZapRequest(photo, amountMsats, message);
const invoice = await this.fetchInvoice(
lnurl.callback,
zapRequest,
amountMsats
);
return { invoice, zapRequest };
}
}
+146 -5
View File
@@ -2207,11 +2207,6 @@ button.create-place {
left: 0.5rem; left: 0.5rem;
} }
.photo-gallery-overlay .photo-gallery-header .actions-btn-container {
width: 48px;
justify-content: center;
}
.photo-gallery-overlay .photo-gallery-uploader-info { .photo-gallery-overlay .photo-gallery-uploader-info {
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
@@ -2431,3 +2426,149 @@ button.create-place {
padding: 2px 6px; padding: 2px 6px;
border-radius: 10px; border-radius: 10px;
} }
/* Generic modal heading reset (shared by all modals) */
.modal-content h2,
.modal-content h3 {
margin-top: 0;
}
/* Zap Photo Modal — scoped, nested styles */
.zap-photo-modal {
text-align: left;
& h2 {
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--divider-color);
}
& section.status {
padding-bottom: 0.75rem;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--divider-color);
}
& section.slider {
text-align: center;
margin-bottom: 1.5rem;
}
& .amount-display {
display: flex;
align-items: baseline;
justify-content: center;
gap: 0.4rem;
margin-bottom: 0.75rem;
}
& .amount-value {
font-size: 2rem;
font-weight: 700;
color: var(--default-list-color);
}
& .amount-unit {
font-size: 0.9rem;
color: var(--body-text-color);
opacity: 0.7;
}
& .zap-slider {
width: 100%;
accent-color: var(--default-list-color);
cursor: pointer;
}
& .slider-ticks {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
color: var(--body-text-color);
opacity: 0.5;
padding: 0 0.25rem;
}
& .form-group {
margin-bottom: 1.5rem;
}
& .lightning-address {
display: flex;
align-items: center;
gap: 0.5rem;
}
& .lightning-badge {
background: var(--default-list-color);
color: white;
font-size: 0.7rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
& .lightning-address-text {
font-family: monospace;
font-size: 0.9rem;
color: var(--body-text-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
& .zap-pay-prompt {
text-align: center;
}
& .qr-code-container {
margin-bottom: 1.5rem;
}
& .btn-full + .btn-full {
margin-top: 0.75rem;
}
& .alert-error {
font-size: 0.95rem;
}
& .meta-info {
font-size: 0.95rem;
}
& .centered {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 1rem;
padding-top: 1.5rem;
}
& .success {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.5rem;
}
& .success-icon {
font-size: 3rem;
}
& .success h4 {
margin: 0;
font-size: 1.2rem;
}
& .success p {
margin: 0;
margin-bottom: 1.5rem;
font-size: 0.9rem;
color: var(--body-text-color);
}
}
+3 -1
View File
@@ -104,6 +104,7 @@
"dependencies": { "dependencies": {
"@noble/hashes": "^2.3.0", "@noble/hashes": "^2.3.0",
"@waysidemapping/pinhead": "^15.25.0", "@waysidemapping/pinhead": "^15.25.0",
"applesauce-common": "^6.2.0",
"applesauce-core": "^6.2.0", "applesauce-core": "^6.2.0",
"applesauce-loaders": "^6.2.0", "applesauce-loaders": "^6.2.0",
"applesauce-relay": "^6.2.1", "applesauce-relay": "^6.2.1",
@@ -116,5 +117,6 @@
"oauth2-pkce": "^3.0.0", "oauth2-pkce": "^3.0.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"rxjs": "^7.8.2" "rxjs": "^7.8.2"
} },
"packageManager": "pnpm@11.22.0+sha512.1ff870c4c6133dfd88fb2afc46dd13d47f09c9794b438c6fdb47ca98caf3bc16381ee0be93a091b8e3824cf01f889f46d7d9e20910fb0be1ab0fb5baa80dd621"
} }
+25
View File
@@ -17,6 +17,9 @@ importers:
'@waysidemapping/pinhead': '@waysidemapping/pinhead':
specifier: ^15.25.0 specifier: ^15.25.0
version: 15.25.0 version: 15.25.0
applesauce-common:
specifier: ^6.2.0
version: 6.2.0(supports-color@10.2.2)(typescript@5.9.3)
applesauce-core: applesauce-core:
specifier: ^6.2.0 specifier: ^6.2.0
version: 6.2.0(supports-color@10.2.2)(typescript@5.9.3) version: 6.2.0(supports-color@10.2.2)(typescript@5.9.3)
@@ -2636,6 +2639,9 @@ packages:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
applesauce-common@6.2.0:
resolution: {integrity: sha512-meXbDCdqA1LKy1tr04BW7wMfnBBkHetLv+wlXYjAj5iR4Sji5KMxLFdAjUe6cSvrxrk9LxFcP+0P9BcZQxjpaA==}
applesauce-core@6.2.0: applesauce-core@6.2.0:
resolution: {integrity: sha512-O6AlVyzqcuIhTOIuexm6UWmx7mRIa2D98gJP7K7vFGf90YdtvSH78AsKQWZXJ0Pk/K14at+9zkwfCkFr7ghgNw==} resolution: {integrity: sha512-O6AlVyzqcuIhTOIuexm6UWmx7mRIa2D98gJP7K7vFGf90YdtvSH78AsKQWZXJ0Pk/K14at+9zkwfCkFr7ghgNw==}
@@ -4733,6 +4739,9 @@ packages:
lie@3.1.1: lie@3.1.1:
resolution: {integrity: sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==} resolution: {integrity: sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==}
light-bolt11-decoder@3.2.0:
resolution: {integrity: sha512-3QEofgiBOP4Ehs9BI+RkZdXZNtSys0nsJ6fyGeSiAGCBsMwHGUDS/JQlY/sTnWs91A2Nh0S9XXfA8Sy9g6QpuQ==}
lightningcss-android-arm64@1.33.0: lightningcss-android-arm64@1.33.0:
resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
@@ -9348,6 +9357,18 @@ snapshots:
normalize-path: 3.0.0 normalize-path: 3.0.0
picomatch: 2.3.1 picomatch: 2.3.1
applesauce-common@6.2.0(supports-color@10.2.2)(typescript@5.9.3):
dependencies:
'@scure/base': 2.2.0
applesauce-core: 6.2.0(supports-color@10.2.2)(typescript@5.9.3)
hash-sum: 2.0.0
light-bolt11-decoder: 3.2.0
nanoid: 5.1.9
rxjs: 7.8.2
transitivePeerDependencies:
- supports-color
- typescript
applesauce-core@6.2.0(supports-color@10.2.2)(typescript@5.9.3): applesauce-core@6.2.0(supports-color@10.2.2)(typescript@5.9.3):
dependencies: dependencies:
debug: 4.4.3(supports-color@10.2.2) debug: 4.4.3(supports-color@10.2.2)
@@ -11974,6 +11995,10 @@ snapshots:
dependencies: dependencies:
immediate: 3.0.6 immediate: 3.0.6
light-bolt11-decoder@3.2.0:
dependencies:
'@scure/base': 1.1.1
lightningcss-android-arm64@1.33.0: lightningcss-android-arm64@1.33.0:
optional: true optional: true
+94 -1
View File
@@ -9,7 +9,12 @@ export class MockNostrAuthService extends Service {
@tracked connectUri = null; @tracked connectUri = null;
get isConnected() { get isConnected() {
return false; return (
!!this.pubkey &&
(this.signerType === 'extension'
? typeof window !== 'undefined' && typeof window.nostr !== 'undefined'
: true)
);
} }
get isMobile() { get isMobile() {
@@ -37,9 +42,20 @@ export class MockNostrDataService extends Service {
@tracked blossomServers = []; @tracked blossomServers = [];
@tracked placePhotos = []; @tracked placePhotos = [];
@tracked profiles = {}; @tracked profiles = {};
@tracked zapReceipts = {};
store = { store = {
add: () => {}, add: () => {},
timeline: () => ({
subscribe: () => ({
unsubscribe: () => {},
}),
}),
replaceable: () => ({
subscribe: () => ({
unsubscribe: () => {},
}),
}),
}; };
getProfile(pubkey) { getProfile(pubkey) {
@@ -100,10 +116,87 @@ export class MockNostrRelayService extends Service {
} }
} }
export class MockNostrZapService extends Service {
@tracked _lightningAddress = 'user@example.com';
@tracked _endpoint = {
callback: 'https://example.com/callback',
minSendable: 1000,
maxSendable: 100_000_000,
allowsNostr: true,
nostrPubkey: 'c'.repeat(64),
};
@tracked _webLNAvailable = false;
@tracked _zapResult = {
invoice: 'lnbc1u1pjtestinvoice1234567890',
zapRequest: { id: 'zap-req-1' },
};
@tracked _parsedInvoice = {
amount: 1_000_000,
expiry: 9_999_999_999,
description: 'test zap',
};
@tracked recipientRelays = null;
getLightningAddress() {
return this._lightningAddress;
}
async resolveLnurl() {
if (!this._endpoint.allowsNostr) {
throw new Error('This lightning address does not support Nostr zaps');
}
return this._endpoint;
}
async loadRecipientRelays() {
this.recipientRelays = ['wss://relay.test'];
return this.recipientRelays;
}
getZapRelays() {
return (
this.recipientRelays || [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.primal.net',
]
);
}
hasWebLN() {
return this._webLNAvailable;
}
subscribeForZapReceipt(zapRequest, photo, onReceipt) {
this._receiptCallback = onReceipt;
this._receiptZapRequest = zapRequest;
return {
unsubscribe: () => {
this._receiptCallback = null;
this._receiptZapRequest = null;
},
};
}
async zap() {
return this._zapResult;
}
parseInvoice() {
return this._parsedInvoice;
}
async payWithWebln() {
return {};
}
}
export function setupNostrMocks(hooks) { export function setupNostrMocks(hooks) {
hooks.beforeEach(function () { hooks.beforeEach(function () {
this.owner.register('service:nostrAuth', MockNostrAuthService); this.owner.register('service:nostrAuth', MockNostrAuthService);
this.owner.register('service:nostrData', MockNostrDataService); this.owner.register('service:nostrData', MockNostrDataService);
this.owner.register('service:nostrRelay', MockNostrRelayService); this.owner.register('service:nostrRelay', MockNostrRelayService);
this.owner.register('service:nostrZap', MockNostrZapService);
}); });
} }
@@ -478,4 +478,64 @@ module('Integration | Component | photo-gallery', function (hooks) {
.dom('.photo-gallery-uploader-name') .dom('.photo-gallery-uploader-name')
.hasText('Bob', 'falls back to display_name when displayName is missing'); .hasText('Bob', 'falls back to display_name when displayName is missing');
}); });
test('it shows Zap this photo in the dropdown', async function (assert) {
this.nostrAuth.pubkey = USER_B;
this.nostrAuth.signerType = 'connect';
this.selectedPhoto = this.photos[0];
await render(
<template>
<div id="test-container">
<div id="modal-portal"></div>
<PhotoGallery
@photos={{this.photos}}
@selectedPhoto={{this.selectedPhoto}}
/>
</div>
</template>
);
await click('.dropdown-trigger-btn');
let zapBtn;
document.querySelectorAll('.dropdown-item').forEach((item) => {
if (item.textContent.includes('Zap this photo')) {
zapBtn = item;
}
});
assert.ok(zapBtn, 'Zap this photo dropdown item exists');
});
test('it opens zap modal when Zap this photo is clicked', async function (assert) {
this.nostrAuth.pubkey = USER_B;
this.nostrAuth.signerType = 'connect';
this.selectedPhoto = this.photos[0];
await render(
<template>
<div id="test-container">
<div id="modal-portal"></div>
<PhotoGallery
@photos={{this.photos}}
@selectedPhoto={{this.selectedPhoto}}
/>
</div>
</template>
);
await click('.dropdown-trigger-btn');
let zapBtn;
document.querySelectorAll('.dropdown-item').forEach((item) => {
if (item.textContent.includes('Zap this photo')) {
zapBtn = item;
}
});
await click(zapBtn);
assert.dom('.zap-photo-modal').exists('zap modal is rendered');
});
}); });
@@ -0,0 +1,286 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'marco/tests/helpers';
import { render, click, fillIn, waitFor } from '@ember/test-helpers';
import Service from '@ember/service';
import ZapPhotoModal from 'marco/components/zap-photo-modal';
import { setupNostrMocks } from 'marco/tests/helpers/mock-nostr';
import sinon from 'sinon';
const USER_A = 'a'.repeat(64);
class MockToastService extends Service {
show() {}
}
module('Integration | Component | zap-photo-modal', function (hooks) {
setupRenderingTest(hooks);
setupNostrMocks(hooks);
hooks.beforeEach(function () {
this.owner.register('service:toast', MockToastService);
this.nostrZap = this.owner.lookup('service:nostrZap');
this.toast = this.owner.lookup('service:toast');
this.photo = {
eventId: 'event1',
pubkey: USER_A,
url: 'https://example.com/photo.jpg',
};
});
hooks.afterEach(function () {
sinon.restore();
});
test('it renders the amount slider when lightning address is available', async function (assert) {
await render(
<template>
<div id="modal-portal"></div>
<ZapPhotoModal @photo={{this.photo}} />
</template>
);
assert.dom('.zap-slider').exists('slider is rendered');
assert.dom('.zap-amount').exists('amount is displayed');
assert
.dom('.lightning-address-text')
.hasText('user@example.com', 'lightning address is shown');
assert.dom('.lightning-badge').hasText('Lightning', 'badge is shown');
});
test('it shows error when user has no lightning address', async function (assert) {
this.nostrZap._lightningAddress = null;
await render(
<template>
<div id="modal-portal"></div>
<ZapPhotoModal @photo={{this.photo}} />
</template>
);
assert
.dom('.alert-error')
.exists('error message is shown when no lightning address');
assert
.dom('.edit-actions .btn-primary')
.isDisabled('zap button is disabled when no address');
});
test('it shows error when LNURL does not support Nostr zaps', async function (assert) {
this.nostrZap._endpoint = {
...this.nostrZap._endpoint,
allowsNostr: false,
};
await render(
<template>
<div id="modal-portal"></div>
<ZapPhotoModal @photo={{this.photo}} />
</template>
);
assert.dom('.alert-error').exists('error shown when allowsNostr is false');
assert
.dom('.edit-actions .btn-primary')
.isDisabled('zap button is disabled');
});
test('it performs zap flow when Zap button is clicked', async function (assert) {
const zapSpy = sinon.spy(this.nostrZap, 'zap');
let closed = false;
this.handleClose = () => {
closed = true;
};
await render(
<template>
<div id="modal-portal"></div>
<ZapPhotoModal @photo={{this.photo}} @onClose={{this.handleClose}} />
</template>
);
assert
.dom('.edit-actions .btn-primary')
.isNotDisabled('zap button is enabled when endpoint is valid');
await click('.edit-actions .btn-primary');
assert.ok(zapSpy.calledOnce, 'nostrZap.zap was called');
assert.deepEqual(
zapSpy.firstCall.args[0],
this.photo,
'zap was called with the photo'
);
assert.strictEqual(
zapSpy.firstCall.args[1],
100_000,
'zap amount is 100 sats in millisats'
);
await waitFor('.qr-code-container');
assert.dom('.qr-code-container').exists('QR code is shown');
assert.dom('.zap-pay-prompt').exists('payment prompt is shown');
assert.notOk(closed, 'modal was not closed by clicking the Zap button');
});
test('it passes the message to the zap call', async function (assert) {
const zapSpy = sinon.spy(this.nostrZap, 'zap');
await render(
<template>
<div id="modal-portal"></div>
<ZapPhotoModal @photo={{this.photo}} />
</template>
);
await fillIn('.form-group input', 'Great photo!');
await click('.edit-actions .btn-primary');
assert.ok(zapSpy.calledOnce, 'nostrZap.zap was called');
assert.strictEqual(
zapSpy.firstCall.args[2],
'Great photo!',
'message is passed to zap'
);
});
test('it shows WebLN button when available', async function (assert) {
this.nostrZap._webLNAvailable = true;
await render(
<template>
<div id="modal-portal"></div>
<ZapPhotoModal @photo={{this.photo}} />
</template>
);
await click('.edit-actions .btn-primary');
await waitFor('.webln-pay-btn');
assert.dom('.webln-pay-btn').exists('WebLN button is shown when available');
});
test('it shows success after WebLN payment', async function (assert) {
this.nostrZap._webLNAvailable = true;
await render(
<template>
<div id="modal-portal"></div>
<ZapPhotoModal @photo={{this.photo}} />
</template>
);
await click('.edit-actions .btn-primary');
await waitFor('.webln-pay-btn');
await click('.webln-pay-btn');
await waitFor('.zap-success');
assert.dom('.zap-success').exists('success state is shown');
assert.dom('.zap-success h4').hasText('Zap Sent!', 'success title');
});
test('it shows success when a matching zap receipt is received', async function (assert) {
await render(
<template>
<div id="modal-portal"></div>
<ZapPhotoModal @photo={{this.photo}} />
</template>
);
await click('.edit-actions .btn-primary');
await waitFor('.qr-code-container');
assert.dom('.qr-code-container').exists('QR code is shown');
assert.ok(
this.nostrZap._receiptCallback,
'receipt subscription was started'
);
this.nostrZap._receiptCallback();
await waitFor('.zap-success');
assert.dom('.zap-success').exists('success state is shown after receipt');
assert.notOk(
this.nostrZap._receiptCallback,
'receipt subscription was cleaned up'
);
});
test('it calls onClose when close button is clicked', async function (assert) {
let closed = false;
this.handleClose = () => {
closed = true;
};
await render(
<template>
<div id="modal-portal"></div>
<ZapPhotoModal @photo={{this.photo}} @onClose={{this.handleClose}} />
</template>
);
await click('.close-modal-btn');
assert.ok(closed, 'onClose was called');
});
test('it calls onClose when Cancel button is clicked', async function (assert) {
let closed = false;
this.handleClose = () => {
closed = true;
};
await render(
<template>
<div id="modal-portal"></div>
<ZapPhotoModal @photo={{this.photo}} @onClose={{this.handleClose}} />
</template>
);
await click('.edit-actions .btn-outline');
assert.ok(closed, 'onClose was called from Cancel button');
});
test('it does not close when clicking inside the modal body', async function (assert) {
let closed = false;
this.handleClose = () => {
closed = true;
};
await render(
<template>
<div id="modal-portal"></div>
<ZapPhotoModal @photo={{this.photo}} @onClose={{this.handleClose}} />
</template>
);
await click('.zap-photo-modal');
assert.notOk(closed, 'onClose was not called when clicking inside modal');
});
test('it closes when clicking the overlay background', async function (assert) {
let closed = false;
this.handleClose = () => {
closed = true;
};
await render(
<template>
<div id="modal-portal"></div>
<ZapPhotoModal @photo={{this.photo}} @onClose={{this.handleClose}} />
</template>
);
await click('.modal-overlay');
assert.ok(closed, 'onClose was called when clicking overlay');
});
});