Subscribe to zap receipt when zapping, show success for external

payment
This commit is contained in:
2026-08-20 12:24:54 -06:00
parent 644d6a2856
commit 2869ed6677
4 changed files with 138 additions and 1 deletions
+54 -1
View File
@@ -47,10 +47,17 @@ export default class ZapPhotoModal extends Component {
@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();
@@ -162,9 +169,11 @@ export default class ZapPhotoModal extends Component {
@action
resetAndClose() {
this._cleanupReceiptSub();
this.step = 'select-amount';
this.error = null;
this.invoice = null;
this.paymentNotDetected = false;
this.handleClose();
}
@@ -206,6 +215,36 @@ export default class ZapPhotoModal extends Component {
e.stopPropagation();
}
_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;
@@ -213,13 +252,14 @@ export default class ZapPhotoModal extends Component {
this.error = null;
try {
const { invoice } = await this.nostrZap.zap(
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';
@@ -235,6 +275,7 @@ export default class ZapPhotoModal extends Component {
try {
await this.nostrZap.payWithWebln(this.invoice);
this._cleanupReceiptSub();
this.step = 'success';
} catch (err) {
console.error('WebLN pay failed:', err);
@@ -244,6 +285,11 @@ export default class ZapPhotoModal extends Component {
}
});
willDestroy() {
this._cleanupReceiptSub();
super.willDestroy(...arguments);
}
<template>
{{! template-lint-disable no-invalid-interactive }}
<div
@@ -396,6 +442,13 @@ export default class ZapPhotoModal extends Component {
</button>
</div>
{{#if this.paymentNotDetected}}
<p class="zap-modal-hint">
Payment not detected yet. Keep the QR code open and try paying
again, or use a different Lightning wallet.
</p>
{{/if}}
<div class="zap-modal-actions">
<button
type="button"
+33
View File
@@ -165,6 +165,39 @@ export default class NostrZapService extends Service {
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';
}
+22
View File
@@ -37,9 +37,20 @@ export class MockNostrDataService extends Service {
@tracked blossomServers = [];
@tracked placePhotos = [];
@tracked profiles = {};
@tracked zapReceipts = {};
store = {
add: () => {},
timeline: () => ({
subscribe: () => ({
unsubscribe: () => {},
}),
}),
replaceable: () => ({
subscribe: () => ({
unsubscribe: () => {},
}),
}),
};
getProfile(pubkey) {
@@ -152,6 +163,17 @@ export class MockNostrZapService extends Service {
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;
}
@@ -185,6 +185,35 @@ module('Integration | Component | zap-photo-modal', function (hooks) {
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('.zap-modal-actions .btn-primary');
await waitFor('.zap-qr-container');
assert.dom('.zap-qr-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 = () => {