WIP Allow users to zap photos

Working, basic implementation of zaps for photos. No fetching or
rendering of receipts yet.
This commit is contained in:
2026-08-20 10:34:10 -06:00
parent 09aefa6e19
commit 644d6a2856
9 changed files with 1401 additions and 1 deletions
+66
View File
@@ -100,10 +100,76 @@ 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;
}
async zap() {
return this._zapResult;
}
parseInvoice() {
return this._parsedInvoice;
}
async payWithWebln() {
return {};
}
}
export function setupNostrMocks(hooks) {
hooks.beforeEach(function () {
this.owner.register('service:nostrAuth', MockNostrAuthService);
this.owner.register('service:nostrData', MockNostrDataService);
this.owner.register('service:nostrRelay', MockNostrRelayService);
this.owner.register('service:nostrZap', MockNostrZapService);
});
}
@@ -478,4 +478,62 @@ module('Integration | Component | photo-gallery', function (hooks) {
.dom('.photo-gallery-uploader-name')
.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_A;
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_A;
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,259 @@
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-value').exists('amount is displayed');
assert
.dom('.zap-modal-address-text')
.hasText('user@example.com', 'lightning address is shown');
assert.dom('.zap-modal-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('.zap-modal-error-msg')
.exists('error message is shown when no lightning address');
assert
.dom('.zap-modal-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('.zap-modal-error-msg')
.exists('error shown when allowsNostr is false');
assert
.dom('.zap-modal-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('.zap-modal-actions .btn-primary')
.isNotDisabled('zap button is enabled when endpoint is valid');
await click('.zap-modal-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],
1_000_000,
'zap amount is 1000 sats in millisats'
);
await waitFor('.zap-qr-container');
assert.dom('.zap-qr-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('.zap-message-field textarea', 'Great photo!');
await click('.zap-modal-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('.zap-modal-actions .btn-primary');
await waitFor('.zap-webln-btn');
assert.dom('.zap-webln-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('.zap-modal-actions .btn-primary');
await waitFor('.zap-webln-btn');
await click('.zap-webln-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 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('.zap-modal-close');
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('.zap-modal-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 does not close 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('.zap-photo-modal-overlay');
assert.notOk(closed, 'onClose was not called when clicking overlay');
});
});