diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 0a0567c..2871fe8 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -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 diff --git a/app/components/modal.gjs b/app/components/modal.gjs index e673e05..37bc88e 100644 --- a/app/components/modal.gjs +++ b/app/components/modal.gjs @@ -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 { } } diff --git a/app/components/photo-gallery.gjs b/app/components/photo-gallery.gjs index f8283f7..1b9d610 100644 --- a/app/components/photo-gallery.gjs +++ b/app/components/photo-gallery.gjs @@ -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 = ; @@ -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}} diff --git a/app/components/zap-photo-modal.gjs b/app/components/zap-photo-modal.gjs new file mode 100644 index 0000000..d01135b --- /dev/null +++ b/app/components/zap-photo-modal.gjs @@ -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); + } + + +} diff --git a/app/services/nostr-data.js b/app/services/nostr-data.js index a6e721d..fc76a27 100644 --- a/app/services/nostr-data.js +++ b/app/services/nostr-data.js @@ -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() { diff --git a/app/services/nostr-zap.js b/app/services/nostr-zap.js new file mode 100644 index 0000000..a10b7ce --- /dev/null +++ b/app/services/nostr-zap.js @@ -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 }; + } +} diff --git a/app/styles/app.css b/app/styles/app.css index 47f243e..e2a6723 100644 --- a/app/styles/app.css +++ b/app/styles/app.css @@ -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); + } +} diff --git a/package.json b/package.json index 051fc84..f0f73d5 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c5d0a5..9cd2991 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/tests/helpers/mock-nostr.js b/tests/helpers/mock-nostr.js index 322f00b..9f3f074 100644 --- a/tests/helpers/mock-nostr.js +++ b/tests/helpers/mock-nostr.js @@ -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); }); } diff --git a/tests/integration/components/photo-gallery-test.gjs b/tests/integration/components/photo-gallery-test.gjs index 5e607af..e9e47bc 100644 --- a/tests/integration/components/photo-gallery-test.gjs +++ b/tests/integration/components/photo-gallery-test.gjs @@ -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( + + ); + + 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( + + ); + + 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'); + }); }); diff --git a/tests/integration/components/zap-photo-modal-test.gjs b/tests/integration/components/zap-photo-modal-test.gjs new file mode 100644 index 0000000..c3c00a5 --- /dev/null +++ b/tests/integration/components/zap-photo-modal-test.gjs @@ -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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + await click('.modal-overlay'); + + assert.ok(closed, 'onClose was called when clicking overlay'); + }); +});