Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc433c8b1e
|
||
|
|
c3af496dfd
|
||
|
|
67e4eaddbb
|
||
|
|
beda860093
|
||
|
|
82bc6a424e
|
||
|
|
74e9860e08
|
||
|
|
135a30f1cd
|
||
|
|
99ebdf3378
|
||
|
|
20bac8ae31
|
||
|
|
2869ed6677
|
||
|
|
644d6a2856
|
||
|
|
09aefa6e19
|
||
|
|
c5198b39b8
|
||
|
|
1387f955d0
|
||
|
|
2d547dde87
|
||
|
|
85334ec15d
|
||
|
|
bfcf855020
|
||
|
|
0f0874a07a
|
File diff suppressed because it is too large
Load Diff
@@ -149,7 +149,7 @@ Each rule file contains:
|
||||
Ember has excellent accessibility support through community addons:
|
||||
|
||||
- **ember-a11y-testing** - Automated accessibility testing in your test suite
|
||||
- **ember-a11y** - Route announcements and focus management
|
||||
- **ember-a11y-refocus** - Route announcements and focus management
|
||||
- **ember-focus-trap** - Focus trapping for modals and dialogs
|
||||
- **ember-page-title** - Accessible page title management
|
||||
- **Platform-native validation** - Use browser's Constraint Validation API for accessible form validation
|
||||
|
||||
@@ -69,6 +69,14 @@ import { setupGlobalA11yHooks } from 'ember-a11y-testing/test-support';
|
||||
setupGlobalA11yHooks(); // Runs on every test automatically
|
||||
```
|
||||
|
||||
### Leave All Rules Enabled
|
||||
|
||||
Keep the default ember-a11y-testing and axe-core rules turned on. Avoid disabling rules globally or excluding specific rules to bypass failures without a documented remediation plan.
|
||||
|
||||
When teams suppress accessibility rules without a time-boxed remediation plan, they hide real defects, accumulate technical debt, and make regressions harder to detect. A skipped rule can allow a serious accessibility defect to ship to production, especially for problems involving forms, keyboard access, focus management, semantics, or ARIA usage.
|
||||
|
||||
If you must suppress a rule temporarily, treat it as an exception: document why it is needed, scope it as narrowly as possible, and create follow-up work to restore the rule quickly.
|
||||
|
||||
ember-a11y-testing catches issues like missing labels, insufficient color contrast, invalid ARIA, and keyboard navigation problems automatically.
|
||||
|
||||
Reference: [ember-a11y-testing](https://github.com/ember-a11y/ember-a11y-testing)
|
||||
|
||||
@@ -35,9 +35,7 @@ All form inputs must have associated labels, and validation errors should be ann
|
||||
<div>
|
||||
<label for="email-input">
|
||||
Email Address
|
||||
{{#if this.isEmailRequired}}
|
||||
<span aria-label="required">*</span>
|
||||
{{/if}}
|
||||
<span aria-hidden="true">*</span>
|
||||
</label>
|
||||
|
||||
<input
|
||||
|
||||
@@ -19,49 +19,86 @@ export default class Router extends EmberRouter {
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (using a11y-announcer library - recommended):**
|
||||
**Correct (using ember-a11y-refocus library - recommended):**
|
||||
|
||||
Use the [a11y-announcer](https://github.com/ember-a11y/a11y-announcer) library for robust route announcements:
|
||||
Use the [ember-a11y-refocus](https://github.com/ember-a11y/ember-a11y-refocus) library for robust route announcements, route transition focus management, and a bypass block (aka skip link).
|
||||
|
||||
```bash
|
||||
ember install @ember-a11y/a11y-announcer
|
||||
pnpm add ember-a11y-refocus
|
||||
```
|
||||
|
||||
```javascript
|
||||
// app/router.js
|
||||
import EmberRouter from '@ember/routing/router';
|
||||
import config from './config/environment';
|
||||
Or with npm:
|
||||
|
||||
export default class Router extends EmberRouter {
|
||||
location = config.locationType;
|
||||
rootURL = config.rootURL;
|
||||
}
|
||||
|
||||
Router.map(function () {
|
||||
this.route('about');
|
||||
this.route('dashboard');
|
||||
this.route('posts', function () {
|
||||
this.route('post', { path: '/:post_id' });
|
||||
});
|
||||
});
|
||||
```bash
|
||||
npm install ember-a11y-refocus
|
||||
```
|
||||
|
||||
The a11y-announcer library automatically handles route announcements. For custom announcements in your routes:
|
||||
Use the addon by rendering `NavigationNarrator` in your application layout and ensuring your primary content has `id="main"`.
|
||||
|
||||
```handlebars
|
||||
{{! app/templates/application.hbs }}
|
||||
<header>
|
||||
<NavigationNarrator />
|
||||
{{! other header content }}
|
||||
</header>
|
||||
|
||||
<main id='main'>
|
||||
{{outlet}}
|
||||
</main>
|
||||
```
|
||||
|
||||
If you are using GJS or GTS, import the component directly:
|
||||
|
||||
```glimmer-js
|
||||
import { NavigationNarrator } from 'ember-a11y-refocus';
|
||||
|
||||
<template>
|
||||
<header>
|
||||
<NavigationNarrator />
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
{{outlet}}
|
||||
</main>
|
||||
</template>
|
||||
```
|
||||
|
||||
The addon ships minimal styles for the skip link and navigation message:
|
||||
|
||||
```javascript
|
||||
// app/routes/dashboard.js
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
// app/app.js or app/app.ts
|
||||
import 'ember-a11y-refocus/styles/navigation-narrator.css';
|
||||
```
|
||||
|
||||
export default class DashboardRoute extends Route {
|
||||
@service announcer;
|
||||
If you need to customize which transitions count as a route change, pass a validator function to `NavigationNarrator`:
|
||||
|
||||
afterModel() {
|
||||
this.announcer.announce('Loaded dashboard with latest data');
|
||||
```javascript
|
||||
// app/controllers/application.js
|
||||
import Controller from '@ember/controller';
|
||||
import { defaultValidator } from 'ember-a11y-refocus';
|
||||
|
||||
export default class ApplicationController extends Controller {
|
||||
myCustomValidator(transition) {
|
||||
if (transition.from?.name === 'special') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return defaultValidator(transition);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```handlebars
|
||||
{{! app/templates/application.hbs }}
|
||||
<header>
|
||||
<NavigationNarrator @routeChangeValidator={{this.myCustomValidator}} />
|
||||
</header>
|
||||
|
||||
<main id='main'>
|
||||
{{outlet}}
|
||||
</main>
|
||||
```
|
||||
|
||||
**Alternative: DIY approach with ARIA live regions:**
|
||||
|
||||
If you prefer not to use a library, you can implement route announcements yourself:
|
||||
@@ -150,25 +187,6 @@ export default class ApplicationRoute extends Route {
|
||||
}
|
||||
```
|
||||
|
||||
**Alternative: Use ember-page-title with announcements:**
|
||||
|
||||
```bash
|
||||
ember install ember-page-title
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// app/routes/dashboard.gjs
|
||||
import { pageTitle } from 'ember-page-title';
|
||||
|
||||
<template>
|
||||
{{pageTitle "Dashboard"}}
|
||||
|
||||
<div class="dashboard">
|
||||
{{outlet}}
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
Route announcements ensure screen reader users know when navigation occurs, improving the overall accessibility experience.
|
||||
|
||||
Reference: [Ember Accessibility - Page Titles](https://guides.emberjs.com/release/accessibility/page-template-considerations/)
|
||||
|
||||
@@ -11,8 +11,8 @@ Implement intelligent model caching strategies to reduce redundant API calls and
|
||||
|
||||
**Incorrect (always fetches fresh data):**
|
||||
|
||||
```glimmer-js
|
||||
// app/routes/post.gjs
|
||||
```javascript
|
||||
// app/routes/post.js
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
@@ -23,21 +23,24 @@ export default class PostRoute extends Route {
|
||||
// Always makes API call, even if we just loaded this post
|
||||
return this.store.request({ url: `/posts/${params.post_id}` });
|
||||
}
|
||||
|
||||
<template>
|
||||
<article>
|
||||
<h1>{{@model.title}}</h1>
|
||||
<div>{{@model.content}}</div>
|
||||
</article>
|
||||
{{outlet}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// app/templates/post.gjs
|
||||
<template>
|
||||
<article>
|
||||
<h1>{{@model.title}}</h1>
|
||||
<div>{{@model.content}}</div>
|
||||
</article>
|
||||
{{outlet}}
|
||||
</template>
|
||||
```
|
||||
|
||||
**Correct (with smart caching):**
|
||||
|
||||
```glimmer-js
|
||||
// app/routes/post.gjs
|
||||
```javascript
|
||||
// app/routes/post.js
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
@@ -68,17 +71,20 @@ export default class PostRoute extends Route {
|
||||
const fiveMinutes = 5 * 60 * 1000;
|
||||
return Date.now() - cacheTime < fiveMinutes;
|
||||
}
|
||||
|
||||
<template>
|
||||
<article>
|
||||
<h1>{{@model.title}}</h1>
|
||||
<div>{{@model.content}}</div>
|
||||
</article>
|
||||
{{outlet}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// app/templates/post.gjs
|
||||
<template>
|
||||
<article>
|
||||
<h1>{{@model.title}}</h1>
|
||||
<div>{{@model.content}}</div>
|
||||
</article>
|
||||
{{outlet}}
|
||||
</template>
|
||||
```
|
||||
|
||||
**Service-based caching layer:**
|
||||
|
||||
```javascript
|
||||
@@ -123,8 +129,8 @@ export default class PostCacheService extends Service {
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// app/routes/post.gjs
|
||||
```javascript
|
||||
// app/routes/post.js
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
@@ -141,21 +147,24 @@ export default class PostRoute extends Route {
|
||||
const params = this.paramsFor('post');
|
||||
await this.postCache.getPost(params.post_id, { forceRefresh: true });
|
||||
}
|
||||
|
||||
<template>
|
||||
<article>
|
||||
<h1>{{@model.title}}</h1>
|
||||
<div>{{@model.content}}</div>
|
||||
</article>
|
||||
{{outlet}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// app/templates/post.gjs
|
||||
<template>
|
||||
<article>
|
||||
<h1>{{@model.title}}</h1>
|
||||
<div>{{@model.content}}</div>
|
||||
</article>
|
||||
{{outlet}}
|
||||
</template>
|
||||
```
|
||||
|
||||
**Using query params for cache control:**
|
||||
|
||||
```glimmer-js
|
||||
// app/routes/posts.gjs
|
||||
```javascript
|
||||
// app/routes/posts.js
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
@@ -174,28 +183,31 @@ export default class PostsRoute extends Route {
|
||||
options,
|
||||
});
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="posts">
|
||||
<button {{on "click" (fn this.refresh)}}>
|
||||
Refresh
|
||||
</button>
|
||||
|
||||
<ul>
|
||||
{{#each @model as |post|}}
|
||||
<li>{{post.title}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</div>
|
||||
{{outlet}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// app/templates/posts.gjs
|
||||
<template>
|
||||
<div class="posts">
|
||||
<button {{on "click" (fn this.refresh)}}>
|
||||
Refresh
|
||||
</button>
|
||||
|
||||
<ul>
|
||||
{{#each @model as |post|}}
|
||||
<li>{{post.title}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</div>
|
||||
{{outlet}}
|
||||
</template>
|
||||
```
|
||||
|
||||
**Background refresh pattern:**
|
||||
|
||||
```glimmer-js
|
||||
// app/routes/dashboard.gjs
|
||||
```javascript
|
||||
// app/routes/dashboard.js
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
@@ -214,17 +226,20 @@ export default class DashboardRoute extends Route {
|
||||
|
||||
return cached || this.store.request({ url: '/dashboard' });
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<h1>Dashboard</h1>
|
||||
<div>Stats: {{@model.stats}}</div>
|
||||
</div>
|
||||
{{outlet}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// app/templates/dashboard.gjs
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<h1>Dashboard</h1>
|
||||
<div>Stats: {{@model.stats}}</div>
|
||||
</div>
|
||||
{{outlet}}
|
||||
</template>
|
||||
```
|
||||
|
||||
Smart caching reduces server load, improves perceived performance, and provides better offline support while keeping data fresh.
|
||||
|
||||
Reference: [WarpDrive Caching](https://warp-drive.io/)
|
||||
|
||||
@@ -1,38 +1,15 @@
|
||||
---
|
||||
title: Use Route Templates with Co-located Syntax
|
||||
title: Use Separate Route and Template Files
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: Better code organization and maintainability
|
||||
tags: routes, templates, gjs, co-location
|
||||
tags: routes, templates, gjs, file-conventions
|
||||
---
|
||||
|
||||
## Use Route Templates with Co-located Syntax
|
||||
## Use Separate Route and Template Files
|
||||
|
||||
Use co-located route templates with modern gjs syntax for better organization and maintainability.
|
||||
Keep route logic in `app/routes/*.js` and route templates in `app/templates/*.gjs`. Route classes imported from `@ember/routing/route` do not support inline `<template>` blocks.
|
||||
|
||||
**Incorrect (separate template file - old pattern):**
|
||||
|
||||
```glimmer-js
|
||||
// app/routes/posts.js (separate file)
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
export default class PostsRoute extends Route {
|
||||
model() {
|
||||
return this.store.request({ url: '/posts' });
|
||||
}
|
||||
}
|
||||
|
||||
// app/templates/posts.gjs (separate template file)
|
||||
<template>
|
||||
<h1>Posts</h1>
|
||||
<ul>
|
||||
{{#each @model as |post|}}
|
||||
<li>{{post.title}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Correct (co-located route template):**
|
||||
**Incorrect (inline template inside a route class):**
|
||||
|
||||
```glimmer-js
|
||||
// app/routes/posts.gjs
|
||||
@@ -56,10 +33,37 @@ export default class PostsRoute extends Route {
|
||||
}
|
||||
```
|
||||
|
||||
**With loading and error states:**
|
||||
**Correct (separate route module and template file):**
|
||||
|
||||
```javascript
|
||||
// app/routes/posts.js
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
export default class PostsRoute extends Route {
|
||||
model() {
|
||||
return this.store.request({ url: '/posts' });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// app/routes/posts.gjs
|
||||
// app/templates/posts.gjs
|
||||
<template>
|
||||
<h1>Posts</h1>
|
||||
<ul>
|
||||
{{#each @model as |post|}}
|
||||
<li>{{post.title}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
|
||||
{{outlet}}
|
||||
</template>
|
||||
```
|
||||
|
||||
**With a separate template file for route UI:**
|
||||
|
||||
```javascript
|
||||
// app/routes/posts.js
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
@@ -69,29 +73,32 @@ export default class PostsRoute extends Route {
|
||||
model() {
|
||||
return this.store.request({ url: '/posts' });
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="posts-page">
|
||||
<h1>Posts</h1>
|
||||
|
||||
{{#if @model}}
|
||||
<ul>
|
||||
{{#each @model as |post|}}
|
||||
<li>{{post.title}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{/if}}
|
||||
|
||||
{{outlet}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// app/templates/posts.gjs
|
||||
<template>
|
||||
<div class="posts-page">
|
||||
<h1>Posts</h1>
|
||||
|
||||
{{#if @model}}
|
||||
<ul>
|
||||
{{#each @model as |post|}}
|
||||
<li>{{post.title}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{/if}}
|
||||
|
||||
{{outlet}}
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Template-only routes:**
|
||||
|
||||
```glimmer-js
|
||||
// app/routes/about.gjs
|
||||
// app/templates/about.gjs
|
||||
<template>
|
||||
<div class="about-page">
|
||||
<h1>About Us</h1>
|
||||
@@ -100,6 +107,6 @@ export default class PostsRoute extends Route {
|
||||
</template>
|
||||
```
|
||||
|
||||
Co-located route templates keep route logic and presentation together, making the codebase easier to navigate and maintain.
|
||||
Keeping route classes and route templates in their conventional files matches Ember's supported routing model and makes examples easier to apply in real apps.
|
||||
|
||||
Reference: [Ember Routes](https://guides.emberjs.com/release/routing/)
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
---
|
||||
name: nostr-zap-integration
|
||||
description: Implement or debug NIP-57 zaps or NIP-61 nutzaps when the task involves zap requests, receipts, LNURL-pay, zap splits, Cashu tokens, or recipient payment configuration.
|
||||
---
|
||||
|
||||
# Nostr Zap Integration
|
||||
|
||||
## Overview
|
||||
|
||||
Build correct Lightning Zap and Nutzap flows for Nostr applications. This skill
|
||||
covers the full NIP-57 lifecycle (LNURL discovery, zap request construction,
|
||||
invoice handling, zap receipt validation) and the NIP-61 Cashu alternative
|
||||
(nutzap configuration, P2PK token minting, nutzap publishing and redemption).
|
||||
|
||||
## When to Use
|
||||
|
||||
- The task involves NIP-57 zaps or NIP-61 nutzaps in a Nostr application.
|
||||
- The user needs zap requests, receipts, LNURL-pay integration, zap splits, recipient config, or Cashu token flow tied to Nostr events.
|
||||
- The problem is payment interoperability between Nostr and Lightning/Cashu, not general wallet construction.
|
||||
- The request is about end-to-end zap behavior or zap validation.
|
||||
|
||||
**Do NOT use when:**
|
||||
|
||||
- The task is generic Lightning, LNURL, or Cashu work with no Nostr zap context.
|
||||
- The work is relay protocol or general Nostr event creation.
|
||||
- The request is bech32 encoding or other peripheral concerns unrelated to zap flows.
|
||||
|
||||
|
||||
## Response format
|
||||
|
||||
Always structure the final response with these top-level sections, in this order:
|
||||
|
||||
1. **Summary** — state the task, scope, and main conclusion in 1-3 sentences.
|
||||
2. **Decision / Approach** — state the key classification, assumptions, or chosen path.
|
||||
3. **Artifacts** — provide the primary deliverable(s) for this skill. Use clear subheadings for multiple files, commands, JSON payloads, queries, or documents.
|
||||
4. **Validation** — state checks performed, important risks, caveats, or unresolved questions.
|
||||
5. **Next steps** — list concrete follow-up actions, or write `None` if nothing remains.
|
||||
|
||||
Rules:
|
||||
- Do not omit a section; write `None` when a section does not apply.
|
||||
- If files are produced, list each file path under **Artifacts** before its contents.
|
||||
- If commands, JSON, SQL, YAML, or code are produced, put each artifact in fenced code blocks with the correct language tag when possible.
|
||||
- Keep section names exactly as written above so output stays predictable across skills.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Determine the Payment Path
|
||||
|
||||
Ask: "Is this a Lightning Zap (NIP-57) or a Nutzap (NIP-61)?"
|
||||
|
||||
| Path | When to Use | Key Kinds |
|
||||
| -------------- | --------------------------------------- | ----------- |
|
||||
| Lightning Zap | Recipient has lud16/lud06, LNURL server | 9734, 9735 |
|
||||
| Nutzap (Cashu) | Recipient has kind:10019, trusted mints | 10019, 9321 |
|
||||
|
||||
If unsure, check the recipient's profile (kind:0) for `lud16`/`lud06` fields
|
||||
(Lightning path) or query for their kind:10019 event (Nutzap path).
|
||||
|
||||
### 2. Lightning Zap Flow (NIP-57)
|
||||
|
||||
Follow the steps in [references/zap-flow.md](references/zap-flow.md) for the
|
||||
complete implementation. Summary:
|
||||
|
||||
#### Step 2a: Discover the LNURL Endpoint
|
||||
|
||||
```typescript
|
||||
// From lud16 (e.g., "bob@example.com")
|
||||
const [name, domain] = lud16.split("@");
|
||||
const url = `https://${domain}/.well-known/lnurlp/${name}`;
|
||||
const res = await fetch(url);
|
||||
const lnurlPayData = await res.json();
|
||||
|
||||
// Verify Nostr support
|
||||
if (!lnurlPayData.allowsNostr || !lnurlPayData.nostrPubkey) {
|
||||
throw new Error("Recipient does not support Nostr zaps");
|
||||
}
|
||||
```
|
||||
|
||||
**Critical checks on the LNURL response:**
|
||||
|
||||
- `allowsNostr` MUST be `true`
|
||||
- `nostrPubkey` MUST be a valid 32-byte hex public key
|
||||
- Save `callback`, `minSendable`, `maxSendable` for later use
|
||||
|
||||
#### Step 2b: Construct the Zap Request (kind:9734)
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 9734,
|
||||
"content": "Optional zap comment",
|
||||
"tags": [
|
||||
["relays", "wss://relay1.example.com", "wss://relay2.example.com"],
|
||||
["amount", "21000"],
|
||||
["lnurl", "lnurl1dp68gurn8ghj7..."],
|
||||
["p", "<recipient-pubkey-hex>"],
|
||||
["e", "<event-id-hex>"],
|
||||
["k", "<event-kind-string>"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Required tags:** `relays` (list of relay URLs), `p` (recipient pubkey).
|
||||
**Recommended tags:** `amount` (millisats as string), `lnurl` (bech32-encoded).
|
||||
**Optional tags:** `e` (event being zapped), `a` (addressable event coordinate),
|
||||
`k` (kind of zapped event as string).
|
||||
|
||||
**Critical:** The zap request is NOT published to relays. It is sent to the
|
||||
LNURL callback URL.
|
||||
|
||||
#### Step 2c: Send to Callback and Get Invoice
|
||||
|
||||
```typescript
|
||||
const zapRequestEncoded = encodeURIComponent(JSON.stringify(signedZapRequest));
|
||||
const url =
|
||||
`${callback}?amount=${amountMsats}&nostr=${zapRequestEncoded}&lnurl=${lnurlBech32}`;
|
||||
const { pr: invoice } = await fetch(url).then((r) => r.json());
|
||||
```
|
||||
|
||||
#### Step 2d: Pay the Invoice
|
||||
|
||||
Pass the bolt11 invoice to a Lightning wallet for payment. After payment, the
|
||||
recipient's LNURL server creates and publishes the zap receipt (kind:9735).
|
||||
|
||||
#### Step 2e: Validate Zap Receipts
|
||||
|
||||
See [references/zap-flow.md](references/zap-flow.md) for full validation logic.
|
||||
The three critical checks:
|
||||
|
||||
1. Receipt `pubkey` MUST match the recipient's LNURL `nostrPubkey`
|
||||
2. Invoice amount in `bolt11` tag MUST match `amount` in the zap request
|
||||
3. `SHA256(description)` SHOULD match the bolt11 description hash
|
||||
|
||||
### 3. Nutzap Flow (NIP-61)
|
||||
|
||||
Follow the steps in [references/nutzap-flow.md](references/nutzap-flow.md) for
|
||||
the complete implementation. Summary:
|
||||
|
||||
#### Step 3a: Fetch Recipient's Nutzap Configuration (kind:10019)
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 10019,
|
||||
"tags": [
|
||||
["relay", "wss://relay1.example.com"],
|
||||
["relay", "wss://relay2.example.com"],
|
||||
["mint", "https://mint.example.com", "sat"],
|
||||
["mint", "https://othermint.example.com", "usd", "sat"],
|
||||
["pubkey", "<p2pk-pubkey-hex>"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Critical:** The `pubkey` tag value MUST NOT be the user's main Nostr pubkey.
|
||||
It is a separate key used exclusively for P2PK locking.
|
||||
|
||||
#### Step 3b: Mint P2PK-Locked Tokens
|
||||
|
||||
1. Choose a mint from the recipient's `mint` tags
|
||||
2. Mint or swap tokens P2PK-locked to the recipient's `pubkey` value
|
||||
3. Prefix the pubkey with `"02"` for nostr-cashu compatibility
|
||||
4. Include DLEQ proofs (NUT-12)
|
||||
|
||||
#### Step 3c: Publish the Nutzap (kind:9321)
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 9321,
|
||||
"content": "Optional comment",
|
||||
"tags": [
|
||||
["proof", "<cashu-proof-json>"],
|
||||
["unit", "sat"],
|
||||
["u", "https://mint.example.com"],
|
||||
["e", "<zapped-event-id>", "<relay-hint>"],
|
||||
["k", "<zapped-event-kind>"],
|
||||
["p", "<recipient-nostr-pubkey>"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Publish to the relays listed in the recipient's kind:10019 `relay` tags.
|
||||
|
||||
#### Step 3d: Receiving Nutzaps
|
||||
|
||||
Recipients query for kind:9321 events p-tagging them, filtered by trusted mint
|
||||
URLs (`#u`). Upon receiving, swap the tokens into their wallet and publish a
|
||||
kind:7376 redemption event.
|
||||
|
||||
### 4. Zap Splits
|
||||
|
||||
When an event has `zap` tags, distribute the zap across recipients:
|
||||
|
||||
```json
|
||||
["zap", "<pubkey>", "<relay>", "<weight>"]
|
||||
```
|
||||
|
||||
Weights are relative. Calculate percentages:
|
||||
|
||||
```typescript
|
||||
const totalWeight = zapTags.reduce((sum, t) => sum + Number(t[3] || 0), 0);
|
||||
for (const tag of zapTags) {
|
||||
const weight = Number(tag[3] || 0);
|
||||
const pct = weight / totalWeight;
|
||||
const recipientAmount = Math.floor(totalAmount * pct);
|
||||
// Create separate zap request for each recipient
|
||||
}
|
||||
```
|
||||
|
||||
Recipients without a weight value get weight 0 (no zap). If no weights are
|
||||
present on any tag, divide equally.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Identified payment path (Lightning vs Nutzap)
|
||||
- [ ] For Lightning: LNURL endpoint discovered and verified (`allowsNostr`,
|
||||
`nostrPubkey`)
|
||||
- [ ] For Lightning: Zap request (kind:9734) has required tags (`relays`, `p`)
|
||||
- [ ] For Lightning: Zap request sent to callback URL, NOT published to relays
|
||||
- [ ] For Lightning: Amount in millisats, within `minSendable`/`maxSendable`
|
||||
- [ ] For Lightning: Zap receipt validation checks all three criteria
|
||||
- [ ] For Nutzap: Recipient's kind:10019 fetched and parsed
|
||||
- [ ] For Nutzap: Tokens minted at one of recipient's listed mints
|
||||
- [ ] For Nutzap: P2PK pubkey prefixed with "02" and is NOT the main Nostr key
|
||||
- [ ] For Nutzap: kind:9321 published to recipient's specified relays
|
||||
- [ ] For splits: Weights calculated correctly, separate zap per recipient
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
| Mistake | Why It Breaks | Fix |
|
||||
| ---------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------ |
|
||||
| Publishing kind:9734 to relays | Zap requests are sent to LNURL callback, never published | Send via HTTP GET to callback URL |
|
||||
| Amount in satoshis instead of millisats | NIP-57 uses millisats (1 sat = 1000 msats) | Multiply sats by 1000 for the `amount` tag |
|
||||
| Using recipient's Nostr pubkey for P2PK | NIP-61 requires a SEPARATE key for P2PK locking | Use the `pubkey` from kind:10019, never the main key |
|
||||
| Missing `relays` tag on zap request | LNURL server won't know where to publish the receipt | Always include at least one relay in `relays` tag |
|
||||
| Not validating receipt pubkey | Fake zap receipts from wrong keys accepted | Receipt pubkey MUST match LNURL `nostrPubkey` |
|
||||
| Sending nutzap to unlisted mint | Recipient may never see it; tokens could be lost | Only use mints from recipient's kind:10019 `mint` tags |
|
||||
| Missing "02" prefix on P2PK pubkey | Cashu P2PK expects compressed pubkey format | Always prefix with "02" for nostr-cashu compat |
|
||||
| Not checking `allowsNostr` on LNURL | Server may not support Nostr zaps at all | Verify `allowsNostr: true` before constructing zap |
|
||||
| Treating zap receipt as proof of payment | Receipts can be forged by rogue LNURL servers | Trust the receipt author, not the receipt itself |
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Operation | Kind | Key Tags | Published? |
|
||||
| ------------- | ----- | -------------------------------------- | --------------------- |
|
||||
| Zap request | 9734 | `relays`, `p`, `amount`, `lnurl`, `e` | NO (HTTP only) |
|
||||
| Zap receipt | 9735 | `p`, `P`, `bolt11`, `description`, `e` | YES (by LNURL server) |
|
||||
| Nutzap config | 10019 | `relay`, `mint`, `pubkey` | YES (replaceable) |
|
||||
| Nutzap send | 9321 | `proof`, `u`, `unit`, `p`, `e` | YES |
|
||||
| Nutzap redeem | 7376 | `e` (9321 ref), `p` (sender) | YES (encrypted) |
|
||||
|
||||
## Key Principles
|
||||
|
||||
1. **Zap requests are HTTP-only** — Kind:9734 events are NEVER published to
|
||||
relays. They are signed, JSON-encoded, URI-encoded, and sent as a query
|
||||
parameter to the LNURL callback URL. This is the most common mistake.
|
||||
|
||||
2. **Validate the full chain** — A valid zap receipt requires matching the
|
||||
receipt pubkey to the LNURL `nostrPubkey`, matching the invoice amount to the
|
||||
request amount, and verifying the description hash. Skipping any check allows
|
||||
forged zaps.
|
||||
|
||||
3. **Nutzap keys are separate** — The P2PK pubkey in kind:10019 MUST be a
|
||||
different key from the user's main Nostr identity key. Using the same key
|
||||
would allow anyone to spend received tokens. Always prefix with "02".
|
||||
|
||||
4. **Amounts are in millisatoshis** — NIP-57 uses millisats everywhere (1 sat =
|
||||
1000 msats). The `amount` tag, `minSendable`, `maxSendable`, and invoice
|
||||
amounts are all in millisats.
|
||||
|
||||
5. **Trust boundaries matter** — Zap receipts are NOT cryptographic proofs of
|
||||
payment. They prove that a LNURL server claims payment was received. The
|
||||
trust is in the LNURL server operator, not in the protocol itself.
|
||||
|
||||
## Optimization Notes
|
||||
|
||||
- Preserve the user's requested output shape exactly and do not substitute generic advice for concrete artifacts.
|
||||
- Include exact commands, code structures, protocol fields, tags, parameters, file paths, or deliverable sections when the task asks for them.
|
||||
- Make safety gates explicit before irreversible, destructive, externally visible, or compliance-sensitive actions.
|
||||
- For multi-step work, present steps in execution order and include validation or rollback checks where relevant.
|
||||
- Avoid overfitting to a single eval example: express lessons as reusable rules, not as task-specific answers.
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"skill_name": "nostr-zap-integration",
|
||||
"evals": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "send-lightning-zap",
|
||||
"prompt": "I'm building a Nostr client and need to implement sending a zap to a user. The recipient's profile has lud16 set to 'alice@getalby.com'. I want to zap 21 sats on their latest note (event id: 'abc123def456...', kind 1). My pubkey is '97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322' and the recipient's pubkey is '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'. Show me the complete implementation including LNURL discovery, zap request construction, and sending to the callback.",
|
||||
"expected_output": "A complete implementation showing: LNURL endpoint discovery from lud16, verification of allowsNostr and nostrPubkey, construction of a kind:9734 zap request with correct tags (relays, amount in millisats, p, e, lnurl), sending the signed event to the callback URL as a query parameter (NOT publishing to relays), and receiving the bolt11 invoice.",
|
||||
"files": [],
|
||||
"assertions": [
|
||||
"Output contains 'kind: 9734' or 'kind:9734' or '9734' for the zap request event",
|
||||
"Output contains 'allowsNostr' to check that the LNURL endpoint supports Nostr zaps",
|
||||
"Output contains 'nostrPubkey' to verify the LNURL endpoint's signing key",
|
||||
"Output contains '21000' (21 sats converted to millisats) in the amount tag",
|
||||
"Output sends the zap request to the 'callback' URL (not publishing to relays)",
|
||||
"The zap request event tags array has a 'relays' entry with relay URLs",
|
||||
"Output sends zap request via HTTP GET to the callback URL with query parameters",
|
||||
"Output constructs the LNURL discovery URL using '.well-known/lnurlp'"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "validate-zap-receipts",
|
||||
"prompt": "I need to implement zap receipt validation for my Nostr client. When I fetch kind:9735 events, I need to verify they are legitimate. Write me a validation function that takes a zap receipt event and the expected LNURL nostrPubkey, and returns whether the zap is valid along with any errors. Cover all the validation checks from NIP-57 including pubkey matching, amount verification, and description hash checking. Also show me how to extract the sender pubkey and zap comment from a valid receipt.",
|
||||
"expected_output": "A comprehensive validation function that checks: (1) receipt pubkey matches LNURL nostrPubkey, (2) parses the description tag as JSON to get the embedded zap request, (3) verifies the zap request is kind 9734, (4) checks invoice amount matches request amount, (5) verifies SHA256 of description matches bolt11 description hash. Also extracts sender pubkey from the embedded zap request's pubkey field and comment from its content field.",
|
||||
"files": [],
|
||||
"assertions": [
|
||||
"Output works with 'kind: 9735' or 'kind:9735' zap receipt events",
|
||||
"Output validates that the receipt pubkey matches the LNURL nostrPubkey",
|
||||
"Output parses the 'description' tag as JSON to get the embedded zap request",
|
||||
"Output checks the 'bolt11' tag for invoice amount verification",
|
||||
"Output verifies amount in bolt11 matches amount in the zap request",
|
||||
"Output contains 'SHA256' or 'sha256' for hashing the description to verify against bolt11 description hash",
|
||||
"Output verifies the embedded zap request is kind 9734",
|
||||
"Output extracts the sender pubkey from the embedded zap request event"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "nutzap-sending",
|
||||
"prompt": "I want to implement Nutzap (NIP-61) sending in my Nostr client as an alternative to Lightning zaps. The recipient has published a kind:10019 event. Show me the complete flow: fetching their nutzap config, minting P2PK-locked Cashu tokens at one of their trusted mints, and publishing the kind:9321 nutzap event. Also explain how the recipient would verify and redeem the nutzap. Make sure to handle the P2PK pubkey correctly for nostr-cashu compatibility.",
|
||||
"expected_output": "Complete implementation showing: (1) fetching and parsing kind:10019 with relay, mint, and pubkey tags, (2) verifying the P2PK pubkey is NOT the user's main Nostr key, (3) minting tokens P2PK-locked with '02' prefix on the pubkey, (4) constructing kind:9321 with proof, u, unit, p tags, (5) publishing to the relays from kind:10019, (6) recipient verification checking mint is trusted and proofs are locked to correct key, (7) redemption via token swap and kind:7376 event.",
|
||||
"files": [],
|
||||
"assertions": [
|
||||
"Output fetches 'kind: 10019' or 'kind:10019' for the nutzap configuration",
|
||||
"Output constructs 'kind: 9321' or 'kind:9321' nutzap events",
|
||||
"Output contains 'P2PK' for locking the Cashu tokens",
|
||||
"Output contains '02' prefix for the P2PK pubkey (compressed public key format)",
|
||||
"Output warns that the P2PK pubkey must NOT be the user's main Nostr pubkey",
|
||||
"The nutzap event has 'proof' tags carrying Cashu token data",
|
||||
"The mint URL is fetched from the recipient's kind:10019 event",
|
||||
"Output explains redemption including 'kind: 7376' or 'kind:7376' or token swap"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
# NIP-61 Nutzap Flow
|
||||
|
||||
Complete step-by-step reference for implementing Cashu-based Nutzaps in Nostr
|
||||
applications.
|
||||
|
||||
## Overview
|
||||
|
||||
Nutzaps are an alternative to Lightning Zaps that use Cashu ecash tokens. The
|
||||
payment itself is the receipt — no LNURL server needed. Tokens are P2PK-locked
|
||||
to a recipient-specified public key and published as Nostr events.
|
||||
|
||||
## Protocol Flow
|
||||
|
||||
```
|
||||
Sender Cashu Mint Relays
|
||||
| | |
|
||||
|-- Fetch kind:10019 -------|--------------------->|
|
||||
|<-- {mints, pubkey, relays}| |
|
||||
| | |
|
||||
|-- Mint P2PK token ------->| |
|
||||
|<-- {proofs} --------------| |
|
||||
| | |
|
||||
|-- Publish kind:9321 ------|--------------------->|
|
||||
| | |
|
||||
| Recipient |
|
||||
| |-- Fetch kind:9321 ------>|
|
||||
| |<-- {proofs} -------------|
|
||||
| | |
|
||||
| |-- Swap token ----------->|
|
||||
| |<-- {new proofs} ---------|
|
||||
| | |
|
||||
| |-- Publish kind:7376 ---->|
|
||||
```
|
||||
|
||||
## Step 1: Fetch Recipient's Configuration (kind:10019)
|
||||
|
||||
Query for the recipient's nutzap informational event:
|
||||
|
||||
```typescript
|
||||
const filter = {
|
||||
kinds: [10019],
|
||||
authors: [recipientPubkey],
|
||||
};
|
||||
```
|
||||
|
||||
### Parse the Configuration
|
||||
|
||||
```typescript
|
||||
interface NutzapConfig {
|
||||
relays: string[];
|
||||
mints: { url: string; units: string[] }[];
|
||||
p2pkPubkey: string;
|
||||
}
|
||||
|
||||
function parseNutzapConfig(event: NostrEvent): NutzapConfig {
|
||||
const relays = event.tags
|
||||
.filter((t) => t[0] === "relay")
|
||||
.map((t) => t[1]);
|
||||
|
||||
const mints = event.tags
|
||||
.filter((t) => t[0] === "mint")
|
||||
.map((t) => ({
|
||||
url: t[1],
|
||||
units: t.slice(2), // Additional elements are supported units
|
||||
}));
|
||||
|
||||
const pubkeyTag = event.tags.find((t) => t[0] === "pubkey");
|
||||
if (!pubkeyTag) throw new Error("No pubkey tag in kind:10019");
|
||||
|
||||
return {
|
||||
relays,
|
||||
mints,
|
||||
p2pkPubkey: pubkeyTag[1],
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Validation Checks
|
||||
|
||||
- At least one `relay` tag must be present
|
||||
- At least one `mint` tag must be present
|
||||
- `pubkey` tag MUST be present
|
||||
- `pubkey` value MUST NOT equal the event's `.pubkey` (the user's main key)
|
||||
- Mints SHOULD support NUT-11 (P2PK) and NUT-12 (DLEQ proofs)
|
||||
|
||||
## Step 2: Mint P2PK-Locked Tokens
|
||||
|
||||
### Choose a Mint
|
||||
|
||||
Select a mint from the recipient's `mint` tags. Check that it supports the
|
||||
desired unit (e.g., "sat"):
|
||||
|
||||
```typescript
|
||||
function selectMint(config: NutzapConfig, unit: string = "sat"): string {
|
||||
const compatible = config.mints.filter(
|
||||
(m) => m.units.length === 0 || m.units.includes(unit),
|
||||
);
|
||||
if (compatible.length === 0) {
|
||||
throw new Error(`No mints support unit: ${unit}`);
|
||||
}
|
||||
return compatible[0].url;
|
||||
}
|
||||
```
|
||||
|
||||
### Mint or Swap Tokens
|
||||
|
||||
Use a Cashu library (e.g., `@cashu/cashu-ts`) to mint tokens P2PK-locked to the
|
||||
recipient's pubkey:
|
||||
|
||||
```typescript
|
||||
import { CashuMint, CashuWallet } from "@cashu/cashu-ts";
|
||||
|
||||
async function mintNutzapTokens(
|
||||
mintUrl: string,
|
||||
amountSats: number,
|
||||
recipientP2pkPubkey: string,
|
||||
): Promise<CashuProof[]> {
|
||||
const mint = new CashuMint(mintUrl);
|
||||
const wallet = new CashuWallet(mint);
|
||||
|
||||
// CRITICAL: Prefix pubkey with "02" for nostr<>cashu compatibility
|
||||
const lockPubkey = recipientP2pkPubkey.startsWith("02")
|
||||
? recipientP2pkPubkey
|
||||
: `02${recipientP2pkPubkey}`;
|
||||
|
||||
// Mint tokens with P2PK lock
|
||||
const { proofs } = await wallet.mintTokens(amountSats, {
|
||||
p2pkPubkey: lockPubkey,
|
||||
includeDleq: true, // NUT-12: Include DLEQ proofs
|
||||
});
|
||||
|
||||
return proofs;
|
||||
}
|
||||
```
|
||||
|
||||
**Critical rules:**
|
||||
|
||||
- Always prefix the P2PK pubkey with `"02"` (compressed key format)
|
||||
- Always request DLEQ proofs (NUT-12) for verifiability
|
||||
- Only use mints listed in the recipient's kind:10019
|
||||
- The mint URL in the nutzap MUST match EXACTLY as listed in kind:10019
|
||||
|
||||
## Step 3: Construct and Publish the Nutzap (kind:9321)
|
||||
|
||||
```typescript
|
||||
function buildNutzap(params: {
|
||||
senderPubkey: string;
|
||||
recipientPubkey: string; // Nostr identity pubkey, NOT P2PK key
|
||||
proofs: CashuProof[];
|
||||
mintUrl: string;
|
||||
unit?: string;
|
||||
eventId?: string;
|
||||
eventKind?: number;
|
||||
relayHint?: string;
|
||||
comment?: string;
|
||||
}): Omit<NostrEvent, "id" | "sig"> {
|
||||
const tags: string[][] = [];
|
||||
|
||||
// Add each proof as a separate tag
|
||||
for (const proof of params.proofs) {
|
||||
tags.push(["proof", JSON.stringify(proof)]);
|
||||
}
|
||||
|
||||
tags.push(["u", params.mintUrl]);
|
||||
tags.push(["unit", params.unit || "sat"]);
|
||||
tags.push(["p", params.recipientPubkey]);
|
||||
|
||||
if (params.eventId) {
|
||||
const eTag = ["e", params.eventId];
|
||||
if (params.relayHint) eTag.push(params.relayHint);
|
||||
tags.push(eTag);
|
||||
}
|
||||
|
||||
if (params.eventKind !== undefined) {
|
||||
tags.push(["k", params.eventKind.toString()]);
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 9321,
|
||||
content: params.comment || "",
|
||||
tags,
|
||||
pubkey: params.senderPubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Tag Reference for kind:9321
|
||||
|
||||
| Tag | Required | Format | Notes |
|
||||
| ------- | -------- | -------------------------------- | ------------------------------ |
|
||||
| `proof` | YES | `["proof", "<json>"]` | One or more proof tags |
|
||||
| `u` | YES | `["u", "<mint-url>"]` | EXACT match to kind:10019 |
|
||||
| `unit` | NO | `["unit", "sat"]` | Default: "sat" if omitted |
|
||||
| `p` | YES | `["p", "<nostr-pubkey>"]` | Recipient's Nostr identity key |
|
||||
| `e` | NO | `["e", "<event-id>", "<relay>"]` | Event being nutzapped |
|
||||
| `k` | NO | `["k", "<kind>"]` | Kind of nutzapped event |
|
||||
|
||||
### Publish to Correct Relays
|
||||
|
||||
Publish the kind:9321 event to the relays listed in the recipient's kind:10019
|
||||
`relay` tags. Failure to publish to these relays means the recipient may never
|
||||
see the nutzap.
|
||||
|
||||
## Step 4: Receiving Nutzaps
|
||||
|
||||
### Query for Incoming Nutzaps
|
||||
|
||||
```typescript
|
||||
function buildNutzapFilter(
|
||||
myPubkey: string,
|
||||
trustedMints: string[],
|
||||
since?: number,
|
||||
): NostrFilter {
|
||||
return {
|
||||
kinds: [9321],
|
||||
"#p": [myPubkey],
|
||||
"#u": trustedMints, // Only from mints we trust
|
||||
...(since ? { since } : {}),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Process and Redeem
|
||||
|
||||
```typescript
|
||||
async function redeemNutzap(
|
||||
nutzapEvent: NostrEvent,
|
||||
wallet: CashuWallet,
|
||||
): Promise<CashuProof[]> {
|
||||
// Extract proofs from the event
|
||||
const proofTags = nutzapEvent.tags.filter((t) => t[0] === "proof");
|
||||
const proofs = proofTags.map((t) => JSON.parse(t[1]));
|
||||
|
||||
// Verify mint URL matches a trusted mint
|
||||
const mintUrl = nutzapEvent.tags.find((t) => t[0] === "u")?.[1];
|
||||
if (!mintUrl) throw new Error("Missing mint URL");
|
||||
|
||||
// Swap tokens into our wallet (this claims them)
|
||||
const newProofs = await wallet.receive(proofs);
|
||||
|
||||
return newProofs;
|
||||
}
|
||||
```
|
||||
|
||||
### Record Redemption (kind:7376)
|
||||
|
||||
After successfully swapping tokens, publish a kind:7376 event to record the
|
||||
redemption:
|
||||
|
||||
```typescript
|
||||
function buildRedemptionRecord(params: {
|
||||
nutzapEventId: string;
|
||||
nutzapRelayHint?: string;
|
||||
senderPubkey: string;
|
||||
amount: string;
|
||||
unit: string;
|
||||
newTokenEventId?: string;
|
||||
newTokenRelayHint?: string;
|
||||
}): Omit<NostrEvent, "id" | "sig" | "pubkey" | "created_at"> {
|
||||
// Content is NIP-44 encrypted
|
||||
const contentTags = [
|
||||
["direction", "in"],
|
||||
["amount", params.amount],
|
||||
["unit", params.unit],
|
||||
];
|
||||
|
||||
if (params.newTokenEventId) {
|
||||
const tag = ["e", params.newTokenEventId];
|
||||
if (params.newTokenRelayHint) tag.push(params.newTokenRelayHint);
|
||||
tag.push("created");
|
||||
contentTags.push(tag);
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 7376,
|
||||
content: nip44Encrypt(JSON.stringify(contentTags)), // NIP-44 encrypted
|
||||
tags: [
|
||||
["e", params.nutzapEventId, params.nutzapRelayHint || "", "redeemed"],
|
||||
["p", params.senderPubkey],
|
||||
],
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Step 5: Verifying Nutzaps (Observer)
|
||||
|
||||
Clients displaying nutzap counts or amounts should verify:
|
||||
|
||||
```typescript
|
||||
function verifyNutzap(
|
||||
nutzap: NostrEvent,
|
||||
recipientConfig: NostrEvent, // kind:10019
|
||||
): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
const config = parseNutzapConfig(recipientConfig);
|
||||
|
||||
// 1. Check mint is in recipient's trusted list
|
||||
const mintUrl = nutzap.tags.find((t) => t[0] === "u")?.[1];
|
||||
if (!mintUrl || !config.mints.some((m) => m.url === mintUrl)) {
|
||||
errors.push("Mint not in recipient's trusted mint list");
|
||||
}
|
||||
|
||||
// 2. Check proofs are locked to the correct pubkey
|
||||
const proofTags = nutzap.tags.filter((t) => t[0] === "proof");
|
||||
for (const proofTag of proofTags) {
|
||||
try {
|
||||
const proof = JSON.parse(proofTag[1]);
|
||||
const secret = JSON.parse(proof.secret);
|
||||
if (secret[0] === "P2PK") {
|
||||
const lockedTo = secret[1].data;
|
||||
const expectedKey = config.p2pkPubkey.startsWith("02")
|
||||
? config.p2pkPubkey
|
||||
: `02${config.p2pkPubkey}`;
|
||||
if (lockedTo !== expectedKey) {
|
||||
errors.push("Proof not locked to recipient's P2PK pubkey");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
errors.push("Invalid proof format");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Verify DLEQ proofs (offline verification)
|
||||
// This requires the mint's keyset - implementation depends on Cashu library
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
```
|
||||
|
||||
All verification can be done offline (given the mint's keyset and the
|
||||
recipient's kind:10019), making it fast and scalable.
|
||||
@@ -0,0 +1,370 @@
|
||||
# NIP-57 Lightning Zap Flow
|
||||
|
||||
Complete step-by-step reference for implementing Lightning Zaps in Nostr
|
||||
applications.
|
||||
|
||||
## Protocol Flow Overview
|
||||
|
||||
```
|
||||
Sender Client LNURL Server Relays Lightning
|
||||
| | | |
|
||||
|-- GET lnurlp/user --->| | |
|
||||
|<-- {allowsNostr, ...}-| | |
|
||||
| | | |
|
||||
|-- Sign kind:9734 ---->| | |
|
||||
| (zap request) | | |
|
||||
| | | |
|
||||
|-- GET callback?nostr= | | |
|
||||
|<-- {pr: bolt11} ------| | |
|
||||
| | | |
|
||||
|-- Pay invoice --------|-------------------|------> |
|
||||
| | | |
|
||||
| |-- kind:9735 ----->| |
|
||||
| | (zap receipt) | |
|
||||
| | | |
|
||||
|<-- Fetch kind:9735 ---|-------------------| |
|
||||
```
|
||||
|
||||
## Step 1: Discover the LNURL Endpoint
|
||||
|
||||
### From lud16 (Lightning Address)
|
||||
|
||||
```typescript
|
||||
function getLnurlPayUrl(lud16: string): string {
|
||||
const [name, domain] = lud16.split("@");
|
||||
return `https://${domain}/.well-known/lnurlp/${name}`;
|
||||
}
|
||||
|
||||
// Example: "bob@walletofsatoshi.com"
|
||||
// → "https://walletofsatoshi.com/.well-known/lnurlp/bob"
|
||||
```
|
||||
|
||||
### From zap tag on an event
|
||||
|
||||
If the event has `zap` tags, use those instead of the author's profile:
|
||||
|
||||
```typescript
|
||||
function getZapRecipients(event: NostrEvent): ZapRecipient[] {
|
||||
const zapTags = event.tags.filter((t) => t[0] === "zap");
|
||||
if (zapTags.length === 0) return []; // Fall back to event author
|
||||
|
||||
const totalWeight = zapTags.reduce((sum, t) => sum + Number(t[3] || 0), 0);
|
||||
|
||||
return zapTags.map((tag) => ({
|
||||
pubkey: tag[1],
|
||||
relay: tag[2],
|
||||
weight: Number(tag[3] || 0),
|
||||
percentage: totalWeight > 0 ? Number(tag[3] || 0) / totalWeight : 0,
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
### Verify Nostr Support
|
||||
|
||||
```typescript
|
||||
interface LnurlPayResponse {
|
||||
callback: string;
|
||||
maxSendable: number; // millisats
|
||||
minSendable: number; // millisats
|
||||
metadata: string;
|
||||
allowsNostr?: boolean;
|
||||
nostrPubkey?: string; // 32-byte hex
|
||||
}
|
||||
|
||||
async function verifyNostrZapSupport(
|
||||
lnurlPayUrl: string,
|
||||
): Promise<LnurlPayResponse> {
|
||||
const res = await fetch(lnurlPayUrl);
|
||||
const data: LnurlPayResponse = await res.json();
|
||||
|
||||
if (!data.allowsNostr) {
|
||||
throw new Error("LNURL endpoint does not support Nostr zaps");
|
||||
}
|
||||
if (!data.nostrPubkey || data.nostrPubkey.length !== 64) {
|
||||
throw new Error("Invalid or missing nostrPubkey");
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
## Step 2: Construct the Zap Request (kind:9734)
|
||||
|
||||
### Required Structure
|
||||
|
||||
```typescript
|
||||
interface ZapRequest {
|
||||
kind: 9734;
|
||||
content: string; // Optional message
|
||||
tags: string[][];
|
||||
pubkey: string; // Sender's pubkey
|
||||
created_at: number;
|
||||
id: string;
|
||||
sig: string;
|
||||
}
|
||||
|
||||
function buildZapRequest(params: {
|
||||
senderPubkey: string;
|
||||
recipientPubkey: string;
|
||||
amountMsats: number;
|
||||
relays: string[];
|
||||
lnurl?: string;
|
||||
eventId?: string;
|
||||
eventKind?: number;
|
||||
addressableCoord?: string; // "kind:pubkey:d-tag"
|
||||
comment?: string;
|
||||
}): Omit<ZapRequest, "id" | "sig"> {
|
||||
const tags: string[][] = [
|
||||
["relays", ...params.relays],
|
||||
["amount", params.amountMsats.toString()],
|
||||
["p", params.recipientPubkey],
|
||||
];
|
||||
|
||||
if (params.lnurl) tags.push(["lnurl", params.lnurl]);
|
||||
if (params.eventId) tags.push(["e", params.eventId]);
|
||||
if (params.addressableCoord) tags.push(["a", params.addressableCoord]);
|
||||
if (params.eventKind !== undefined) {
|
||||
tags.push(["k", params.eventKind.toString()]);
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 9734,
|
||||
content: params.comment || "",
|
||||
tags,
|
||||
pubkey: params.senderPubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Tag Reference
|
||||
|
||||
| Tag | Required | Format | Notes |
|
||||
| -------- | ----------- | ------------------------------------ | --------------------- |
|
||||
| `relays` | YES | `["relays", "wss://r1", "wss://r2"]` | NOT nested arrays |
|
||||
| `p` | YES | `["p", "<hex-pubkey>"]` | Exactly one |
|
||||
| `amount` | Recommended | `["amount", "21000"]` | Millisats as string |
|
||||
| `lnurl` | Recommended | `["lnurl", "lnurl1..."]` | Bech32-encoded |
|
||||
| `e` | Optional | `["e", "<hex-event-id>"]` | When zapping an event |
|
||||
| `a` | Optional | `["a", "30023:pubkey:d-tag"]` | Addressable events |
|
||||
| `k` | Optional | `["k", "1"]` | Kind of zapped event |
|
||||
|
||||
### Validation Rules for Zap Requests
|
||||
|
||||
The LNURL server validates incoming zap requests:
|
||||
|
||||
1. Valid Nostr signature
|
||||
2. Has tags
|
||||
3. Exactly one `p` tag
|
||||
4. Zero or one `e` tags
|
||||
5. Has a `relays` tag
|
||||
6. If `amount` tag exists, it MUST equal the `amount` query parameter
|
||||
7. If `a` tag exists, it MUST be a valid event coordinate
|
||||
8. Zero or one `P` tags
|
||||
|
||||
## Step 3: Send to Callback URL
|
||||
|
||||
```typescript
|
||||
async function requestInvoice(
|
||||
callback: string,
|
||||
signedZapRequest: ZapRequest,
|
||||
amountMsats: number,
|
||||
lnurl?: string,
|
||||
): Promise<string> {
|
||||
const params = new URLSearchParams({
|
||||
amount: amountMsats.toString(),
|
||||
nostr: JSON.stringify(signedZapRequest),
|
||||
});
|
||||
|
||||
if (lnurl) params.set("lnurl", lnurl);
|
||||
|
||||
const res = await fetch(`${callback}?${params.toString()}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.status === "ERROR") {
|
||||
throw new Error(`LNURL error: ${data.reason}`);
|
||||
}
|
||||
|
||||
return data.pr; // bolt11 invoice
|
||||
}
|
||||
```
|
||||
|
||||
**Critical:** The zap request is JSON-encoded, then sent as a query parameter.
|
||||
It is NOT published to any relay.
|
||||
|
||||
## Step 4: Pay the Invoice
|
||||
|
||||
Pass the bolt11 invoice string to a Lightning wallet or payment library. This
|
||||
step is outside the Nostr protocol — use whatever Lightning integration your
|
||||
application supports (WebLN, NWC, direct LND/CLN API, etc.).
|
||||
|
||||
## Step 5: Zap Receipt Creation (Server-Side)
|
||||
|
||||
After the invoice is paid, the recipient's LNURL server creates a kind:9735
|
||||
event:
|
||||
|
||||
```typescript
|
||||
function buildZapReceipt(params: {
|
||||
serverPubkey: string; // The LNURL server's nostrPubkey
|
||||
zapRequest: ZapRequest;
|
||||
bolt11: string;
|
||||
preimage?: string;
|
||||
paidAt: number;
|
||||
}): Omit<NostrEvent, "id" | "sig"> {
|
||||
const zapReq = params.zapRequest;
|
||||
const recipientPubkey = zapReq.tags.find((t) => t[0] === "p")?.[1];
|
||||
const senderPubkey = zapReq.pubkey;
|
||||
const eventId = zapReq.tags.find((t) => t[0] === "e")?.[1];
|
||||
const aTag = zapReq.tags.find((t) => t[0] === "a");
|
||||
|
||||
const tags: string[][] = [
|
||||
["p", recipientPubkey!],
|
||||
["P", senderPubkey],
|
||||
["bolt11", params.bolt11],
|
||||
["description", JSON.stringify(zapReq)],
|
||||
];
|
||||
|
||||
if (eventId) tags.push(["e", eventId]);
|
||||
if (aTag) tags.push(aTag);
|
||||
if (params.preimage) tags.push(["preimage", params.preimage]);
|
||||
|
||||
// Amount from the zap request
|
||||
const amount = zapReq.tags.find((t) => t[0] === "amount")?.[1];
|
||||
if (amount) tags.push(["amount", amount]);
|
||||
|
||||
return {
|
||||
kind: 9735,
|
||||
content: "",
|
||||
tags,
|
||||
pubkey: params.serverPubkey,
|
||||
created_at: params.paidAt,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
The receipt is published to the relays specified in the zap request's `relays`
|
||||
tag.
|
||||
|
||||
## Step 6: Validate Zap Receipts (Client-Side)
|
||||
|
||||
```typescript
|
||||
interface ZapValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
zapRequest?: ZapRequest;
|
||||
amountMsats?: number;
|
||||
senderPubkey?: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
function validateZapReceipt(
|
||||
receipt: NostrEvent,
|
||||
expectedNostrPubkey: string,
|
||||
): ZapValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
// 1. Check receipt pubkey matches LNURL nostrPubkey
|
||||
if (receipt.pubkey !== expectedNostrPubkey) {
|
||||
errors.push(
|
||||
`Receipt pubkey ${receipt.pubkey} does not match expected ${expectedNostrPubkey}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Parse the embedded zap request
|
||||
const descriptionTag = receipt.tags.find((t) => t[0] === "description");
|
||||
if (!descriptionTag) {
|
||||
errors.push("Missing description tag");
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
let zapRequest: ZapRequest;
|
||||
try {
|
||||
zapRequest = JSON.parse(descriptionTag[1]);
|
||||
} catch {
|
||||
errors.push("Invalid JSON in description tag");
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
if (zapRequest.kind !== 9734) {
|
||||
errors.push(`Zap request kind is ${zapRequest.kind}, expected 9734`);
|
||||
}
|
||||
|
||||
// 3. Verify amount matches
|
||||
const bolt11Tag = receipt.tags.find((t) => t[0] === "bolt11");
|
||||
const requestAmount = zapRequest.tags.find((t) => t[0] === "amount")?.[1];
|
||||
|
||||
if (bolt11Tag && requestAmount) {
|
||||
const invoiceAmount = decodeBolt11Amount(bolt11Tag[1]);
|
||||
if (invoiceAmount !== Number(requestAmount)) {
|
||||
errors.push(
|
||||
`Invoice amount ${invoiceAmount} does not match request amount ${requestAmount}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Verify description hash (SHOULD check)
|
||||
if (bolt11Tag) {
|
||||
const descHash = sha256(descriptionTag[1]);
|
||||
const bolt11DescHash = extractDescriptionHash(bolt11Tag[1]);
|
||||
if (bolt11DescHash && descHash !== bolt11DescHash) {
|
||||
errors.push("Description hash mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
zapRequest,
|
||||
amountMsats: requestAmount ? Number(requestAmount) : undefined,
|
||||
senderPubkey: zapRequest.pubkey,
|
||||
comment: zapRequest.content || undefined,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Fetching Zap Receipts
|
||||
|
||||
```typescript
|
||||
// Zaps on a specific event
|
||||
const filter = { kinds: [9735], "#e": [eventId] };
|
||||
|
||||
// Zaps on a user profile
|
||||
const filter = { kinds: [9735], "#p": [pubkey] };
|
||||
```
|
||||
|
||||
## Zap Splits Implementation
|
||||
|
||||
When an event has multiple `zap` tags:
|
||||
|
||||
```typescript
|
||||
function calculateZapSplits(
|
||||
event: NostrEvent,
|
||||
totalAmountMsats: number,
|
||||
): { pubkey: string; relay: string; amountMsats: number }[] {
|
||||
const zapTags = event.tags.filter((t) => t[0] === "zap");
|
||||
if (zapTags.length === 0) return [];
|
||||
|
||||
const totalWeight = zapTags.reduce((sum, t) => sum + Number(t[3] || 0), 0);
|
||||
|
||||
if (totalWeight === 0) {
|
||||
// No weights: divide equally
|
||||
const perRecipient = Math.floor(totalAmountMsats / zapTags.length);
|
||||
return zapTags.map((t) => ({
|
||||
pubkey: t[1],
|
||||
relay: t[2],
|
||||
amountMsats: perRecipient,
|
||||
}));
|
||||
}
|
||||
|
||||
return zapTags
|
||||
.filter((t) => Number(t[3] || 0) > 0) // Skip zero-weight recipients
|
||||
.map((t) => ({
|
||||
pubkey: t[1],
|
||||
relay: t[2],
|
||||
amountMsats: Math.floor(totalAmountMsats * Number(t[3]) / totalWeight),
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
Each split recipient gets a separate zap request → callback → invoice → payment
|
||||
cycle.
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -7,10 +7,13 @@ import { service } from '@ember/service';
|
||||
import { modifier } from 'ember-modifier';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { EventFactory } from 'applesauce-core';
|
||||
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';
|
||||
|
||||
const GalleryContent = <template>
|
||||
<div
|
||||
@@ -25,26 +28,48 @@ const GalleryContent = <template>
|
||||
class="photo-gallery-content"
|
||||
data-current-event-id={{@currentPhoto.eventId}}
|
||||
>
|
||||
<div class="actions-btn-container">
|
||||
<DropdownMenu
|
||||
@iconSize={{24}}
|
||||
@triggerIcon="more-horizontal"
|
||||
@iconColor="white"
|
||||
as |closeMenu|
|
||||
>
|
||||
<button
|
||||
class="dropdown-item"
|
||||
type="button"
|
||||
{{on "click" (fn @copyEventId closeMenu)}}
|
||||
>Copy Photo Event ID</button>
|
||||
{{#if @canDeletePhoto}}
|
||||
<div class="photo-gallery-header">
|
||||
<div class="actions-btn-container">
|
||||
<DropdownMenu
|
||||
@iconSize={{24}}
|
||||
@triggerIcon={{@triggerIcon}}
|
||||
@iconColor="white"
|
||||
as |closeMenu|
|
||||
>
|
||||
<button
|
||||
class="dropdown-item text-danger"
|
||||
class="dropdown-item"
|
||||
type="button"
|
||||
{{on "click" (fn @deletePhotoTask.perform closeMenu)}}
|
||||
>Delete Photo</button>
|
||||
{{/if}}
|
||||
</DropdownMenu>
|
||||
{{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"
|
||||
type="button"
|
||||
{{on "click" (fn @deletePhotoTask.perform closeMenu)}}
|
||||
>Delete Photo</button>
|
||||
{{/if}}
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{{#if (or @uploaderName @photoDate)}}
|
||||
<div class="photo-gallery-uploader-info">
|
||||
{{#if @uploaderName}}
|
||||
<span class="photo-gallery-uploader-name">{{@uploaderName}}</span>
|
||||
{{/if}}
|
||||
{{#if @photoDate}}
|
||||
<span class="photo-gallery-uploader-date">
|
||||
{{formatRelativeDate @photoDate}}
|
||||
</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -77,6 +102,10 @@ const GalleryContent = <template>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#if @zapModalOpen}}
|
||||
<ZapPhotoModal @photo={{@currentPhoto}} @onClose={{@closeZap}} />
|
||||
{{/if}}
|
||||
</div>
|
||||
</template>;
|
||||
|
||||
@@ -93,10 +122,19 @@ 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) {
|
||||
return 'more-vertical';
|
||||
}
|
||||
return 'more-horizontal';
|
||||
}
|
||||
|
||||
get isCreator() {
|
||||
return (
|
||||
@@ -112,6 +150,27 @@ 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;
|
||||
const profile = this.nostrData.getProfile?.(pubkey);
|
||||
if (!profile) return null;
|
||||
return profile.displayName || profile.display_name || profile.name || null;
|
||||
}
|
||||
|
||||
get photoDate() {
|
||||
const ts = this.currentPhoto?.publishedAt || this.currentPhoto?.createdAt;
|
||||
return ts || null;
|
||||
}
|
||||
|
||||
bindKeyboard = modifier((element, [handler]) => {
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
@@ -131,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('.actions-btn-container')
|
||||
e.target.closest('.photo-gallery-header') ||
|
||||
e.target.closest('.modal-overlay')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -187,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(
|
||||
@@ -268,6 +340,13 @@ export default class PhotoGallery extends Component {
|
||||
@handleVisiblePhotoChange={{this.handleVisiblePhotoChange}}
|
||||
@placeName={{@placeName}}
|
||||
@selectPhoto={{this.selectPhoto}}
|
||||
@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}}
|
||||
@@ -284,6 +363,13 @@ export default class PhotoGallery extends Component {
|
||||
@handleVisiblePhotoChange={{this.handleVisiblePhotoChange}}
|
||||
@placeName={{@placeName}}
|
||||
@selectPhoto={{this.selectPhoto}}
|
||||
@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>
|
||||
}
|
||||
+185
-7
@@ -1,10 +1,12 @@
|
||||
import Service, { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { EMPTY, from } from 'rxjs';
|
||||
import { EventStore } from 'applesauce-core/event-store';
|
||||
import { ProfileModel } from 'applesauce-core/models/profile';
|
||||
import { MailboxesModel } from 'applesauce-core/models/mailboxes';
|
||||
import { npubEncode } from 'applesauce-core/helpers/pointers';
|
||||
import { persistEventsToCache } from 'applesauce-core/helpers/event-cache';
|
||||
import { createEventLoaderForStore } from 'applesauce-loaders/loaders';
|
||||
import { NostrIDB, openDB } from 'nostr-idb';
|
||||
import {
|
||||
excludeRequiredRelays,
|
||||
@@ -15,9 +17,9 @@ import {
|
||||
import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage';
|
||||
|
||||
const DIRECTORY_RELAYS = [
|
||||
'wss://purplepag.es',
|
||||
'wss://relay.damus.io',
|
||||
'wss://relay.primal.net',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.damus.io',
|
||||
];
|
||||
|
||||
const DEFAULT_READ_RELAYS = ['wss://nostr.kosmos.org'];
|
||||
@@ -35,20 +37,42 @@ export default class NostrDataService extends Service {
|
||||
@tracked blossomServers = [];
|
||||
@tracked placePhotos = [];
|
||||
@tracked myContributionEvents = [];
|
||||
@tracked profiles = {};
|
||||
@tracked zapReceipts = {};
|
||||
|
||||
_profileSub = null;
|
||||
_mailboxesSub = null;
|
||||
_blossomSub = null;
|
||||
_photosSub = null;
|
||||
_contributionsSub = null;
|
||||
_profileModelSubs = new Map();
|
||||
|
||||
_zapReceiptsSub = null;
|
||||
_zapReceiptsNetworkSub = null;
|
||||
_zapRefreshTimer = null;
|
||||
_lastPhotoIds = new Set();
|
||||
|
||||
_requestSub = null;
|
||||
_cachePromise = null;
|
||||
_currentPlaceEntityId = null;
|
||||
loadedGeohashPrefixes = new Set();
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
// Set up the event loader synchronously so that any subscription
|
||||
// (e.g. loadProfiles from a route's afterModel) can auto-fetch even
|
||||
// before the IndexedDB cache has finished opening. The cacheRequest
|
||||
// is lazy — it returns EMPTY until `this.cache` is available, so the
|
||||
// loader falls through to relay hints → lookup relays in the meantime.
|
||||
createEventLoaderForStore(this.store, this.nostrRelay.pool, {
|
||||
cacheRequest: (filters) => {
|
||||
if (!this.cache) return EMPTY;
|
||||
return from(this.cache.query(filters));
|
||||
},
|
||||
lookupRelays: DIRECTORY_RELAYS,
|
||||
});
|
||||
|
||||
// Initialize the IndexedDB cache
|
||||
this._cachePromise = openDB('applesauce-events').then(async (db) => {
|
||||
this.cache = new NostrIDB(db, {
|
||||
@@ -69,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) {
|
||||
@@ -212,19 +237,36 @@ export default class NostrDataService extends Service {
|
||||
}
|
||||
|
||||
async loadPhotosForPlace(place) {
|
||||
const entityId =
|
||||
place && place.osmId && place.osmType
|
||||
? `osm:${place.osmType}:${place.osmId}`
|
||||
: null;
|
||||
|
||||
// Skip the full reset if we're loading the same place again (e.g. from
|
||||
// checkUpdates calling selectPlace a second time). This prevents tearing
|
||||
// down timeline and profile subscriptions that are still in-flight.
|
||||
if (entityId && entityId === this._currentPlaceEntityId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._photosSub) {
|
||||
this._photosSub.unsubscribe();
|
||||
this._photosSub = null;
|
||||
}
|
||||
|
||||
this.placePhotos = [];
|
||||
this._cleanupZapReceiptSubs();
|
||||
this._clearZapRefreshTimer();
|
||||
|
||||
if (!place || !place.osmId || !place.osmType) {
|
||||
this.placePhotos = [];
|
||||
this.zapReceipts = {};
|
||||
this._lastPhotoIds = new Set();
|
||||
this._clearProfileSubs();
|
||||
this._currentPlaceEntityId = entityId;
|
||||
|
||||
if (!entityId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entityId = `osm:${place.osmType}:${place.osmId}`;
|
||||
|
||||
// Setup reactive store query
|
||||
this._photosSub = this.store
|
||||
.timeline([
|
||||
@@ -235,6 +277,9 @@ export default class NostrDataService extends Service {
|
||||
])
|
||||
.subscribe((events) => {
|
||||
this.placePhotos = events;
|
||||
const pubkeys = [...new Set(events.map((e) => e.pubkey))];
|
||||
this.loadProfiles(pubkeys);
|
||||
this._scheduleZapReceiptRefresh(events);
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -331,6 +376,33 @@ export default class NostrDataService extends Service {
|
||||
});
|
||||
}
|
||||
|
||||
loadProfiles(pubkeys) {
|
||||
const newPubkeys = pubkeys.filter(
|
||||
(pk) => pk && !this._profileModelSubs.has(pk)
|
||||
);
|
||||
|
||||
for (const pubkey of newPubkeys) {
|
||||
const sub = this.store
|
||||
.model(ProfileModel, pubkey)
|
||||
.subscribe((profileContent) => {
|
||||
this.profiles = { ...this.profiles, [pubkey]: profileContent };
|
||||
});
|
||||
this._profileModelSubs.set(pubkey, sub);
|
||||
}
|
||||
}
|
||||
|
||||
getProfile(pubkey) {
|
||||
return this.profiles[pubkey];
|
||||
}
|
||||
|
||||
_clearProfileSubs() {
|
||||
for (const sub of this._profileModelSubs.values()) {
|
||||
sub.unsubscribe();
|
||||
}
|
||||
this._profileModelSubs.clear();
|
||||
this.profiles = {};
|
||||
}
|
||||
|
||||
async loadProfile(pubkey) {
|
||||
if (!pubkey) return;
|
||||
|
||||
@@ -441,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();
|
||||
@@ -466,11 +641,14 @@ export default class NostrDataService extends Service {
|
||||
this._contributionsSub.unsubscribe();
|
||||
this._contributionsSub = null;
|
||||
}
|
||||
this._cleanupZapReceiptSubs();
|
||||
this._clearZapRefreshTimer();
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
super.willDestroy(...arguments);
|
||||
this._cleanupSubscriptions();
|
||||
this._clearProfileSubs();
|
||||
|
||||
if (this._stopPersisting) {
|
||||
this._stopPersisting();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
+203
-6
@@ -2151,17 +2151,68 @@ button.create-place {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
/* Actions button in photo gallery */
|
||||
.photo-gallery-overlay .actions-btn-container {
|
||||
/* Photo gallery header (actions button + uploader info) */
|
||||
.photo-gallery-overlay .photo-gallery-header {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
left: 0.5rem;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
left: 1rem;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.photo-gallery-overlay .photo-gallery-header .actions-btn-container {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Uploader info (name + date) in photo gallery */
|
||||
.photo-gallery-overlay .photo-gallery-uploader-info {
|
||||
color: rgb(255 255 255 / 90%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
pointer-events: none;
|
||||
text-shadow: 0 1px 2px rgb(0 0 0 / 60%);
|
||||
max-width: calc(100vw - 4rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.photo-gallery-overlay .photo-gallery-uploader-name {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.photo-gallery-overlay .photo-gallery-uploader-date {
|
||||
font-size: 0.8rem;
|
||||
color: rgb(255 255 255 / 70%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.photo-gallery-overlay .photo-gallery-header {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
left: 0.5rem;
|
||||
}
|
||||
|
||||
.photo-gallery-overlay .photo-gallery-uploader-info {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Snappy slide-in from left (Desktop) */
|
||||
@@ -2375,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,11 +94,14 @@ function parsePhotoFromEvent(event) {
|
||||
if (!url) return null;
|
||||
|
||||
const placeIdentifier = tags.find((t) => t[0] === 'i')?.[1];
|
||||
const publishedAtRaw = tags.find((t) => t[0] === 'published_at')?.[1];
|
||||
const publishedAt = publishedAtRaw ? Number(publishedAtRaw) : null;
|
||||
|
||||
return {
|
||||
eventId: event.id,
|
||||
pubkey: event.pubkey,
|
||||
createdAt: event.created_at,
|
||||
publishedAt: publishedAt && publishedAt > 0 ? publishedAt : null,
|
||||
url,
|
||||
thumbUrl,
|
||||
blurhash,
|
||||
|
||||
@@ -69,6 +69,10 @@ export function parsePlacePhotos(events) {
|
||||
let aspectRatio = 16 / 9; // default
|
||||
let altText = null;
|
||||
let placeIdentifier = event.tags.find((t) => t[0] === 'i')?.[1];
|
||||
const publishedAtRaw = event.tags.find(
|
||||
(t) => t[0] === 'published_at'
|
||||
)?.[1];
|
||||
const publishedAt = publishedAtRaw ? Number(publishedAtRaw) : null;
|
||||
|
||||
for (const tag of imeta.slice(1)) {
|
||||
if (tag.startsWith('url ')) {
|
||||
@@ -98,6 +102,7 @@ export function parsePlacePhotos(events) {
|
||||
eventId: event.id,
|
||||
pubkey: event.pubkey,
|
||||
createdAt: event.created_at,
|
||||
publishedAt: publishedAt && publishedAt > 0 ? publishedAt : null,
|
||||
url,
|
||||
thumbUrl,
|
||||
blurhash,
|
||||
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "marco",
|
||||
"version": "1.27.1",
|
||||
"version": "1.29.0",
|
||||
"private": true,
|
||||
"description": "Unhosted maps app",
|
||||
"repository": {
|
||||
@@ -104,7 +104,9 @@
|
||||
"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",
|
||||
"applesauce-signers": "^6.2.2",
|
||||
"blurhash": "^2.0.5",
|
||||
@@ -115,5 +117,6 @@
|
||||
"oauth2-pkce": "^3.0.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"rxjs": "^7.8.2"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@11.22.0+sha512.1ff870c4c6133dfd88fb2afc46dd13d47f09c9794b438c6fdb47ca98caf3bc16381ee0be93a091b8e3824cf01f889f46d7d9e20910fb0be1ab0fb5baa80dd621"
|
||||
}
|
||||
|
||||
Generated
+40
@@ -17,9 +17,15 @@ 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)
|
||||
applesauce-loaders:
|
||||
specifier: ^6.2.0
|
||||
version: 6.2.0(supports-color@10.2.2)(typescript@5.9.3)
|
||||
applesauce-relay:
|
||||
specifier: ^6.2.1
|
||||
version: 6.2.1(supports-color@10.2.2)(typescript@5.9.3)
|
||||
@@ -2633,9 +2639,15 @@ 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==}
|
||||
|
||||
applesauce-loaders@6.2.0:
|
||||
resolution: {integrity: sha512-isU2BuoVFhugv4WxdbUFrqm7mCNgsGrMU4BYcNqMm8qAVpVb3L7K3I1hlkIZWwOvNTkhoYO/u0utgwnkQj4Hrg==}
|
||||
|
||||
applesauce-relay@6.2.1:
|
||||
resolution: {integrity: sha512-YIUHEtL2Fl5FZjeKYe/IuwL3OnMUNP2j4ydSr3iZDeHEZSEda4d1PoChefiNhtjE/U/F5OBHEbAX1zCHRZoxpg==}
|
||||
|
||||
@@ -4727,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'}
|
||||
@@ -9342,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)
|
||||
@@ -9354,6 +9381,15 @@ snapshots:
|
||||
- supports-color
|
||||
- typescript
|
||||
|
||||
applesauce-loaders@6.2.0(supports-color@10.2.2)(typescript@5.9.3):
|
||||
dependencies:
|
||||
applesauce-core: 6.2.0(supports-color@10.2.2)(typescript@5.9.3)
|
||||
nanoid: 5.1.9
|
||||
rxjs: 7.8.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
- typescript
|
||||
|
||||
applesauce-relay@6.2.1(supports-color@10.2.2)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@noble/hashes': 2.3.0
|
||||
@@ -11959,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
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -39,8 +39,8 @@
|
||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||
|
||||
<script type="module" crossorigin src="/assets/main-D3dQ-CGf.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-P9lvOtv_.css">
|
||||
<script type="module" crossorigin src="/assets/main-Cx0KJENk.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-CHNrhL7t.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="modal-portal"></div>
|
||||
|
||||
+10
-1
@@ -4,17 +4,26 @@
|
||||
"ember-best-practices": {
|
||||
"source": "nullvoxpopuli/agent-skills",
|
||||
"sourceType": "github",
|
||||
"computedHash": "7909c3def6c4ddefb358d1973cf724269ede9f6cdba1dd2888e4e6072a897f3e"
|
||||
"skillPath": "skills/ember-best-practices/SKILL.md",
|
||||
"computedHash": "34e82668e2fa1e6b025ac8cf1015634484bf5d8fbc80e8aa642489d2df79498c"
|
||||
},
|
||||
"nak": {
|
||||
"source": "soapbox-pub/nostr-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/nak/SKILL.md",
|
||||
"computedHash": "710d3f3945ff421ed2b7f40ecd32c5e263bc029d43fe8f4fd1491a8013c7389a"
|
||||
},
|
||||
"nostr": {
|
||||
"source": "soapbox-pub/nostr-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/nostr/SKILL.md",
|
||||
"computedHash": "e1e6834c18d18a5deef4cd9555f6eee0fc0b968acf1c619253999eda76beab8e"
|
||||
},
|
||||
"nostr-zap-integration": {
|
||||
"source": "accolver/skill-maker",
|
||||
"sourceType": "github",
|
||||
"skillPath": "nostr-zap-integration/SKILL.md",
|
||||
"computedHash": "5c0c9a97fb74b6b620340bdc522fc3e813dae34a2af87cfda764733d2632229c"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
@@ -36,11 +41,27 @@ export class MockNostrDataService extends Service {
|
||||
@tracked mailboxes = null;
|
||||
@tracked blossomServers = [];
|
||||
@tracked placePhotos = [];
|
||||
@tracked profiles = {};
|
||||
@tracked zapReceipts = {};
|
||||
|
||||
store = {
|
||||
add: () => {},
|
||||
timeline: () => ({
|
||||
subscribe: () => ({
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
}),
|
||||
replaceable: () => ({
|
||||
subscribe: () => ({
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
getProfile(pubkey) {
|
||||
return this.profiles[pubkey];
|
||||
}
|
||||
|
||||
get activeReadRelays() {
|
||||
return [];
|
||||
}
|
||||
@@ -95,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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -339,4 +339,203 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
await triggerKeyEvent(document, 'keydown', 'Escape');
|
||||
assert.ok(closed, 'gallery was closed on escape key');
|
||||
});
|
||||
|
||||
test('it renders uploader name and date when profile is loaded', async function (assert) {
|
||||
const displayName = 'Alice';
|
||||
const publishedAt = Math.floor(Date.now() / 1000) - 60 * 60 * 24; // 1 day ago
|
||||
|
||||
this.nostrData.profiles = {
|
||||
[USER_A]: { displayName, display_name: 'ignored', name: 'ignored' },
|
||||
};
|
||||
|
||||
this.photos = [
|
||||
{
|
||||
eventId: 'event1',
|
||||
pubkey: USER_A,
|
||||
placeIdentifier: 'osm:node:12345',
|
||||
url: 'https://example.com/photo.jpg',
|
||||
publishedAt,
|
||||
createdAt: publishedAt + 10,
|
||||
},
|
||||
];
|
||||
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>
|
||||
);
|
||||
|
||||
assert
|
||||
.dom('.photo-gallery-uploader-name')
|
||||
.hasText(displayName, 'uploader name is rendered');
|
||||
assert
|
||||
.dom('.photo-gallery-uploader-date')
|
||||
.exists('date element is rendered');
|
||||
});
|
||||
|
||||
test('it prefers published_at over created_at for the date', async function (assert) {
|
||||
this.nostrData.profiles = {
|
||||
[USER_A]: { displayName: 'Alice' },
|
||||
};
|
||||
|
||||
const publishedAt = Math.floor(Date.now() / 1000) - 60 * 60 * 24 * 3; // 3 days ago
|
||||
const createdAt = Math.floor(Date.now() / 1000) - 60; // 1 min ago
|
||||
|
||||
this.photos = [
|
||||
{
|
||||
eventId: 'event1',
|
||||
pubkey: USER_A,
|
||||
url: 'https://example.com/photo.jpg',
|
||||
publishedAt,
|
||||
createdAt,
|
||||
},
|
||||
];
|
||||
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>
|
||||
);
|
||||
|
||||
// Should show "3 days ago" (publishedAt), not "just now" (createdAt)
|
||||
assert.dom('.photo-gallery-uploader-date').hasText('3 days ago');
|
||||
});
|
||||
|
||||
test('it does not render uploader info when profile is missing', async function (assert) {
|
||||
this.nostrData.profiles = {};
|
||||
|
||||
this.photos = [
|
||||
{
|
||||
eventId: 'event1',
|
||||
pubkey: USER_A,
|
||||
url: 'https://example.com/photo.jpg',
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
];
|
||||
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>
|
||||
);
|
||||
|
||||
assert
|
||||
.dom('.photo-gallery-uploader-name')
|
||||
.doesNotExist('uploader name is not rendered without profile');
|
||||
// Date should still render since createdAt is present
|
||||
assert.dom('.photo-gallery-uploader-date').exists('date is still rendered');
|
||||
});
|
||||
|
||||
test('it falls back through displayName -> display_name -> name', async function (assert) {
|
||||
this.nostrData.profiles = {
|
||||
[USER_A]: { display_name: 'Bob', name: 'Robert' },
|
||||
};
|
||||
|
||||
this.photos = [
|
||||
{
|
||||
eventId: 'event1',
|
||||
pubkey: USER_A,
|
||||
url: 'https://example.com/photo.jpg',
|
||||
createdAt: Math.floor(Date.now() / 1000) - 60,
|
||||
},
|
||||
];
|
||||
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>
|
||||
);
|
||||
|
||||
assert
|
||||
.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');
|
||||
});
|
||||
});
|
||||
@@ -210,6 +210,66 @@ module('Unit | Utility | nostr', function () {
|
||||
assert.strictEqual(photos[0].alt, null);
|
||||
});
|
||||
|
||||
test('parsePlacePhotos extracts published_at when present', function (assert) {
|
||||
const events = [
|
||||
{
|
||||
id: 'event-1',
|
||||
pubkey: 'pubkey-1',
|
||||
created_at: 200,
|
||||
tags: [
|
||||
['i', 'osm:node:123'],
|
||||
['published_at', '100'],
|
||||
['imeta', 'url https://example.com/photo.jpg', 'dim 800x600'],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const photos = parsePlacePhotos(events);
|
||||
|
||||
assert.strictEqual(photos.length, 1);
|
||||
assert.strictEqual(photos[0].publishedAt, 100);
|
||||
assert.strictEqual(photos[0].createdAt, 200);
|
||||
});
|
||||
|
||||
test('parsePlacePhotos sets publishedAt to null when not present', function (assert) {
|
||||
const events = [
|
||||
{
|
||||
id: 'event-1',
|
||||
pubkey: 'pubkey-1',
|
||||
created_at: 200,
|
||||
tags: [
|
||||
['i', 'osm:node:123'],
|
||||
['imeta', 'url https://example.com/photo.jpg', 'dim 800x600'],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const photos = parsePlacePhotos(events);
|
||||
|
||||
assert.strictEqual(photos.length, 1);
|
||||
assert.strictEqual(photos[0].publishedAt, null);
|
||||
});
|
||||
|
||||
test('parsePlacePhotos ignores invalid published_at values', function (assert) {
|
||||
const events = [
|
||||
{
|
||||
id: 'event-1',
|
||||
pubkey: 'pubkey-1',
|
||||
created_at: 200,
|
||||
tags: [
|
||||
['i', 'osm:node:123'],
|
||||
['published_at', 'not-a-number'],
|
||||
['imeta', 'url https://example.com/photo.jpg', 'dim 800x600'],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const photos = parsePlacePhotos(events);
|
||||
|
||||
assert.strictEqual(photos.length, 1);
|
||||
assert.strictEqual(photos[0].publishedAt, null);
|
||||
});
|
||||
|
||||
test('uniqNormalizedRelays returns normalized unique relays', function (assert) {
|
||||
const relays = uniqNormalizedRelays([
|
||||
'Relay.example.com',
|
||||
|
||||
Reference in New Issue
Block a user