Zap the planet! #81
@@ -20,13 +20,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Lint
|
||||
@@ -42,9 +39,6 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Run Tests
|
||||
|
||||
@@ -30,6 +30,10 @@ export default class Modal extends Component {
|
||||
return config.environment === 'test';
|
||||
}
|
||||
|
||||
get shouldPortal() {
|
||||
return !this.isTesting && !this.args.inline;
|
||||
}
|
||||
|
||||
get destinationElement() {
|
||||
return document.getElementById('modal-portal') || document.body;
|
||||
}
|
||||
@@ -48,15 +52,7 @@ export default class Modal extends Component {
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.isTesting}}
|
||||
<ModalContent
|
||||
@close={{this.close}}
|
||||
@stopProp={{this.stopProp}}
|
||||
@disableClose={{@disableClose}}
|
||||
>
|
||||
{{yield}}
|
||||
</ModalContent>
|
||||
{{else}}
|
||||
{{#if this.shouldPortal}}
|
||||
{{#in-element this.destinationElement}}
|
||||
<ModalContent
|
||||
@close={{this.close}}
|
||||
@@ -66,6 +62,14 @@ export default class Modal extends Component {
|
||||
{{yield}}
|
||||
</ModalContent>
|
||||
{{/in-element}}
|
||||
{{else}}
|
||||
<ModalContent
|
||||
@close={{this.close}}
|
||||
@stopProp={{this.stopProp}}
|
||||
@disableClose={{@disableClose}}
|
||||
>
|
||||
{{yield}}
|
||||
</ModalContent>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import or from 'ember-truth-helpers/helpers/or';
|
||||
import config from 'marco/config/environment';
|
||||
import DropdownMenu from './dropdown-menu';
|
||||
import PhotoCarousel from './photo-carousel';
|
||||
import ZapPhotoModal from './zap-photo-modal';
|
||||
import Icon from './icon';
|
||||
import formatRelativeDate from '../helpers/format-relative-date';
|
||||
|
||||
@@ -40,6 +41,13 @@ const GalleryContent = <template>
|
||||
type="button"
|
||||
{{on "click" (fn @copyEventId closeMenu)}}
|
||||
>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}}
|
||||
<button
|
||||
class="dropdown-item text-danger"
|
||||
@@ -94,6 +102,10 @@ const GalleryContent = <template>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#if @zapModalOpen}}
|
||||
<ZapPhotoModal @photo={{@currentPhoto}} @onClose={{@closeZap}} />
|
||||
{{/if}}
|
||||
</div>
|
||||
</template>;
|
||||
|
||||
@@ -110,10 +122,12 @@ export default class PhotoGallery extends Component {
|
||||
@service nostrAuth;
|
||||
@service nostrData;
|
||||
@service nostrRelay;
|
||||
@service nostrZap;
|
||||
@service blossom;
|
||||
@service settings;
|
||||
|
||||
@tracked currentPhoto = this.args.selectedPhoto || this.args.photos?.[0];
|
||||
@tracked zapModalOpen = false;
|
||||
|
||||
get triggerIcon() {
|
||||
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() {
|
||||
const pubkey = this.currentPhoto?.pubkey;
|
||||
if (!pubkey) return null;
|
||||
@@ -168,7 +190,8 @@ export default class PhotoGallery extends Component {
|
||||
e.target.closest('.thumbnail-strip-container') ||
|
||||
e.target.closest('.carousel-nav-btn') ||
|
||||
e.target.closest('.close-btn') ||
|
||||
e.target.closest('.photo-gallery-header')
|
||||
e.target.closest('.photo-gallery-header') ||
|
||||
e.target.closest('.modal-overlay')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -224,6 +247,18 @@ export default class PhotoGallery extends Component {
|
||||
closeMenu();
|
||||
}
|
||||
|
||||
@action
|
||||
openZap(closeMenu, e) {
|
||||
e?.stopPropagation();
|
||||
this.zapModalOpen = true;
|
||||
if (closeMenu) closeMenu();
|
||||
}
|
||||
|
||||
@action
|
||||
closeZap() {
|
||||
this.zapModalOpen = false;
|
||||
}
|
||||
|
||||
deletePhotoTask = task(async (closeMenu) => {
|
||||
if (
|
||||
!confirm(
|
||||
@@ -308,6 +343,10 @@ export default class PhotoGallery extends Component {
|
||||
@uploaderName={{this.uploaderName}}
|
||||
@photoDate={{this.photoDate}}
|
||||
@triggerIcon={{this.triggerIcon}}
|
||||
@openZap={{this.openZap}}
|
||||
@closeZap={{this.closeZap}}
|
||||
@zapModalOpen={{this.zapModalOpen}}
|
||||
@canZapPhoto={{this.canZapPhoto}}
|
||||
/>
|
||||
{{else}}
|
||||
{{#in-element this.destinationElement}}
|
||||
@@ -327,6 +366,10 @@ export default class PhotoGallery extends Component {
|
||||
@uploaderName={{this.uploaderName}}
|
||||
@photoDate={{this.photoDate}}
|
||||
@triggerIcon={{this.triggerIcon}}
|
||||
@openZap={{this.openZap}}
|
||||
@closeZap={{this.closeZap}}
|
||||
@zapModalOpen={{this.zapModalOpen}}
|
||||
@canZapPhoto={{this.canZapPhoto}}
|
||||
/>
|
||||
{{/in-element}}
|
||||
{{/if}}
|
||||
|
||||
@@ -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">⚡</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
@@ -38,6 +38,7 @@ export default class NostrDataService extends Service {
|
||||
@tracked placePhotos = [];
|
||||
@tracked myContributionEvents = [];
|
||||
@tracked profiles = {};
|
||||
@tracked zapReceipts = {};
|
||||
|
||||
_profileSub = null;
|
||||
_mailboxesSub = null;
|
||||
@@ -46,6 +47,11 @@ export default class NostrDataService extends Service {
|
||||
_contributionsSub = null;
|
||||
_profileModelSubs = new Map();
|
||||
|
||||
_zapReceiptsSub = null;
|
||||
_zapReceiptsNetworkSub = null;
|
||||
_zapRefreshTimer = null;
|
||||
_lastPhotoIds = new Set();
|
||||
|
||||
_requestSub = null;
|
||||
_cachePromise = null;
|
||||
_currentPlaceEntityId = null;
|
||||
@@ -87,7 +93,8 @@ export default class NostrDataService extends Service {
|
||||
e.kind === 5 ||
|
||||
e.kind === 10002 ||
|
||||
e.kind === 10063 ||
|
||||
e.kind === 360
|
||||
e.kind === 360 ||
|
||||
e.kind === 9735
|
||||
);
|
||||
|
||||
if (toCache.length > 0) {
|
||||
@@ -247,7 +254,12 @@ export default class NostrDataService extends Service {
|
||||
this._photosSub = null;
|
||||
}
|
||||
|
||||
this._cleanupZapReceiptSubs();
|
||||
this._clearZapRefreshTimer();
|
||||
|
||||
this.placePhotos = [];
|
||||
this.zapReceipts = {};
|
||||
this._lastPhotoIds = new Set();
|
||||
this._clearProfileSubs();
|
||||
this._currentPlaceEntityId = entityId;
|
||||
|
||||
@@ -267,6 +279,7 @@ export default class NostrDataService extends Service {
|
||||
this.placePhotos = events;
|
||||
const pubkeys = [...new Set(events.map((e) => e.pubkey))];
|
||||
this.loadProfiles(pubkeys);
|
||||
this._scheduleZapReceiptRefresh(events);
|
||||
});
|
||||
|
||||
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() {
|
||||
if (this._requestSub) {
|
||||
this._requestSub.unsubscribe();
|
||||
@@ -525,6 +641,8 @@ export default class NostrDataService extends Service {
|
||||
this._contributionsSub.unsubscribe();
|
||||
this._contributionsSub = null;
|
||||
}
|
||||
this._cleanupZapReceiptSubs();
|
||||
this._clearZapRefreshTimer();
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
|
||||
@@ -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
@@ -2207,11 +2207,6 @@ button.create-place {
|
||||
left: 0.5rem;
|
||||
}
|
||||
|
||||
.photo-gallery-overlay .photo-gallery-header .actions-btn-container {
|
||||
width: 48px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.photo-gallery-overlay .photo-gallery-uploader-info {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
@@ -2431,3 +2426,149 @@ button.create-place {
|
||||
padding: 2px 6px;
|
||||
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
@@ -104,6 +104,7 @@
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.3.0",
|
||||
"@waysidemapping/pinhead": "^15.25.0",
|
||||
"applesauce-common": "^6.2.0",
|
||||
"applesauce-core": "^6.2.0",
|
||||
"applesauce-loaders": "^6.2.0",
|
||||
"applesauce-relay": "^6.2.1",
|
||||
@@ -116,5 +117,6 @@
|
||||
"oauth2-pkce": "^3.0.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"rxjs": "^7.8.2"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@11.22.0+sha512.1ff870c4c6133dfd88fb2afc46dd13d47f09c9794b438c6fdb47ca98caf3bc16381ee0be93a091b8e3824cf01f889f46d7d9e20910fb0be1ab0fb5baa80dd621"
|
||||
}
|
||||
|
||||
Generated
+25
@@ -17,6 +17,9 @@ importers:
|
||||
'@waysidemapping/pinhead':
|
||||
specifier: ^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:
|
||||
specifier: ^6.2.0
|
||||
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==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
applesauce-common@6.2.0:
|
||||
resolution: {integrity: sha512-meXbDCdqA1LKy1tr04BW7wMfnBBkHetLv+wlXYjAj5iR4Sji5KMxLFdAjUe6cSvrxrk9LxFcP+0P9BcZQxjpaA==}
|
||||
|
||||
applesauce-core@6.2.0:
|
||||
resolution: {integrity: sha512-O6AlVyzqcuIhTOIuexm6UWmx7mRIa2D98gJP7K7vFGf90YdtvSH78AsKQWZXJ0Pk/K14at+9zkwfCkFr7ghgNw==}
|
||||
|
||||
@@ -4733,6 +4739,9 @@ packages:
|
||||
lie@3.1.1:
|
||||
resolution: {integrity: sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==}
|
||||
|
||||
light-bolt11-decoder@3.2.0:
|
||||
resolution: {integrity: sha512-3QEofgiBOP4Ehs9BI+RkZdXZNtSys0nsJ6fyGeSiAGCBsMwHGUDS/JQlY/sTnWs91A2Nh0S9XXfA8Sy9g6QpuQ==}
|
||||
|
||||
lightningcss-android-arm64@1.33.0:
|
||||
resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -9348,6 +9357,18 @@ snapshots:
|
||||
normalize-path: 3.0.0
|
||||
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):
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
@@ -11974,6 +11995,10 @@ snapshots:
|
||||
dependencies:
|
||||
immediate: 3.0.6
|
||||
|
||||
light-bolt11-decoder@3.2.0:
|
||||
dependencies:
|
||||
'@scure/base': 1.1.1
|
||||
|
||||
lightningcss-android-arm64@1.33.0:
|
||||
optional: true
|
||||
|
||||
|
||||
@@ -9,7 +9,12 @@ export class MockNostrAuthService extends Service {
|
||||
@tracked connectUri = null;
|
||||
|
||||
get isConnected() {
|
||||
return false;
|
||||
return (
|
||||
!!this.pubkey &&
|
||||
(this.signerType === 'extension'
|
||||
? typeof window !== 'undefined' && typeof window.nostr !== 'undefined'
|
||||
: true)
|
||||
);
|
||||
}
|
||||
|
||||
get isMobile() {
|
||||
@@ -37,9 +42,20 @@ export class MockNostrDataService extends Service {
|
||||
@tracked blossomServers = [];
|
||||
@tracked placePhotos = [];
|
||||
@tracked profiles = {};
|
||||
@tracked zapReceipts = {};
|
||||
|
||||
store = {
|
||||
add: () => {},
|
||||
timeline: () => ({
|
||||
subscribe: () => ({
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
}),
|
||||
replaceable: () => ({
|
||||
subscribe: () => ({
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
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) {
|
||||
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,64 @@ 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_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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user