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
+31 -1
View File
@@ -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,11 @@ const GalleryContent = <template>
type="button"
{{on "click" (fn @copyEventId closeMenu)}}
>Copy Photo Event ID</button>
<button
class="dropdown-item"
type="button"
{{on "click" (fn @openZap closeMenu)}}
>Zap this photo</button>
{{#if @canDeletePhoto}}
<button
class="dropdown-item text-danger"
@@ -94,6 +100,10 @@ const GalleryContent = <template>
/>
</div>
</div>
{{#if @zapModalOpen}}
<ZapPhotoModal @photo={{@currentPhoto}} @onClose={{@closeZap}} />
{{/if}}
</div>
</template>;
@@ -114,6 +124,7 @@ export default class PhotoGallery extends Component {
@service settings;
@tracked currentPhoto = this.args.selectedPhoto || this.args.photos?.[0];
@tracked zapModalOpen = false;
get triggerIcon() {
if (typeof window !== 'undefined' && window.innerWidth <= 768) {
@@ -168,7 +179,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('.zap-photo-modal-overlay')
) {
return;
}
@@ -224,6 +236,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 +332,9 @@ export default class PhotoGallery extends Component {
@uploaderName={{this.uploaderName}}
@photoDate={{this.photoDate}}
@triggerIcon={{this.triggerIcon}}
@openZap={{this.openZap}}
@closeZap={{this.closeZap}}
@zapModalOpen={{this.zapModalOpen}}
/>
{{else}}
{{#in-element this.destinationElement}}
@@ -327,6 +354,9 @@ export default class PhotoGallery extends Component {
@uploaderName={{this.uploaderName}}
@photoDate={{this.photoDate}}
@triggerIcon={{this.triggerIcon}}
@openZap={{this.openZap}}
@closeZap={{this.closeZap}}
@zapModalOpen={{this.zapModalOpen}}
/>
{{/in-element}}
{{/if}}
+493
View File
@@ -0,0 +1,493 @@
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';
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(1000);
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;
sliderSteps = SLIDER_STEPS;
sliderTicks = ['10', '100', '1k', '10k', '100k'];
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.step = 'select-amount';
this.error = null;
this.invoice = null;
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
}
}
@action
stopPropagation(e) {
e.stopPropagation();
}
zapTask = task(async () => {
if (!this.canZap) return;
this.step = 'fetching-invoice';
this.error = null;
try {
const { invoice } = await this.nostrZap.zap(
this.photo,
this.amountMsats,
this.message
);
this.invoice = invoice;
this.step = 'awaiting-payment';
} 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.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';
}
});
<template>
{{! template-lint-disable no-invalid-interactive }}
<div
class="zap-photo-modal-overlay"
role="dialog"
aria-modal="true"
{{on "click" this.stopPropagation}}
>
<div class="zap-photo-modal">
<div class="zap-photo-modal-header">
<h3>Zap Photo</h3>
<button
type="button"
class="btn-text zap-modal-close"
{{on "click" this.resetAndClose}}
aria-label="Close"
>
<Icon @name="x" @size={{20}} />
</button>
</div>
{{! Lightning address status }}
<div class="zap-modal-status">
{{#if this.lnurlLoading}}
<div class="zap-modal-loading">Loading lightning address...</div>
{{else if this.lightningAddress}}
<div class="zap-modal-address">
<span class="zap-modal-badge">Lightning</span>
<span
class="zap-modal-address-text"
>{{this.lightningAddress}}</span>
</div>
{{/if}}
{{#if this.lnurlError}}
<div class="zap-modal-error-msg">{{this.lnurlError}}</div>
{{/if}}
</div>
{{! Step: Select Amount }}
{{#if (eq this.step "select-amount")}}
<div class="zap-modal-body">
<div class="zap-amount-display">
<span class="zap-amount-value">{{this.formattedAmount}}</span>
<span class="zap-amount-unit">sats</span>
</div>
<input
type="range"
min="0"
max={{this.sliderSteps}}
value={{this.sliderPosition}}
aria-label="Zap amount in sats"
{{on "input" this.updateSlider}}
class="zap-slider"
/>
<div class="zap-slider-ticks">
{{#each this.sliderTicks as |label|}}
<span class="zap-tick">{{label}}</span>
{{/each}}
</div>
{{#if this.lnurlEndpoint}}
<div class="zap-min-max">
Min:
{{this.minSats}}
sats · Max:
{{this.maxSats}}
sats
</div>
{{/if}}
<div class="zap-message-field">
<label>Message (optional)</label>
<textarea
rows="2"
placeholder="Say something nice..."
aria-label="Zap message"
value={{this.message}}
{{on "input" this.updateMessage}}
></textarea>
</div>
<div class="zap-modal-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>
</div>
{{/if}}
{{! Step: Fetching Invoice }}
{{#if (eq this.step "fetching-invoice")}}
<div class="zap-modal-body zap-modal-center">
<div class="zap-spinner"></div>
<p>Creating zap request and fetching invoice...</p>
</div>
{{/if}}
{{! Step: Awaiting Payment }}
{{#if (eq this.step "awaiting-payment")}}
<div class="zap-modal-body">
<p class="zap-pay-prompt">
Scan to pay
<strong>{{this.formattedAmount}} sats</strong>
</p>
<div class="zap-qr-container">
<canvas {{qrCode this.invoice}}></canvas>
</div>
<a
href={{this.lightningUri}}
class="btn btn-outline zap-open-wallet-link"
>
Open in Lightning wallet
</a>
{{#if this.hasWebLN}}
<button
type="button"
class="btn btn-primary zap-webln-btn"
{{on "click" this.performWebLNPay}}
>
Pay with WebLN
</button>
{{/if}}
<div class="zap-invoice-details">
<button
type="button"
class="btn-text"
{{on "click" this.copyInvoice}}
>
Copy invoice
</button>
</div>
<div class="zap-modal-actions">
<button
type="button"
class="btn btn-outline"
{{on "click" this.resetAndClose}}
>
Cancel
</button>
</div>
</div>
{{/if}}
{{! Step: Paying with Wallet }}
{{#if (eq this.step "paying-with-wallet")}}
<div class="zap-modal-body zap-modal-center">
<div class="zap-spinner"></div>
<p>Paying invoice with WebLN...</p>
</div>
{{/if}}
{{! Step: Wallet Pay Error }}
{{#if (eq this.step "wallet-pay-error")}}
<div class="zap-modal-body">
<div class="zap-modal-error-msg">{{this.error}}</div>
<p class="zap-modal-hint">
The WebLN payment failed. You can still scan the QR code with a
Lightning wallet.
</p>
<div class="zap-modal-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>
</div>
{{/if}}
{{! Step: Success }}
{{#if (eq this.step "success")}}
<div class="zap-modal-body zap-modal-center zap-success">
<div class="zap-success-icon">&#9889;</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="zap-modal-body">
<div class="zap-modal-error-msg">{{this.error}}</div>
<div class="zap-modal-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>
</div>
{{/if}}
</div>
</div>
</template>
}
+211
View File
@@ -0,0 +1,211 @@
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);
}
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 };
}
}
+257
View File
@@ -2431,3 +2431,260 @@ button.create-place {
padding: 2px 6px;
border-radius: 10px;
}
/* Zap Photo Modal */
.zap-photo-modal-overlay {
position: fixed;
inset: 0;
background: rgb(0 0 0 / 70%);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.zap-photo-modal {
background: white;
border-radius: 12px;
box-shadow: 0 8px 32px rgb(0 0 0 / 30%);
width: 100%;
max-width: 420px;
max-height: 90vh;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.zap-photo-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--divider-color);
}
.zap-photo-modal-header h3 {
margin: 0;
}
.zap-modal-close {
background: none;
border: none;
padding: 0.25rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #898989;
}
.zap-modal-close:hover {
color: var(--body-text-color);
}
.zap-modal-status {
padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--divider-color);
min-height: 2.5rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.zap-modal-loading {
font-size: 0.85rem;
color: #898989;
}
.zap-modal-address {
display: flex;
align-items: center;
gap: 0.5rem;
}
.zap-modal-badge {
background: #f7b500;
color: white;
font-size: 0.7rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.zap-modal-address-text {
font-family: monospace;
font-size: 0.8rem;
color: #555;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.zap-modal-error-msg {
color: var(--danger-color);
font-size: 0.85rem;
padding: 0.5rem 0.75rem;
background: rgb(234 67 53 / 8%);
border-radius: 4px;
}
.zap-modal-body {
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.zap-modal-center {
align-items: center;
text-align: center;
}
.zap-amount-display {
display: flex;
align-items: baseline;
justify-content: center;
gap: 0.4rem;
}
.zap-amount-value {
font-size: 2rem;
font-weight: 700;
color: #f7b500;
}
.zap-amount-unit {
font-size: 0.9rem;
color: #898989;
}
.zap-slider {
width: 100%;
accent-color: #f7b500;
cursor: pointer;
}
.zap-slider-ticks {
display: flex;
justify-content: space-between;
font-size: 0.7rem;
color: #aaa;
padding: 0 0.25rem;
}
.zap-min-max {
font-size: 0.75rem;
color: #898989;
text-align: center;
}
.zap-message-field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.zap-message-field label {
font-size: 0.8rem;
color: #898989;
}
.zap-message-field textarea {
width: 100%;
border: 1px solid #ddd;
border-radius: 4px;
padding: 0.5rem;
font-family: inherit;
font-size: 0.9rem;
resize: vertical;
}
.zap-message-field textarea:focus {
outline: none;
border-color: var(--link-color);
}
.zap-modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.zap-modal-hint {
font-size: 0.8rem;
color: #898989;
text-align: center;
}
.zap-spinner {
width: 32px;
height: 32px;
border: 3px solid #eee;
border-top-color: #f7b500;
border-radius: 50%;
animation: zap-spin 0.8s linear infinite;
}
@keyframes zap-spin {
to {
transform: rotate(360deg);
}
}
.zap-pay-prompt {
text-align: center;
font-size: 0.9rem;
}
.zap-qr-container {
display: flex;
justify-content: center;
}
.zap-qr-container canvas {
border-radius: 8px;
background: white;
padding: 8px;
}
.zap-qr-container a {
display: block;
}
.zap-webln-btn {
width: 100%;
}
.zap-open-wallet-link {
width: 100%;
text-align: center;
text-decoration: none;
}
.zap-invoice-details {
display: flex;
justify-content: center;
font-size: 0.8rem;
}
.zap-success {
gap: 0.5rem;
}
.zap-success-icon {
font-size: 3rem;
}
.zap-success h4 {
margin: 0;
font-size: 1.2rem;
}
.zap-success p {
margin: 0;
font-size: 0.9rem;
color: #555;
}