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); } }