Subscribe to zap receipt when zapping, show success for external
payment
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
Reference in New Issue
Block a user