Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da752f7807
|
||
|
|
76867db1dd
|
||
|
|
23444a49a8
|
||
|
|
919c0335eb
|
||
|
|
d0252309c4
|
||
|
|
c7fc98d4bd
|
||
|
|
3f6bd5267f
|
||
|
|
fd1b570717
|
||
|
|
6d68a6240b
|
||
|
|
0ebe565cbc
|
||
|
|
e7020469c3
|
||
|
|
bf3011115b
|
||
|
|
8c98e0757b
|
||
|
|
2ae9a78f5d
|
||
|
|
252f41f897
|
||
|
|
572213eda2
|
||
|
|
ac7a4eb262
|
||
|
|
7d19068777
|
||
|
|
17dd721986
|
||
|
|
474bc8cd63
|
||
|
|
3cc5e8834e
|
||
|
|
45c18100d3
|
||
|
|
17db6a5f65
|
||
|
|
7f9ee29136
|
||
|
|
81bbc03f54
|
||
|
|
bf1fcbcb40
|
||
|
|
5d9c6ebc89
|
||
|
|
2690c5470f
|
||
|
|
46bb2efc70
|
||
|
|
6feb776247
|
||
|
|
2746ec035d
|
||
|
|
cdeb4f5d8c
|
||
|
|
8494f5a084
|
||
|
|
bc433c8b1e
|
||
|
|
c3af496dfd
|
||
|
|
67e4eaddbb
|
||
|
|
beda860093
|
||
|
|
82bc6a424e
|
||
|
|
74e9860e08
|
||
|
|
135a30f1cd
|
||
|
|
99ebdf3378
|
||
|
|
20bac8ae31
|
||
|
|
2869ed6677
|
||
|
|
644d6a2856
|
||
|
|
09aefa6e19
|
||
|
|
c5198b39b8
|
||
|
|
1387f955d0
|
||
|
|
2d547dde87
|
||
|
|
85334ec15d
|
||
|
|
bfcf855020
|
||
|
|
0f0874a07a
|
||
|
|
c9a6304926
|
||
|
|
6fb4ecede3
|
||
|
|
77db625b11
|
||
|
|
6351cfbd07
|
||
|
|
b7e46da3e9
|
||
|
|
3233150cc4
|
||
|
|
822472a0e6
|
||
|
|
7ede81fe5f
|
||
|
|
3d2b723e05
|
||
|
|
10ecb745ee
|
||
|
|
59243d7703
|
||
|
|
5bdb90c6f0
|
||
|
|
55a4a7a2da
|
||
|
|
1de276fcc8
|
||
|
|
61242420d1
|
||
|
|
45f6f898fa
|
||
|
|
21d261ef17
|
||
|
|
3d578177ab
|
||
|
|
df9831b7c3
|
||
|
|
78687b63ff
|
||
|
|
d6e47a1b27
|
||
|
|
42cd620c48
|
||
|
|
b9e5213c27
|
||
|
|
d6fddddd4e
|
||
|
|
335ff1a9d8
|
||
|
|
9dc080b86e
|
||
|
|
a457e1521b
|
||
|
|
39ab5e2cd1
|
||
|
|
ce53d42581
|
||
|
|
387fd6efed
|
||
|
|
aaf0d8c3e9
|
||
|
|
9807a2b822
|
||
|
|
ac9870240f
|
||
|
|
1b185d1033
|
||
|
|
4d4ed8c1ea
|
||
|
|
1613ebf0bf
|
||
|
|
4cb8c94c9c
|
||
|
|
1f87c802e1
|
||
|
|
a6576fcda8
|
||
|
|
1822c4893f
|
||
|
|
39dca446ac
|
||
|
|
2e6827f0d0
|
||
|
|
8c3a805684
|
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/)
|
||||
|
||||
@@ -1,359 +0,0 @@
|
||||
---
|
||||
name: nak
|
||||
description: Interact with Nostr protocol using the nak CLI tool. Use to generate Nostr secret keys, encode and decode Nostr identifiers (hex/npub/nsec/nip05/etc), fetch events from relays, sign and publish Nostr events, and more.
|
||||
license: CC-BY-SA-4.0
|
||||
---
|
||||
|
||||
# nak - Nostr Army Knife
|
||||
|
||||
Work with the Nostr protocol using the `nak` CLI tool.
|
||||
|
||||
GitHub: https://github.com/fiatjaf/nak
|
||||
|
||||
## Installation
|
||||
|
||||
To install (or upgrade) nak:
|
||||
|
||||
```bash
|
||||
curl -sSL https://raw.githubusercontent.com/fiatjaf/nak/master/install.sh | sh
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
|
||||
Based on analyzing extensive real-world usage, here are the fundamental concepts distilled down, then branched out:
|
||||
|
||||
---
|
||||
|
||||
### CONCEPT 1: Query & Discovery
|
||||
*"Finding events and data on Nostr"*
|
||||
|
||||
**Basic:**
|
||||
- Fetch by identifier: "Get event/profile without knowing relays"
|
||||
- `nak fetch nevent1...` (uses embedded relay hints)
|
||||
- `nak fetch alex@gleasonator.dev` (NIP-05 resolution)
|
||||
- Query by event ID: "I want THIS specific event from a relay"
|
||||
- `nak req -i <event_id> <relay>`
|
||||
- Query by author: "Show me everything from this person"
|
||||
- `nak req -a <pubkey> <relay>`
|
||||
- Query by kind: "Show me all profiles/notes/videos"
|
||||
- `nak req -k <kind> <relay>`
|
||||
|
||||
**Intermediate:**
|
||||
- Fetch addressable events: "Get event by naddr/nprofile"
|
||||
- `nak fetch naddr1...` (kind, author, identifier encoded)
|
||||
- `nak fetch -r relay.primal.net naddr1...` (override relays)
|
||||
- Filter by multiple criteria: "Find posts by author X of kind Y"
|
||||
- `nak req -k 1 -a <pubkey> <relay>`
|
||||
- Tag-based queries: "Find events tagged with bitcoin"
|
||||
- `nak req --tag t=bitcoin <relay>`
|
||||
|
||||
**Advanced:**
|
||||
- Search with ranking: "Find trending/top content"
|
||||
- `nak req --search "sort:hot" <relay>`
|
||||
- `nak req --search "popular:24h" <relay>`
|
||||
- Live monitoring: "Watch events in real-time"
|
||||
- `nak req --stream <relay>`
|
||||
- Cross-protocol queries: "Find bridged Bluesky content"
|
||||
- `nak req --tag "proxy=at://..." <relay>`
|
||||
|
||||
**Use cases:** Discovering content, debugging relay data, testing search algorithms, monitoring live feeds
|
||||
|
||||
---
|
||||
|
||||
### CONCEPT 2: Broadcast & Migration
|
||||
*"Moving/copying events between relays"*
|
||||
|
||||
**Basic:**
|
||||
- Publish a single event: "Put this on relay X"
|
||||
- `cat event.json | nak event <relay>`
|
||||
- Query and republish: "Copy event from relay A to relay B"
|
||||
- `nak req -i <id> relay1 | nak event relay2`
|
||||
|
||||
**Intermediate:**
|
||||
- Batch migration: "Copy all events of kind X"
|
||||
- `nak req -k 30717 source_relay | nak event dest_relay`
|
||||
- Paginated backup: "Download everything from a relay"
|
||||
- `nak req --paginate source_relay | nak event backup_relay`
|
||||
- Multi-relay broadcast: "Publish to multiple relays at once"
|
||||
- `cat event.json | nak event relay1 relay2 relay3`
|
||||
|
||||
**Advanced:**
|
||||
- Selective migration: "Copy follow list members' data"
|
||||
- Loop through follow list, query each author, republish to new relay
|
||||
- Filter and migrate: "Copy only tagged/searched content"
|
||||
- `nak req --tag client=X relay1 | nak event relay2`
|
||||
- Cross-relay synchronization: "Keep two relays in sync"
|
||||
- `nak sync source_relay dest_relay`
|
||||
|
||||
**Use cases:** Seeding new relays, backing up data, migrating content between relays, bridging Mostr/Fediverse content
|
||||
|
||||
---
|
||||
|
||||
### CONCEPT 3: Identity & Encoding
|
||||
*"Working with Nostr identifiers and keys"*
|
||||
|
||||
**Basic:**
|
||||
- Decode identifiers: "What's inside this npub/nevent?"
|
||||
- `nak decode npub1...`
|
||||
- `nak decode user@domain.com`
|
||||
- Encode identifiers: "Turn this pubkey into npub"
|
||||
- `nak encode npub <hex_pubkey>`
|
||||
|
||||
**Intermediate:**
|
||||
- Generate keys: "Create a new identity"
|
||||
- `nak key generate`
|
||||
- `nak key generate | nak key public | nak encode npub`
|
||||
- Extract hex from NIP-05: "Get the raw pubkey from an address"
|
||||
- `nak decode user@domain.com | jq -r .pubkey`
|
||||
- Create shareable references: "Make a nevent with relay hints"
|
||||
- `nak encode nevent <event_id> --relay <relay>`
|
||||
|
||||
**Advanced:**
|
||||
- Complex naddr creation: "Create addressable event reference with metadata"
|
||||
- `nak encode naddr -k 30717 -a <author> -d <identifier> -r <relay>`
|
||||
- Multi-relay nprofile: "Create profile with multiple relay hints"
|
||||
- `nak encode nprofile <pubkey> -r relay1 -r relay2 -r relay3`
|
||||
|
||||
**Use cases:** Converting between formats, sharing references with relay hints, managing multiple identities, extracting pubkeys for scripting
|
||||
|
||||
---
|
||||
|
||||
### CONCEPT 4: Event Creation & Publishing
|
||||
*"Creating and signing new events"*
|
||||
|
||||
**Basic:**
|
||||
- Interactive creation: "Create an event with prompts"
|
||||
- `nak event --prompt-sec <relay>`
|
||||
- Simple note: "Publish a text note"
|
||||
- `nak event -k 1 -c "Hello Nostr" <relay>`
|
||||
|
||||
**Intermediate:**
|
||||
- Events with tags: "Create tagged content"
|
||||
- `nak event -k 1 -t t=bitcoin -t t=nostr <relay>`
|
||||
- Event deletion: "Delete a previous event"
|
||||
- `nak event -k 5 -e <event_id> --prompt-sec <relay>`
|
||||
- Replaceable events: "Create/update a profile or app data"
|
||||
- `nak event -k 10019 -t mint=<url> -t pubkey=<key> <relay>`
|
||||
|
||||
**Advanced:**
|
||||
- Remote signing with bunker: "Sign without exposing keys"
|
||||
- `nak event --connect "bunker://..." <relay>`
|
||||
- Batch event creation: "Generate many events via script"
|
||||
- `for i in {1..100}; do nak event localhost:8000; done`
|
||||
- Complex events from JSON: "Craft specific event structure"
|
||||
- Modify JSON, then `cat event.json | nak event <relay>`
|
||||
|
||||
**Use cases:** Testing event creation, developing apps (kind 31990, 37515), wallet integration (kind 10019), moderation (kind 5), bunker/remote signing implementation
|
||||
|
||||
---
|
||||
|
||||
### CONCEPT 5: Development & Testing
|
||||
*"Building on Nostr"*
|
||||
|
||||
**Basic:**
|
||||
- Local relay testing: "Test against dev relay"
|
||||
- `nak req localhost:8000`
|
||||
- `nak event ws://127.0.0.1:7777`
|
||||
|
||||
**Intermediate:**
|
||||
- Inspect JSON: "Examine event structure"
|
||||
- `nak req -i <id> <relay> | jq .`
|
||||
- Test search: "Verify search functionality"
|
||||
- `nak req --search "<query>" localhost:8000`
|
||||
- Admin operations: "Manage relay content"
|
||||
- `nak admin --prompt-sec banevent --id <id> <relay>`
|
||||
|
||||
**Advanced:**
|
||||
- Protocol bridging: "Query/test ATProto integration"
|
||||
- `nak req --tag "proxy=at://..." eclipse.pub/relay`
|
||||
- Git over Nostr: "Use git with Nostr transport"
|
||||
- `nak git clone nostr://...`
|
||||
- `nak req -k 30617 git.shakespeare.diy`
|
||||
- Performance testing: "Measure query speed"
|
||||
- `time nak req -k 0 -a <pubkey> <relay>`
|
||||
- Custom event kinds: "Test proprietary event types"
|
||||
- `nak req -k 37515 -a <author> -d <id> ditto.pub/relay`
|
||||
|
||||
**Use cases:** Relay development (Ditto), testing bridges (Mostr/Bluesky), developing video platforms, implementing Git-over-Nostr, testing search ranking algorithms, performance benchmarking
|
||||
|
||||
---
|
||||
|
||||
### CONCEPT 6: Analytics & Monitoring
|
||||
*"Understanding Nostr data"*
|
||||
|
||||
**Basic:**
|
||||
- Count results: "How many events match?"
|
||||
- `nak req -k 1 <relay> | wc -l`
|
||||
- `nak count -k 7 -e <event_id> <relay>`
|
||||
|
||||
**Intermediate:**
|
||||
- Live monitoring: "Watch relay activity"
|
||||
- `nak req --stream <relay>`
|
||||
- `nak req -l 0 --stream relay1 relay2 relay3`
|
||||
- Client analytics: "What apps are posting?"
|
||||
- `nak req --tag client=<app> <relay>`
|
||||
|
||||
**Advanced:**
|
||||
- Event chain analysis: "Track engagement"
|
||||
- `nak count -k 7 -e <event_id> <relay>` (reactions)
|
||||
- `nak req -k 6 -k 7 -e <event_id> <relay>` (reposts + reactions)
|
||||
- Content ranking: "Find top/hot content"
|
||||
- `nak req --search "sort:top" <relay>`
|
||||
- Cross-relay comparison: "Compare event availability"
|
||||
- Query same event from multiple relays, compare results
|
||||
|
||||
**Use cases:** Monitoring relay health, tracking client usage (Ditto, noStrudel, moStard), analyzing engagement, testing ranking algorithms
|
||||
|
||||
---
|
||||
|
||||
## Summary: The 6 Core Mental Models
|
||||
|
||||
1. **Query & Discovery**: "How do I find things?"
|
||||
2. **Broadcast & Migration**: "How do I move things?"
|
||||
3. **Identity & Encoding**: "How do I represent things?"
|
||||
4. **Event Creation**: "How do I make things?"
|
||||
5. **Development & Testing**: "How do I build things?"
|
||||
6. **Analytics & Monitoring**: "How do I measure things?"
|
||||
|
||||
---
|
||||
|
||||
## Command Shapes and Edge-Cases
|
||||
|
||||
Non-obvious patterns and edge cases for nak commands.
|
||||
|
||||
### Signing Methods
|
||||
|
||||
**Using environment variable:**
|
||||
```bash
|
||||
export NOSTR_SECRET_KEY=<hex_key>
|
||||
nak event -c "hello" # Automatically uses $NOSTR_SECRET_KEY
|
||||
```
|
||||
|
||||
**Reading key from file:**
|
||||
```bash
|
||||
nak event -c "hello" --sec $(cat /path/to/key.txt)
|
||||
```
|
||||
|
||||
### Content from File
|
||||
|
||||
**Using @ prefix to read content from file:**
|
||||
```bash
|
||||
echo "hello world" > content.txt
|
||||
nak event -c @content.txt
|
||||
```
|
||||
|
||||
### Tag Syntax
|
||||
|
||||
**Tag with multiple values (semicolon-separated):**
|
||||
```bash
|
||||
nak event -t custom="value1;value2;value3"
|
||||
# Creates: ["custom", "value1", "value2", "value3"]
|
||||
```
|
||||
|
||||
### Filter Output Modes
|
||||
|
||||
**Print bare filter (JSON only):**
|
||||
```bash
|
||||
nak req -k 1 -l 5 --bare
|
||||
# Output: {"kinds":[1],"limit":5}
|
||||
```
|
||||
|
||||
**Filter from stdin can be modified with flags:**
|
||||
```bash
|
||||
echo '{"kinds": [1]}' | nak req -l 5 -k 3 --bare
|
||||
```
|
||||
|
||||
**Unlimited stream:**
|
||||
```bash
|
||||
nak req -l 0 --stream wss://relay.example.com
|
||||
```
|
||||
|
||||
### Relay Specification
|
||||
|
||||
**Local relays and WebSocket schemes:**
|
||||
```bash
|
||||
nak req localhost:8000
|
||||
nak req ws://127.0.0.1:7777
|
||||
nak req relay.example.com # Assumes wss:// if not specified
|
||||
```
|
||||
|
||||
### Encoding
|
||||
|
||||
**Encode from JSON stdin (auto-detects type):**
|
||||
```bash
|
||||
echo '{"pubkey":"<hex>","relays":["wss://relay.example.com"]}' | nak encode
|
||||
```
|
||||
|
||||
### Key Operations
|
||||
|
||||
**Complete key generation pipeline:**
|
||||
```bash
|
||||
nak key generate | tee secret.key | nak key public | nak encode npub
|
||||
# Saves private key to file AND prints the public npub
|
||||
```
|
||||
|
||||
### Verification
|
||||
|
||||
**Verify and pipe:**
|
||||
```bash
|
||||
nak event -c "test" --sec <key> | nak verify && echo "Valid"
|
||||
```
|
||||
|
||||
### Fetch vs Req
|
||||
|
||||
**nak fetch uses relay hints from identifiers:**
|
||||
```bash
|
||||
nak fetch nevent1... # Uses relays encoded in nevent
|
||||
nak fetch naddr1... # Uses relays encoded in naddr
|
||||
nak fetch alex@gleasonator.dev # Resolves NIP-05
|
||||
nak fetch -r relay.primal.net naddr1... # Override relays
|
||||
```
|
||||
|
||||
**nak req requires explicit relay specification:**
|
||||
```bash
|
||||
nak req -i <event_id> wss://relay.example.com
|
||||
```
|
||||
|
||||
### Edge Cases
|
||||
|
||||
**No relays specified (prints event without publishing):**
|
||||
```bash
|
||||
nak event -c "test" --sec <key> # Just prints the event JSON
|
||||
```
|
||||
|
||||
**Tag order matters for addressable events:**
|
||||
```bash
|
||||
# The first 'd' tag is the identifier
|
||||
nak event -k 30023 -d first -d second # "first" is the identifier
|
||||
```
|
||||
|
||||
**Timestamp override:**
|
||||
```bash
|
||||
nak event --ts 1700000000 -c "backdated" --sec <key>
|
||||
nak event --ts 0 -c "genesis" --sec <key> # Event at Unix epoch
|
||||
```
|
||||
|
||||
**Kind 0 (profile) requires JSON content:**
|
||||
```bash
|
||||
nak event -k 0 -c '{"name":"Alice","about":"Developer"}' --sec <key>
|
||||
```
|
||||
|
||||
**POW (Proof of Work):**
|
||||
```bash
|
||||
nak event --pow 20 -c "mined event" --sec <key>
|
||||
# Will compute hash until difficulty target is met
|
||||
```
|
||||
|
||||
**NIP-42 AUTH:**
|
||||
```bash
|
||||
nak req --auth -k 1 wss://relay.example.com
|
||||
nak event --auth -c "test" --sec <key> wss://relay.example.com
|
||||
# Automatically handles AUTH challenges
|
||||
```
|
||||
|
||||
**Stdin takes precedence over flags:**
|
||||
```bash
|
||||
echo '{"content":"from stdin"}' | nak event -c "from flag" --sec <key>
|
||||
# Uses "from stdin" (stdin overrides flags)
|
||||
```
|
||||
|
||||
@@ -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: 10
|
||||
- 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: 10
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Run Tests
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { on } from '@ember/modifier';
|
||||
import Icon from './icon';
|
||||
import ActivityZapItem from './activity-zap-item';
|
||||
import Modal from './modal';
|
||||
import NostrConnect from './nostr-connect';
|
||||
import not from 'ember-truth-helpers/helpers/not';
|
||||
import eq from 'ember-truth-helpers/helpers/eq';
|
||||
import restoreScroll from '../modifiers/restore-scroll';
|
||||
|
||||
export default class ActivityTimelineComponent extends Component {
|
||||
@tracked isNostrConnectModalOpen = false;
|
||||
|
||||
@action
|
||||
openNostrConnectModal(event) {
|
||||
event.preventDefault();
|
||||
this.isNostrConnectModalOpen = true;
|
||||
}
|
||||
|
||||
@action
|
||||
closeNostrConnectModal() {
|
||||
this.isNostrConnectModalOpen = false;
|
||||
}
|
||||
|
||||
@action
|
||||
onNostrConnected() {
|
||||
this.closeNostrConnectModal();
|
||||
this.args.onNostrConnected?.();
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header has-back-btn">
|
||||
<button type="button" class="back-btn" {{on "click" @onBack}}>
|
||||
<Icon @name="arrow-left" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
<h2 class="sidebar-header-text-centered">
|
||||
<span class="sidebar-header-icon-wrapper">
|
||||
<Icon @name="activity" @size={{20}} @color="#898989" />
|
||||
</span>
|
||||
Activity
|
||||
</h2>
|
||||
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
||||
<Icon @name="x" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-content" {{restoreScroll @scrollTop}}>
|
||||
{{#if @isLoading}}
|
||||
<div class="sidebar-loading">
|
||||
<Icon @name="loading-ring" @size={{24}} @color="#898989" />
|
||||
</div>
|
||||
{{else if (not @isConnected)}}
|
||||
<p class="empty-state">
|
||||
<a
|
||||
href="#"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
{{on "click" this.openNostrConnectModal}}
|
||||
>Connect your Nostr account</a>
|
||||
to see your activity.
|
||||
</p>
|
||||
{{else if (not @items.length)}}
|
||||
<p class="empty-state">No activity yet.</p>
|
||||
{{else}}
|
||||
<ul class="activity-list">
|
||||
{{#each @items as |item|}}
|
||||
{{#if (eq item.type "zap")}}
|
||||
<ActivityZapItem @item={{item}} @onSelect={{@onSelect}} />
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#if this.isNostrConnectModalOpen}}
|
||||
<Modal @onClose={{this.closeNostrConnectModal}}>
|
||||
<NostrConnect @onConnect={{this.onNostrConnected}} />
|
||||
</Modal>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { on } from '@ember/modifier';
|
||||
import { fn } from '@ember/helper';
|
||||
import formatRelativeDate from '../helpers/format-relative-date';
|
||||
import { npubEncode } from 'applesauce-core/helpers/pointers';
|
||||
import Icon from './icon';
|
||||
|
||||
export default class ActivityZapItem extends Component {
|
||||
get item() {
|
||||
return this.args.item;
|
||||
}
|
||||
|
||||
get senderName() {
|
||||
return this.item?.senderName;
|
||||
}
|
||||
|
||||
get senderDisplayName() {
|
||||
const name = this.senderName;
|
||||
if (name) return name;
|
||||
const pubkey = this.item?.senderPubkey;
|
||||
if (!pubkey) return 'Someone';
|
||||
try {
|
||||
return `${npubEncode(pubkey).slice(0, 12)}…`;
|
||||
} catch {
|
||||
return `${pubkey.slice(0, 12)}…`;
|
||||
}
|
||||
}
|
||||
|
||||
get senderAvatar() {
|
||||
return this.item?.senderAvatar;
|
||||
}
|
||||
|
||||
get hasPhoto() {
|
||||
return !!this.item?.photo;
|
||||
}
|
||||
|
||||
get photoThumbUrl() {
|
||||
return this.item?.photo?.thumbUrl || this.item?.photo?.url;
|
||||
}
|
||||
|
||||
get placeName() {
|
||||
return this.item?.placeName;
|
||||
}
|
||||
|
||||
get placeNameLoading() {
|
||||
return this.item?.placeNameLoading;
|
||||
}
|
||||
|
||||
<template>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="activity-zap-item"
|
||||
{{on "click" (fn @onSelect @item)}}
|
||||
>
|
||||
<div class="zap-header">
|
||||
<span class="zap-sender-line">
|
||||
<span class="zap-sender-name">{{this.senderDisplayName}}</span>
|
||||
{{! template-lint-disable no-whitespace-for-layout }}
|
||||
<span class="zap-action"> zapped your photo</span>
|
||||
</span>
|
||||
<span class="zap-amount">{{this.item.amountSats}} ⚡</span>
|
||||
</div>
|
||||
|
||||
<div class="zap-context">
|
||||
<div class="zap-context-images">
|
||||
{{#if this.senderAvatar}}
|
||||
<img
|
||||
class="zap-sender-avatar"
|
||||
src={{this.senderAvatar}}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
{{else}}
|
||||
<div class="zap-sender-avatar-placeholder">
|
||||
<Icon @name="user" @size={{16}} @color="#999" />
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if this.hasPhoto}}
|
||||
<div class="zap-context-thumb">
|
||||
<img src={{this.photoThumbUrl}} alt="" loading="lazy" />
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="zap-context-text">
|
||||
<span class="zap-place-name">
|
||||
{{#if this.placeName}}
|
||||
{{this.placeName}}
|
||||
{{else if this.placeNameLoading}}
|
||||
<span class="contribution-name-loading">Loading…</span>
|
||||
{{else}}
|
||||
<span class="contribution-name-loading">Unnamed place</span>
|
||||
{{/if}}
|
||||
</span>
|
||||
<span class="zap-date">{{formatRelativeDate @item.createdAt}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#if this.item.message}}
|
||||
<div class="zap-message">{{this.item.message}}</div>
|
||||
{{/if}}
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
}
|
||||
@@ -34,6 +34,15 @@ import Icon from '#components/icon';
|
||||
>remote storage</a>
|
||||
to sync place bookmarks across apps and devices.
|
||||
</p>
|
||||
<p>
|
||||
Connect your
|
||||
<a
|
||||
href="https://start.nostr.net"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>Nostr</a>
|
||||
identity to publish photos of places.
|
||||
</p>
|
||||
|
||||
<details>
|
||||
<summary>
|
||||
@@ -91,6 +100,26 @@ import Icon from '#components/icon';
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a
|
||||
href="https://openfreemap.org"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
Map tiles
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="https://github.com/hyperknot/openfreemap/blob/main/LICENSE.md"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
Various
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a
|
||||
@@ -139,13 +168,20 @@ import Icon from '#components/icon';
|
||||
<div class="details-content">
|
||||
<p>
|
||||
<strong>Most impactful:</strong>
|
||||
Add and improve data for points of interest in
|
||||
<a
|
||||
href="https://www.openstreetmap.org"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>OpenStreetMap</a>.
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
Add and improve data for points of interest in
|
||||
<a
|
||||
href="https://www.openstreetmap.org"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>OpenStreetMap</a>.
|
||||
</li>
|
||||
<li>
|
||||
Add place photos with your Nostr account.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
<strong>Most appreciated:</strong>
|
||||
Use this app as much as you can and
|
||||
|
||||
@@ -25,6 +25,18 @@ import iconRounded from '../../icons/icon-rounded.svg?raw';
|
||||
<span>Collections</span>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" {{on "click" @onActivity}}>
|
||||
<Icon @name="activity" @size={{20}} />
|
||||
<span>Activity</span>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" {{on "click" @onContributions}}>
|
||||
<Icon @name="user-check" @size={{20}} />
|
||||
<span>My Contributions</span>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" {{on "click" (fn @onNavigate "settings")}}>
|
||||
<Icon @name="settings" @size={{20}} />
|
||||
|
||||
@@ -23,6 +23,16 @@ export default class AppMenu extends Component {
|
||||
this.router.transitionTo('lists.index');
|
||||
}
|
||||
|
||||
@action
|
||||
goToContributions() {
|
||||
this.router.transitionTo('contributions');
|
||||
}
|
||||
|
||||
@action
|
||||
goToActivity() {
|
||||
this.router.transitionTo('activity');
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="sidebar app-menu-pane">
|
||||
{{#if (eq this.currentView "menu")}}
|
||||
@@ -30,6 +40,8 @@ export default class AppMenu extends Component {
|
||||
@onNavigate={{this.setView}}
|
||||
@onClose={{@onClose}}
|
||||
@onSavedPlaces={{this.goToSavedPlaces}}
|
||||
@onContributions={{this.goToContributions}}
|
||||
@onActivity={{this.goToActivity}}
|
||||
/>
|
||||
|
||||
{{else if (eq this.currentView "settings")}}
|
||||
|
||||
@@ -4,12 +4,16 @@ import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { service } from '@ember/service';
|
||||
import { fn } from '@ember/helper';
|
||||
import eq from 'ember-truth-helpers/helpers/eq';
|
||||
import Icon from '#components/icon';
|
||||
import {
|
||||
excludeRequiredRelays,
|
||||
mergeRequiredRelays,
|
||||
normalizeRelayUrl,
|
||||
uniqNormalizedRelays,
|
||||
} from '../../../utils/nostr';
|
||||
import { DEFAULT_BLOSSOM_SERVER } from '../../../services/blossom';
|
||||
import tooltip from '../../../modifiers/tooltip';
|
||||
|
||||
const stripProtocol = (url) => (url ? url.replace(/^wss?:\/\//, '') : '');
|
||||
|
||||
@@ -47,6 +51,10 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
return new Set(this.nostrData.requiredReadRelays.filter(Boolean));
|
||||
}
|
||||
|
||||
get trustedRelaySet() {
|
||||
return new Set(this.nostrData.trustedRelays.filter(Boolean));
|
||||
}
|
||||
|
||||
get requiredWriteRelaySet() {
|
||||
return new Set(this.nostrData.requiredWriteRelays.filter(Boolean));
|
||||
}
|
||||
@@ -76,6 +84,7 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
return {
|
||||
url,
|
||||
isRequired: this.requiredReadRelaySet.has(url),
|
||||
isTrusted: this.trustedRelaySet.has(url),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -89,6 +98,15 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
});
|
||||
}
|
||||
|
||||
get mediaServerOptions() {
|
||||
const servers = this.nostrData.blossomServers;
|
||||
return servers.length ? servers : [DEFAULT_BLOSSOM_SERVER];
|
||||
}
|
||||
|
||||
get hasMultipleMediaServers() {
|
||||
return this.mediaServerOptions.length > 1;
|
||||
}
|
||||
|
||||
@action
|
||||
updateNewReadRelay(event) {
|
||||
this.newReadRelay = event.target.value;
|
||||
@@ -171,6 +189,26 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
this.settings.update('nostrReadRelayExclusions', null);
|
||||
}
|
||||
|
||||
@action
|
||||
toggleRelayTrust(url) {
|
||||
const normalized = normalizeRelayUrl(url);
|
||||
if (!normalized) return;
|
||||
// Required relays are always trusted and cannot be untrusted.
|
||||
if (this.requiredReadRelaySet.has(normalized)) return;
|
||||
|
||||
const current = uniqNormalizedRelays(
|
||||
this.settings.nostrTrustedRelays || []
|
||||
);
|
||||
const idx = current.indexOf(normalized);
|
||||
let next;
|
||||
if (idx >= 0) {
|
||||
next = current.filter((r) => r !== normalized);
|
||||
} else {
|
||||
next = [...current, normalized];
|
||||
}
|
||||
this.settings.update('nostrTrustedRelays', next.length > 0 ? next : null);
|
||||
}
|
||||
|
||||
@action
|
||||
addWriteRelay() {
|
||||
const url = normalizeRelayUrl(this.newWriteRelay);
|
||||
@@ -240,7 +278,7 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
}
|
||||
|
||||
<template>
|
||||
{{! template-lint-disable no-nested-interactive }}
|
||||
{{! template-lint-disable no-nested-interactive no-unsupported-role-attributes }}
|
||||
<details>
|
||||
<summary>
|
||||
<Icon @name="zap" @size={{20}} />
|
||||
@@ -249,21 +287,56 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
<div class="details-content form-layout">
|
||||
<div class="form-group">
|
||||
<label for="new-read-relay">Read Relays</label>
|
||||
<p class="relay-list-hint">
|
||||
Mark relays as trusted to show
|
||||
<strong>all</strong>
|
||||
photos and reviews from them by default (risk of spam).
|
||||
</p>
|
||||
<ul class="relay-list">
|
||||
{{#each this.readRelaysForDisplay as |relay|}}
|
||||
<li>
|
||||
<span>{{stripProtocol relay.url}}</span>
|
||||
{{#unless relay.isRequired}}
|
||||
<div class="relay-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-trust-relay {{if relay.isTrusted 'is-trusted'}}"
|
||||
aria-label="Toggle trust"
|
||||
aria-description={{if
|
||||
relay.isRequired
|
||||
"Required relay — always trusted"
|
||||
(if
|
||||
relay.isTrusted
|
||||
"Trusted: show all content from this relay"
|
||||
"Untrusted: hide content from outside of my circles"
|
||||
)
|
||||
}}
|
||||
disabled={{relay.isRequired}}
|
||||
{{tooltip}}
|
||||
{{on "click" (fn this.toggleRelayTrust relay.url)}}
|
||||
>
|
||||
<Icon
|
||||
@name="shield"
|
||||
@size={{14}}
|
||||
@color="currentColor"
|
||||
@filled={{relay.isTrusted}}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-remove-relay"
|
||||
title="Remove relay"
|
||||
aria-label="Remove"
|
||||
aria-description={{if
|
||||
relay.isRequired
|
||||
"Required relays cannot be removed"
|
||||
"Remove relay"
|
||||
}}
|
||||
disabled={{relay.isRequired}}
|
||||
{{tooltip}}
|
||||
{{on "click" (fn this.removeReadRelay relay.url)}}
|
||||
>
|
||||
<Icon @name="x" @size={{14}} @color="currentColor" />
|
||||
</button>
|
||||
{{/unless}}
|
||||
</div>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
@@ -300,17 +373,23 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
{{#each this.writeRelaysForDisplay as |relay|}}
|
||||
<li>
|
||||
<span>{{stripProtocol relay.url}}</span>
|
||||
{{#unless relay.isRequired}}
|
||||
<div class="relay-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-remove-relay"
|
||||
title="Remove relay"
|
||||
aria-label="Remove"
|
||||
aria-description={{if
|
||||
relay.isRequired
|
||||
"Required relays cannot be removed"
|
||||
"Remove relay"
|
||||
}}
|
||||
disabled={{relay.isRequired}}
|
||||
{{tooltip}}
|
||||
{{on "click" (fn this.removeWriteRelay relay.url)}}
|
||||
>
|
||||
<Icon @name="x" @size={{14}} @color="currentColor" />
|
||||
</button>
|
||||
{{/unless}}
|
||||
</div>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
@@ -342,31 +421,57 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="nostr-photo-fallback-uploads">Upload photos to fallback
|
||||
servers</label>
|
||||
<label for="nostr-media-server">Media server</label>
|
||||
<select
|
||||
id="nostr-photo-fallback-uploads"
|
||||
id="nostr-media-server"
|
||||
class="form-control"
|
||||
{{on "change" (fn @onChange "nostrPhotoFallbackUploads")}}
|
||||
{{on "change" (fn @onChange "nostrMediaServer")}}
|
||||
>
|
||||
<option
|
||||
value="true"
|
||||
selected={{if this.settings.nostrPhotoFallbackUploads "selected"}}
|
||||
>
|
||||
Yes
|
||||
</option>
|
||||
<option
|
||||
value="false"
|
||||
selected={{unless
|
||||
this.settings.nostrPhotoFallbackUploads
|
||||
"selected"
|
||||
}}
|
||||
>
|
||||
No
|
||||
</option>
|
||||
{{#each this.mediaServerOptions as |server|}}
|
||||
<option
|
||||
value={{server}}
|
||||
selected={{if
|
||||
(eq server this.settings.nostrMediaServer)
|
||||
"selected"
|
||||
}}
|
||||
>
|
||||
{{server}}
|
||||
</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{#if this.hasMultipleMediaServers}}
|
||||
<div class="form-group">
|
||||
<label for="nostr-photo-fallback-uploads">Upload photos to fallback
|
||||
servers</label>
|
||||
<select
|
||||
id="nostr-photo-fallback-uploads"
|
||||
class="form-control"
|
||||
{{on "change" (fn @onChange "nostrPhotoFallbackUploads")}}
|
||||
>
|
||||
<option
|
||||
value="true"
|
||||
selected={{if
|
||||
this.settings.nostrPhotoFallbackUploads
|
||||
"selected"
|
||||
}}
|
||||
>
|
||||
Yes
|
||||
</option>
|
||||
<option
|
||||
value="false"
|
||||
selected={{unless
|
||||
this.settings.nostrPhotoFallbackUploads
|
||||
"selected"
|
||||
}}
|
||||
>
|
||||
No
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<div class="form-group">
|
||||
<label>Cached data</label>
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { on } from '@ember/modifier';
|
||||
import { fn } from '@ember/helper';
|
||||
import or from 'ember-truth-helpers/helpers/or';
|
||||
import formatRelativeDate from '../helpers/format-relative-date';
|
||||
|
||||
export default class ContributionPhoto extends Component {
|
||||
get item() {
|
||||
return this.args.item;
|
||||
}
|
||||
|
||||
get primaryPhoto() {
|
||||
return this.item?.photos?.[0] || null;
|
||||
}
|
||||
|
||||
get extraPhotoCount() {
|
||||
return Math.max(0, (this.item?.photos?.length || 0) - 1);
|
||||
}
|
||||
|
||||
get placeName() {
|
||||
return this.item?.placeName;
|
||||
}
|
||||
|
||||
get isNameLoading() {
|
||||
return this.item?.placeNameLoading;
|
||||
}
|
||||
|
||||
get tags() {
|
||||
// Collect unique tags across all photos in the contribution group
|
||||
const all = (this.item?.photos || []).flatMap((p) => p.tags || []);
|
||||
return [...new Set(all)];
|
||||
}
|
||||
|
||||
<template>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="contribution-item"
|
||||
{{on "click" (fn @onSelect @item)}}
|
||||
>
|
||||
<div class="contribution-thumb">
|
||||
{{#if this.primaryPhoto}}
|
||||
<img
|
||||
src={{or this.primaryPhoto.thumbUrl this.primaryPhoto.url}}
|
||||
alt={{or this.primaryPhoto.alt "Place photo"}}
|
||||
loading="lazy"
|
||||
/>
|
||||
{{/if}}
|
||||
{{#if (gt this.extraPhotoCount 0)}}
|
||||
<span
|
||||
class="contribution-thumb-badge"
|
||||
>+{{this.extraPhotoCount}}</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="contribution-info">
|
||||
<div class="contribution-place">
|
||||
{{#if this.placeName}}
|
||||
{{this.placeName}}
|
||||
{{else if this.isNameLoading}}
|
||||
<span class="contribution-name-loading">Loading…</span>
|
||||
{{else}}
|
||||
<span class="contribution-name-loading">Unnamed place</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="contribution-meta">
|
||||
{{formatRelativeDate @item.createdAt}}
|
||||
{{#if (gt this.item.photos.length 1)}}
|
||||
·
|
||||
{{this.item.photos.length}}
|
||||
photos
|
||||
{{else}}
|
||||
· 1 photo
|
||||
{{/if}}
|
||||
</div>
|
||||
{{#if this.tags.length}}
|
||||
<div class="contribution-tags">
|
||||
{{#each this.tags as |tag|}}
|
||||
<span class="contribution-tag">{{tag}}</span>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { on } from '@ember/modifier';
|
||||
import Icon from './icon';
|
||||
import ContributionPhoto from './contribution-photo';
|
||||
import Modal from './modal';
|
||||
import NostrConnect from './nostr-connect';
|
||||
import eq from 'ember-truth-helpers/helpers/eq';
|
||||
import not from 'ember-truth-helpers/helpers/not';
|
||||
import restoreScroll from '../modifiers/restore-scroll';
|
||||
|
||||
export default class ContributionsTimelineComponent extends Component {
|
||||
@tracked isNostrConnectModalOpen = false;
|
||||
|
||||
@action
|
||||
openNostrConnectModal(event) {
|
||||
event.preventDefault();
|
||||
this.isNostrConnectModalOpen = true;
|
||||
}
|
||||
|
||||
@action
|
||||
closeNostrConnectModal() {
|
||||
this.isNostrConnectModalOpen = false;
|
||||
}
|
||||
|
||||
@action
|
||||
onNostrConnected() {
|
||||
this.closeNostrConnectModal();
|
||||
this.args.onNostrConnected?.();
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header has-back-btn">
|
||||
<button type="button" class="back-btn" {{on "click" @onBack}}>
|
||||
<Icon @name="arrow-left" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
<h2 class="sidebar-header-text-centered">
|
||||
<span class="sidebar-header-icon-wrapper">
|
||||
<Icon @name="activity" @size={{20}} @color="#898989" />
|
||||
</span>
|
||||
My Contributions
|
||||
</h2>
|
||||
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
||||
<Icon @name="x" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-content" {{restoreScroll @scrollTop}}>
|
||||
{{#if @isLoading}}
|
||||
<div class="sidebar-loading">
|
||||
<Icon @name="loading-ring" @size={{24}} @color="#898989" />
|
||||
</div>
|
||||
{{else if (not @isConnected)}}
|
||||
<p class="empty-state">
|
||||
<a
|
||||
href="#"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
{{on "click" this.openNostrConnectModal}}
|
||||
>Connect your Nostr account</a>
|
||||
to see your contributions.
|
||||
</p>
|
||||
{{else if (not @items.length)}}
|
||||
<p class="empty-state">No contributions yet. Start by adding photos to
|
||||
places.</p>
|
||||
{{else}}
|
||||
<ul class="contributions-list">
|
||||
{{#each @items as |item|}}
|
||||
{{#if (eq item.type "photo")}}
|
||||
<ContributionPhoto @item={{item}} @onSelect={{@onSelect}} />
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#if this.isNostrConnectModalOpen}}
|
||||
<Modal @onClose={{this.closeNostrConnectModal}}>
|
||||
<NostrConnect @onConnect={{this.onNostrConnected}} />
|
||||
</Modal>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
+21
-1
@@ -8,6 +8,7 @@ import { defaults as defaultInteractions, DragPan } from 'ol/interaction.js';
|
||||
import Kinetic from 'ol/Kinetic.js';
|
||||
import View from 'ol/View.js';
|
||||
import { fromLonLat, toLonLat, getPointResolution } from 'ol/proj.js';
|
||||
import { containsExtent } from 'ol/extent.js';
|
||||
import Overlay from 'ol/Overlay.js';
|
||||
import LayerGroup from 'ol/layer/Group.js';
|
||||
import VectorLayer from 'ol/layer/Vector.js';
|
||||
@@ -665,7 +666,12 @@ export default class MapComponent extends Component {
|
||||
if (options.preventZoom) {
|
||||
// If we are preventing zoom (e.g. user clicked a bookmark), we rely on visibility check.
|
||||
// This avoids unnecessary panning if the place is already visible.
|
||||
this.handlePinVisibility(coords, { maintainZoom: true });
|
||||
// But if the place has a bbox that doesn't fit in the current view, zoom out to fit it.
|
||||
if (selected.bbox && !this.bboxFitsInView(selected.bbox)) {
|
||||
this.zoomToBbox(selected.bbox);
|
||||
} else {
|
||||
this.handlePinVisibility(coords, { maintainZoom: true });
|
||||
}
|
||||
} else if (selected.bbox) {
|
||||
this.zoomToBbox(selected.bbox);
|
||||
} else {
|
||||
@@ -678,6 +684,20 @@ export default class MapComponent extends Component {
|
||||
}
|
||||
});
|
||||
|
||||
bboxFitsInView(bbox) {
|
||||
if (!this.mapInstance || !bbox) return true;
|
||||
|
||||
const view = this.mapInstance.getView();
|
||||
const size = this.mapInstance.getSize();
|
||||
const viewExtent = view.calculateExtent(size);
|
||||
|
||||
const min = fromLonLat([bbox.minLon, bbox.minLat]);
|
||||
const max = fromLonLat([bbox.maxLon, bbox.maxLat]);
|
||||
const bboxExtent = [...min, ...max];
|
||||
|
||||
return containsExtent(viewExtent, bboxExtent);
|
||||
}
|
||||
|
||||
zoomToBbox(bbox) {
|
||||
if (!this.mapInstance || !bbox) return;
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -237,7 +237,7 @@ export default class PhotoCarousel extends Component {
|
||||
data-src={{photo.url}}
|
||||
class="place-header-photo
|
||||
{{if photo.isLandscape 'landscape' 'portrait'}}"
|
||||
alt={{@name}}
|
||||
alt={{photo.alt}}
|
||||
{{fadeInImage photo.url}}
|
||||
/>
|
||||
{{else if this.isGalleryThumbnails}}
|
||||
@@ -245,7 +245,7 @@ export default class PhotoCarousel extends Component {
|
||||
data-src={{if photo.thumbUrl photo.thumbUrl photo.url}}
|
||||
class="place-header-photo
|
||||
{{if photo.isLandscape 'landscape' 'portrait'}}"
|
||||
alt={{@name}}
|
||||
alt={{photo.alt}}
|
||||
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
|
||||
/>
|
||||
{{else}}
|
||||
@@ -260,7 +260,7 @@ export default class PhotoCarousel extends Component {
|
||||
<img
|
||||
data-src={{photo.url}}
|
||||
class="place-header-photo landscape"
|
||||
alt={{@name}}
|
||||
alt={{photo.alt}}
|
||||
{{fadeInImage photo.url}}
|
||||
/>
|
||||
</picture>
|
||||
@@ -269,7 +269,7 @@ export default class PhotoCarousel extends Component {
|
||||
<img
|
||||
data-src={{if photo.thumbUrl photo.thumbUrl photo.url}}
|
||||
class="place-header-photo portrait"
|
||||
alt={{@name}}
|
||||
alt={{photo.alt}}
|
||||
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
@@ -6,11 +6,14 @@ import { fn } from '@ember/helper';
|
||||
import { service } from '@ember/service';
|
||||
import { modifier } from 'ember-modifier';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { EventFactory } from 'applesauce-factory';
|
||||
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(
|
||||
@@ -201,21 +273,17 @@ export default class PhotoGallery extends Component {
|
||||
const eventId = this.currentPhoto.eventId;
|
||||
|
||||
// Publish Nostr kind: 5 deletion event first so we don't end up with dead blossom links on a failure
|
||||
const factory = new EventFactory({ signer: this.nostrAuth.signer });
|
||||
const tags = [['e', eventId]];
|
||||
|
||||
if (this.currentPhoto.placeIdentifier) {
|
||||
tags.push(['i', this.currentPhoto.placeIdentifier]);
|
||||
}
|
||||
|
||||
const template = {
|
||||
kind: 5,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
content: 'Deleted photo',
|
||||
tags,
|
||||
};
|
||||
|
||||
const event = await factory.sign(template);
|
||||
const event = await EventFactory.fromKind(5)
|
||||
.content('Deleted photo')
|
||||
.modifyPublicTags(() => tags)
|
||||
.as(this.nostrAuth.signer)
|
||||
.sign();
|
||||
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
|
||||
|
||||
// Remove from local store by adding the kind 5 to it
|
||||
@@ -272,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}}
|
||||
@@ -288,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}}
|
||||
|
||||
@@ -113,6 +113,17 @@ export default class PlaceDetails extends Component {
|
||||
}));
|
||||
}
|
||||
|
||||
get hasUntrustedContent() {
|
||||
return (
|
||||
this.nostrData.untrustedContentCount > 0 &&
|
||||
!this.nostrData.showUntrustedContent
|
||||
);
|
||||
}
|
||||
|
||||
get hasMultipleUntrusted() {
|
||||
return this.nostrData.untrustedContentCount > 1;
|
||||
}
|
||||
|
||||
@action
|
||||
startEditing() {
|
||||
if (!this.isSaved) return; // Only allow editing saved places
|
||||
@@ -232,7 +243,7 @@ export default class PlaceDetails extends Component {
|
||||
return htmlSafe(
|
||||
parts
|
||||
.map((p) => {
|
||||
const safeTel = p.replace(/[\s-]+/g, '');
|
||||
const safeTel = p.replace(/[\s()+.-]/g, '');
|
||||
return `<a href="https://wa.me/${safeTel}" target="_blank" rel="noopener noreferrer">${p}</a>`;
|
||||
})
|
||||
.join('<br>')
|
||||
@@ -618,6 +629,23 @@ export default class PlaceDetails extends Component {
|
||||
</button>
|
||||
</span>
|
||||
</p>
|
||||
{{#if this.hasUntrustedContent}}
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="shield" />
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link"
|
||||
{{on "click" this.nostrData.toggleShowUntrustedContent}}
|
||||
>
|
||||
Show
|
||||
{{this.nostrData.untrustedContentCount}}
|
||||
hidden photo{{if this.hasMultipleUntrusted "s" ""}}
|
||||
(untrusted relays)
|
||||
</button>
|
||||
</span>
|
||||
</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,7 @@ export default class PlacePhotoUpload extends Component {
|
||||
@tracked isPublishing = false;
|
||||
@tracked isDragging = false;
|
||||
@tracked selectedTags = [];
|
||||
@tracked altText = '';
|
||||
|
||||
get place() {
|
||||
return this.args.place || {};
|
||||
@@ -103,6 +104,7 @@ export default class PlacePhotoUpload extends Component {
|
||||
this.file = null;
|
||||
this.uploadedPhoto = null;
|
||||
this.selectedTags = [];
|
||||
this.altText = '';
|
||||
if (this.args.onUploadStateChange) {
|
||||
this.args.onUploadStateChange(false);
|
||||
}
|
||||
@@ -118,6 +120,11 @@ export default class PlacePhotoUpload extends Component {
|
||||
this.selectedTags = [tag];
|
||||
}
|
||||
|
||||
@action
|
||||
updateAltText(event) {
|
||||
this.altText = event.target.value;
|
||||
}
|
||||
|
||||
deletePhotoTask = task(async (photoData) => {
|
||||
try {
|
||||
if (photoData.hash) {
|
||||
@@ -155,8 +162,6 @@ export default class PlacePhotoUpload extends Component {
|
||||
this.isPublishing = true;
|
||||
|
||||
try {
|
||||
const factory = new EventFactory({ signer: this.nostrAuth.signer });
|
||||
|
||||
const tags = [['i', `osm:${osmType}:${osmId}`]];
|
||||
|
||||
for (const tag of this.selectedTags) {
|
||||
@@ -179,7 +184,10 @@ export default class PlacePhotoUpload extends Component {
|
||||
imeta.push(`dim ${photo.dim}`);
|
||||
}
|
||||
|
||||
imeta.push('alt A photo of a place');
|
||||
const alt = this.altText.trim();
|
||||
if (alt) {
|
||||
imeta.push(`alt ${alt}`);
|
||||
}
|
||||
|
||||
if (photo.fallbackUrls && photo.fallbackUrls.length > 0) {
|
||||
for (const fallbackUrl of photo.fallbackUrls) {
|
||||
@@ -198,17 +206,11 @@ export default class PlacePhotoUpload extends Component {
|
||||
tags.push(imeta);
|
||||
|
||||
// NIP-XX draft Place Photo event
|
||||
const template = {
|
||||
kind: 360,
|
||||
content: '',
|
||||
tags,
|
||||
};
|
||||
|
||||
if (!template.created_at) {
|
||||
template.created_at = Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
const event = await factory.sign(template);
|
||||
const event = await EventFactory.fromKind(360)
|
||||
.content('')
|
||||
.modifyPublicTags(() => tags)
|
||||
.as(this.nostrAuth.signer)
|
||||
.sign();
|
||||
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
|
||||
this.nostrData.store.add(event);
|
||||
|
||||
@@ -217,6 +219,7 @@ export default class PlacePhotoUpload extends Component {
|
||||
// Clear out the file so user can upload more or be done
|
||||
this.file = null;
|
||||
this.uploadedPhoto = null;
|
||||
this.altText = '';
|
||||
|
||||
if (this.args.onUploadStateChange) {
|
||||
this.args.onUploadStateChange(false);
|
||||
@@ -243,13 +246,11 @@ export default class PlacePhotoUpload extends Component {
|
||||
{{/if}}
|
||||
|
||||
{{#if this.file}}
|
||||
<div class="photo-grid">
|
||||
<PlacePhotoUploadItem
|
||||
@file={{this.file}}
|
||||
@onSuccess={{this.handleUploadSuccess}}
|
||||
@onRemove={{this.removeFile}}
|
||||
/>
|
||||
</div>
|
||||
<PlacePhotoUploadItem
|
||||
@file={{this.file}}
|
||||
@onSuccess={{this.handleUploadSuccess}}
|
||||
@onRemove={{this.removeFile}}
|
||||
/>
|
||||
|
||||
{{#if this.suggestedTags.length}}
|
||||
<div class="photo-tag-suggestions">
|
||||
@@ -271,6 +272,19 @@ export default class PlacePhotoUpload extends Component {
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="photo-alt-input">Description (optional):</label>
|
||||
<input
|
||||
id="photo-alt-input"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Describe this photo"
|
||||
value={{this.altText}}
|
||||
disabled={{this.isPublishing}}
|
||||
{{on "input" this.updateAltText}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-publish"
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import Controller from '@ember/controller';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { task } from 'ember-concurrency';
|
||||
|
||||
export default class ActivityController extends Controller {
|
||||
@service router;
|
||||
@service mapUi;
|
||||
@service nostrAuth;
|
||||
@service activity;
|
||||
|
||||
loadTask = task({ restartable: true }, async (pubkey) => {
|
||||
if (!pubkey) return;
|
||||
await this.activity.load(pubkey);
|
||||
});
|
||||
|
||||
get scrollTop() {
|
||||
return this.mapUi.getScrollPosition('activity');
|
||||
}
|
||||
|
||||
get items() {
|
||||
return this.activity.items;
|
||||
}
|
||||
|
||||
get isConnected() {
|
||||
return this.nostrAuth.isConnected;
|
||||
}
|
||||
|
||||
@action
|
||||
selectItem(item) {
|
||||
if (!item || !item.placeIdentifier) return;
|
||||
|
||||
const sidebarContent = document.querySelector('.sidebar-content');
|
||||
if (sidebarContent) {
|
||||
this.mapUi.saveScrollPosition('activity', sidebarContent.scrollTop);
|
||||
}
|
||||
|
||||
this.mapUi.returnToRoute = { name: 'activity' };
|
||||
this.mapUi.showSidebar();
|
||||
this.mapUi.preventNextZoom = true;
|
||||
|
||||
this.router.transitionTo(`/place/${item.placeIdentifier}`);
|
||||
}
|
||||
|
||||
@action
|
||||
onNostrConnected() {
|
||||
this.loadTask.perform(this.nostrAuth.pubkey);
|
||||
}
|
||||
|
||||
@action
|
||||
backToMenu() {
|
||||
this.router.transitionTo('menu');
|
||||
}
|
||||
|
||||
@action
|
||||
close() {
|
||||
this.router.transitionTo('index');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import Controller from '@ember/controller';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { task } from 'ember-concurrency';
|
||||
|
||||
export default class ContributionsController extends Controller {
|
||||
@service router;
|
||||
@service mapUi;
|
||||
@service nostrAuth;
|
||||
@service contributions;
|
||||
@service storage;
|
||||
|
||||
loadContributionsTask = task({ restartable: true }, async (pubkey) => {
|
||||
if (!pubkey) return;
|
||||
await this.contributions.load(pubkey);
|
||||
});
|
||||
|
||||
get scrollTop() {
|
||||
return this.mapUi.getScrollPosition('contributions');
|
||||
}
|
||||
|
||||
get items() {
|
||||
return this.contributions.items;
|
||||
}
|
||||
|
||||
get isConnected() {
|
||||
return this.nostrAuth.isConnected;
|
||||
}
|
||||
|
||||
@action
|
||||
selectContribution(item) {
|
||||
if (!item) return;
|
||||
|
||||
const sidebarContent = document.querySelector('.sidebar-content');
|
||||
if (sidebarContent) {
|
||||
this.mapUi.saveScrollPosition('contributions', sidebarContent.scrollTop);
|
||||
}
|
||||
|
||||
this.mapUi.returnToRoute = { name: 'contributions' };
|
||||
this.mapUi.showSidebar();
|
||||
this.mapUi.preventNextZoom = true;
|
||||
|
||||
this.router.transitionTo(`/place/${item.placeIdentifier}`);
|
||||
}
|
||||
|
||||
@action
|
||||
onNostrConnected() {
|
||||
this.loadContributionsTask.perform(this.nostrAuth.pubkey);
|
||||
}
|
||||
|
||||
@action
|
||||
backToMenu() {
|
||||
this.router.transitionTo('menu');
|
||||
}
|
||||
|
||||
@action
|
||||
close() {
|
||||
this.router.transitionTo('index');
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,16 @@ import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { task } from 'ember-concurrency';
|
||||
|
||||
function getPlaceTime(place) {
|
||||
const dateVal = place.createdAt;
|
||||
if (!dateVal) return 0;
|
||||
if (typeof dateVal === 'number') {
|
||||
return dateVal;
|
||||
}
|
||||
const parsed = Date.parse(dateVal);
|
||||
return isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
export default class ListsListController extends Controller {
|
||||
@service router;
|
||||
@service mapUi;
|
||||
@@ -73,7 +83,7 @@ export default class ListsListController extends Controller {
|
||||
}
|
||||
});
|
||||
|
||||
return merged;
|
||||
return merged.sort((a, b) => getPlaceTime(b) - getPlaceTime(a));
|
||||
}
|
||||
|
||||
@action
|
||||
|
||||
@@ -179,10 +179,8 @@ export default class SearchController extends Controller {
|
||||
const targetName = params.selected || params.q;
|
||||
|
||||
if (targetName && pois.length > 0) {
|
||||
let matchedPlace = null;
|
||||
|
||||
// 1. Exact Name Match
|
||||
matchedPlace = pois.find(
|
||||
let matchedPlace = pois.find(
|
||||
(p) =>
|
||||
p.osmTags &&
|
||||
(p.osmTags.name === targetName || p.osmTags['name:en'] === targetName)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { helper } from '@ember/component/helper';
|
||||
import { formatRelativeDate as format } from '../utils/format-text';
|
||||
|
||||
export default helper(function formatRelativeDate([timestamp]) {
|
||||
return format(timestamp);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { modifier } from 'ember-modifier';
|
||||
|
||||
export default modifier((element) => {
|
||||
let tooltipEl = null;
|
||||
const portal = document.getElementById('modal-portal') || document.body;
|
||||
|
||||
const show = () => {
|
||||
const text = element.getAttribute('aria-description');
|
||||
if (!text || tooltipEl) return;
|
||||
|
||||
tooltipEl = document.createElement('div');
|
||||
tooltipEl.className = 'tooltip';
|
||||
tooltipEl.setAttribute('role', 'tooltip');
|
||||
tooltipEl.textContent = text;
|
||||
portal.appendChild(tooltipEl);
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
const tipRect = tooltipEl.getBoundingClientRect();
|
||||
const margin = 6;
|
||||
let top = rect.top - tipRect.height - margin;
|
||||
if (top < margin) top = rect.bottom + margin;
|
||||
let left = rect.left + rect.width / 2 - tipRect.width / 2;
|
||||
left = Math.max(
|
||||
margin,
|
||||
Math.min(left, window.innerWidth - tipRect.width - margin)
|
||||
);
|
||||
|
||||
tooltipEl.style.top = `${top}px`;
|
||||
tooltipEl.style.left = `${left}px`;
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
tooltipEl?.remove();
|
||||
tooltipEl = null;
|
||||
};
|
||||
|
||||
element.addEventListener('mouseenter', show);
|
||||
element.addEventListener('mouseleave', hide);
|
||||
element.addEventListener('focus', show);
|
||||
element.addEventListener('blur', hide);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener('mouseenter', show);
|
||||
element.removeEventListener('mouseleave', hide);
|
||||
element.removeEventListener('focus', show);
|
||||
element.removeEventListener('blur', hide);
|
||||
hide();
|
||||
};
|
||||
});
|
||||
@@ -14,6 +14,8 @@ Router.map(function () {
|
||||
this.route('lists', function () {
|
||||
this.route('list', { path: '/:list_id' });
|
||||
});
|
||||
this.route('contributions');
|
||||
this.route('activity');
|
||||
this.route('oauth', function () {
|
||||
this.route('osm-callback', { path: '/osm/callback' });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class ActivityRoute extends Route {
|
||||
@service mapUi;
|
||||
@service nostrAuth;
|
||||
@service activity;
|
||||
|
||||
activate() {
|
||||
this.mapUi.showSidebar();
|
||||
}
|
||||
|
||||
setupController(controller, model) {
|
||||
super.setupController(controller, model);
|
||||
if (controller && controller.loadTask) {
|
||||
controller.loadTask.perform(this.nostrAuth.pubkey);
|
||||
}
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this.activity.stop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class ContributionsRoute extends Route {
|
||||
@service mapUi;
|
||||
@service nostrAuth;
|
||||
@service contributions;
|
||||
|
||||
activate() {
|
||||
this.mapUi.showSidebar();
|
||||
}
|
||||
|
||||
setupController(controller, model) {
|
||||
super.setupController(controller, model);
|
||||
if (controller && controller.loadContributionsTask) {
|
||||
controller.loadContributionsTask.perform(this.nostrAuth.pubkey);
|
||||
}
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this.contributions.stop();
|
||||
}
|
||||
}
|
||||
+39
-4
@@ -1,6 +1,23 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
// Detects whether the fresh OSM data differs from the currently-shown model
|
||||
// by more than ~1m of coordinate drift, or in any tag. Mirrors the relevant
|
||||
// subset of `storage.refreshPlace`'s diff for the non-bookmark path.
|
||||
function hasOsmChanges(place, fresh) {
|
||||
const latDiff = Math.abs((place.lat ?? 0) - (fresh.lat ?? 0));
|
||||
const lonDiff = Math.abs((place.lon ?? 0) - (fresh.lon ?? 0));
|
||||
if (latDiff > 0.00001 || lonDiff > 0.00001) return true;
|
||||
|
||||
const oldTags = place.osmTags || {};
|
||||
const newTags = fresh.osmTags || {};
|
||||
const allKeys = new Set([...Object.keys(oldTags), ...Object.keys(newTags)]);
|
||||
for (const key of allKeys) {
|
||||
if (oldTags[key] !== newTags[key]) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export default class PlaceRoute extends Route {
|
||||
@service storage;
|
||||
@service osm;
|
||||
@@ -131,14 +148,32 @@ export default class PlaceRoute extends Route {
|
||||
}
|
||||
|
||||
async checkUpdates(place) {
|
||||
// Only check for updates if it's a saved place (has ID) and is an OSM object
|
||||
if (place && place.id && place.osmId && place.osmType) {
|
||||
if (!place || !place.osmId || !place.osmType) return;
|
||||
|
||||
// Bookmarked place — refresh via storage, which persists the update.
|
||||
if (place.id) {
|
||||
const updatedPlace = await this.storage.refreshPlace(place);
|
||||
if (updatedPlace) {
|
||||
// If an update occurred, refresh the map UI selection without moving the camera
|
||||
// This ensures the sidebar shows the new data
|
||||
// If an update occurred, refresh the map UI selection without moving
|
||||
// the camera. This ensures the sidebar shows the new data.
|
||||
this.mapUi.selectPlace(updatedPlace, { preventZoom: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-bookmarked explicit OSM place — fetch fresh and update the model
|
||||
// in-place if anything changed, so the sidebar reflects the latest OSM
|
||||
// data without requiring a re-open.
|
||||
try {
|
||||
const fresh = await this.osm.fetchOsmObject(place.osmId, place.osmType, {
|
||||
forceFresh: true,
|
||||
});
|
||||
if (fresh && hasOsmChanges(place, fresh)) {
|
||||
Object.assign(place, fresh);
|
||||
this.mapUi.selectPlace(place, { preventZoom: true });
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug('[place] Fresh fetch failed for', place.osmId, e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import Service, { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { ProfileModel } from 'applesauce-core/models/profile';
|
||||
import { getProfileContent } from 'applesauce-core/helpers/profile';
|
||||
import { parseZapReceipt, enrichWithPhoto } from '../utils/activity';
|
||||
|
||||
/**
|
||||
* Orchestrates loading the user's incoming social activity (zaps received on
|
||||
* their photos) and exposing it as a tracked `items` list for the activity
|
||||
* timeline.
|
||||
*
|
||||
* The flow is:
|
||||
* 1. Subscribe to `nostrData.store.timeline(...)` for kind 9735 zap receipts
|
||||
* where the user is the recipient (`#p` filter).
|
||||
* 2. Parse each receipt into an `ActivityEntry`, filtering to zaps on the
|
||||
* user's own kind 360 photos.
|
||||
* 3. Resolve sender profiles asynchronously via a per-sender `ProfileModel`
|
||||
* subscription, updating the tracked entry fields when they load.
|
||||
* 4. Resolve place names via the shared `placeNameResolver` service.
|
||||
* 5. Update `@tracked items` so the UI renders progressively.
|
||||
*/
|
||||
export default class ActivityService extends Service {
|
||||
@service nostrData;
|
||||
@service nostrAuth;
|
||||
@service placeNameResolver;
|
||||
|
||||
@tracked items = [];
|
||||
|
||||
_sub = null;
|
||||
_profileSubs = new Map();
|
||||
_userPubkey = null;
|
||||
|
||||
/**
|
||||
* Loads the user's incoming zap receipts and subscribes to live updates.
|
||||
*
|
||||
* @param {string} pubkey The user's Nostr pubkey
|
||||
*/
|
||||
async load(pubkey) {
|
||||
if (!pubkey) {
|
||||
this.items = [];
|
||||
return;
|
||||
}
|
||||
|
||||
this._userPubkey = pubkey;
|
||||
|
||||
const filters = [{ kinds: [9735], '#p': [pubkey] }];
|
||||
|
||||
console.debug('[activity] Subscribing to zap receipts', {
|
||||
filters,
|
||||
pubkey,
|
||||
activeReadRelays: this.nostrData.activeReadRelays,
|
||||
});
|
||||
|
||||
// Subscribe to the store timeline so we get live updates as receipts
|
||||
// arrive and are added to the store.
|
||||
this._sub = this.nostrData.store.timeline(filters).subscribe((events) => {
|
||||
this._updateItems(events, pubkey);
|
||||
});
|
||||
|
||||
// Ensure the user's kind 360 photo events are in the store first so
|
||||
// enrichWithPhoto can look them up when zap receipts arrive.
|
||||
await this.nostrData.loadMyContributions(pubkey);
|
||||
|
||||
// Then load zap receipts — adding them to the store triggers the
|
||||
// timeline subscription, and by now the photo events are available.
|
||||
await this.nostrData.loadIncomingZaps(pubkey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops subscriptions and clears the timeline. Called when leaving the
|
||||
* activity route.
|
||||
*/
|
||||
stop() {
|
||||
if (this._sub) {
|
||||
this._sub.unsubscribe();
|
||||
this._sub = null;
|
||||
}
|
||||
this._cleanupProfileSubs();
|
||||
this.items = [];
|
||||
this._userPubkey = null;
|
||||
this.placeNameResolver.reset();
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
this.stop();
|
||||
super.willDestroy(...arguments);
|
||||
}
|
||||
|
||||
_updateItems(receipts, pubkey) {
|
||||
const entries = [];
|
||||
|
||||
for (const receipt of receipts) {
|
||||
const entry = parseZapReceipt(receipt, pubkey);
|
||||
if (!entry) continue;
|
||||
|
||||
// Only keep zaps for the user's own kind 360 photos
|
||||
if (!enrichWithPhoto(entry, this.nostrData.store, pubkey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve sender profile (async, fire-and-forget)
|
||||
this._resolveSender(entry);
|
||||
|
||||
entries.push(entry);
|
||||
}
|
||||
|
||||
// Sort newest-first by created_at
|
||||
entries.sort((a, b) => b.createdAt - a.createdAt);
|
||||
|
||||
console.debug('[activity] Zap receipts received', {
|
||||
total: receipts.length,
|
||||
matched: entries.length,
|
||||
pubkey,
|
||||
});
|
||||
|
||||
// 2. Bookmark lookup is synchronous — resolve those immediately so the
|
||||
// first render shows bookmarked place names without a "Loading…" flicker.
|
||||
for (const entry of entries) {
|
||||
if (entry.osmId) {
|
||||
const bookmarkName = this.placeNameResolver.resolveBookmark(
|
||||
entry.osmId
|
||||
);
|
||||
if (bookmarkName) {
|
||||
entry.placeName = bookmarkName;
|
||||
entry.placeNameLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.items = entries;
|
||||
|
||||
// 3. Async-resolve remaining entries from the IndexedDB name cache and OSM
|
||||
// cache, then fall through to the network batch for the rest.
|
||||
void this.placeNameResolver.resolveInBackground(entries).then(() => {
|
||||
this.items = [...this.items];
|
||||
});
|
||||
}
|
||||
|
||||
_resolveSender(entry) {
|
||||
const pubkey = entry.senderPubkey;
|
||||
if (!pubkey) return;
|
||||
|
||||
// Set up a ProfileModel subscription for this sender so the entry's
|
||||
// tracked fields update when the profile arrives from cache or network.
|
||||
// The event loader (configured in nostrData) auto-fetches missing kind 0
|
||||
// events from the IDB cache and relays.
|
||||
if (!this._profileSubs.has(pubkey)) {
|
||||
const sub = this.nostrData.store
|
||||
.model(ProfileModel, pubkey)
|
||||
.subscribe((profileContent) => {
|
||||
this._applyProfileToPubkey(pubkey, profileContent);
|
||||
});
|
||||
this._profileSubs.set(pubkey, sub);
|
||||
}
|
||||
|
||||
// Read immediately in case the profile is already cached
|
||||
this._applySenderProfile(entry, pubkey);
|
||||
}
|
||||
|
||||
_applySenderProfile(entry, pubkey) {
|
||||
// Try nostrData's profiles dict first (populated by other parts of the app)
|
||||
let profile = this.nostrData.getProfile(pubkey);
|
||||
|
||||
// Fall back to reading the kind 0 event directly from the store. This
|
||||
// handles the re-open case where the event is already in the store from
|
||||
// a previous load but nostrData.profiles wasn't populated by this service.
|
||||
if (!profile) {
|
||||
const event = this.nostrData.store.getReplaceable(0, pubkey);
|
||||
if (event) {
|
||||
profile = getProfileContent(event);
|
||||
}
|
||||
}
|
||||
|
||||
if (profile) {
|
||||
entry.senderName = profile.name || profile.display_name || null;
|
||||
entry.senderAvatar = profile.picture || null;
|
||||
entry.senderProfileLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
_applyProfileToPubkey(pubkey, profileContent) {
|
||||
// Update all entries matching this sender
|
||||
let changed = false;
|
||||
for (const entry of this.items) {
|
||||
if (entry.senderPubkey === pubkey) {
|
||||
entry.senderName =
|
||||
profileContent.name || profileContent.display_name || null;
|
||||
entry.senderAvatar = profileContent.picture || null;
|
||||
entry.senderProfileLoading = false;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
// Trigger re-render
|
||||
this.items = [...this.items];
|
||||
}
|
||||
}
|
||||
|
||||
_cleanupProfileSubs() {
|
||||
for (const sub of this._profileSubs.values()) {
|
||||
sub.unsubscribe();
|
||||
}
|
||||
this._profileSubs.clear();
|
||||
}
|
||||
}
|
||||
+12
-14
@@ -24,34 +24,32 @@ export default class BlossomService extends Service {
|
||||
@service settings;
|
||||
|
||||
get servers() {
|
||||
const servers = this.nostrData.blossomServers;
|
||||
const allServers = servers.length ? servers : [DEFAULT_BLOSSOM_SERVER];
|
||||
const discovered = this.nostrData.blossomServers;
|
||||
const mainServer = this.settings.nostrMediaServer || DEFAULT_BLOSSOM_SERVER;
|
||||
const fallbacks = discovered.filter((server) => server !== mainServer);
|
||||
|
||||
if (!this.settings.nostrPhotoFallbackUploads) {
|
||||
return [allServers[0]];
|
||||
return [mainServer];
|
||||
}
|
||||
|
||||
return allServers;
|
||||
return [mainServer, ...fallbacks];
|
||||
}
|
||||
|
||||
async _getAuthHeader(action, hash, serverUrl) {
|
||||
const factory = new EventFactory({ signer: this.nostrAuth.signer });
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const serverHostname = new URL(serverUrl).hostname;
|
||||
|
||||
const authTemplate = {
|
||||
kind: 24242,
|
||||
created_at: now,
|
||||
content: action === 'upload' ? 'Upload photo for place' : 'Delete photo',
|
||||
tags: [
|
||||
const authEvent = await EventFactory.fromKind(24242)
|
||||
.content(action === 'upload' ? 'Upload photo for place' : 'Delete photo')
|
||||
.modifyPublicTags(() => [
|
||||
['t', action],
|
||||
['x', hash],
|
||||
['expiration', String(now + 3600)],
|
||||
['server', serverHostname],
|
||||
],
|
||||
};
|
||||
|
||||
const authEvent = await factory.sign(authTemplate);
|
||||
])
|
||||
.created(now)
|
||||
.as(this.nostrAuth.signer)
|
||||
.sign();
|
||||
const base64 = btoa(JSON.stringify(authEvent));
|
||||
const base64url = base64
|
||||
.replace(/\+/g, '-')
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import Service, { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { groupPhotoContributions } from '../utils/contributions';
|
||||
|
||||
/**
|
||||
* Orchestrates loading the user's own Nostr contributions, grouping them into
|
||||
* timeline entries, and resolving place names from bookmarks or the OSM API.
|
||||
*
|
||||
* The flow is:
|
||||
* 1. Subscribe to `nostrData.store.timeline(...)` for the user's kind 360 events.
|
||||
* 2. Group events into contribution entries via `groupPhotoContributions`.
|
||||
* 3. Resolve place names from bookmarks (sync, instant) on the first render,
|
||||
* then asynchronously from the IndexedDB name cache and OSM cache.
|
||||
* 4. For unresolved names, batch-fetch from the OSM API in the background.
|
||||
* 5. Any items that can't be resolved get a fallback name so they don't stay
|
||||
* stuck in a "Loading…" state forever.
|
||||
* 6. Update `@tracked items` so the UI renders progressively.
|
||||
*
|
||||
* The persistent name cache is stored in IndexedDB (via the `localForage`
|
||||
* service) with one key per `placeIdentifier`, so partial updates don't
|
||||
* require re-serializing the whole map.
|
||||
*/
|
||||
export default class ContributionsService extends Service {
|
||||
@service nostrData;
|
||||
@service nostrAuth;
|
||||
@service placeNameResolver;
|
||||
|
||||
@tracked items = [];
|
||||
|
||||
_sub = null;
|
||||
|
||||
/**
|
||||
* Loads the user's contributions and subscribes to live updates.
|
||||
*
|
||||
* @param {string} pubkey The user's Nostr pubkey
|
||||
*/
|
||||
async load(pubkey) {
|
||||
if (!pubkey) {
|
||||
this.items = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// Subscribe to the user's own kind 360 events. Each update triggers a re-group
|
||||
// and place-name resolution. This mirrors the `loadPhotosForPlace` pattern.
|
||||
this._sub = this.nostrData.store
|
||||
.timeline([{ kinds: [360, 5], authors: [pubkey] }])
|
||||
.subscribe((events) => {
|
||||
this._updateItems(events);
|
||||
});
|
||||
|
||||
// Also kick off the network fetch via nostrData. This populates the store and
|
||||
// causes the subscription above to fire with fresh events.
|
||||
await this.nostrData.loadMyContributions(pubkey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the subscription and clears the timeline. Called when leaving the
|
||||
* contributions route.
|
||||
*/
|
||||
stop() {
|
||||
if (this._sub) {
|
||||
this._sub.unsubscribe();
|
||||
this._sub = null;
|
||||
}
|
||||
this.items = [];
|
||||
this.placeNameResolver.reset();
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
this.stop();
|
||||
super.willDestroy(...arguments);
|
||||
}
|
||||
|
||||
_updateItems(events) {
|
||||
// 1. Group events into contribution entries (newest-first)
|
||||
const entries = groupPhotoContributions(events);
|
||||
|
||||
// 2. Bookmark lookup is synchronous — resolve those immediately so the
|
||||
// first render shows bookmarked place names without a "Loading…" flicker.
|
||||
for (const entry of entries) {
|
||||
const bookmarkName = this.placeNameResolver.resolveBookmark(entry.osmId);
|
||||
if (bookmarkName) {
|
||||
entry.placeName = bookmarkName;
|
||||
entry.placeNameLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
this.items = entries;
|
||||
|
||||
// 3. Async-resolve remaining entries from the IndexedDB name cache and OSM
|
||||
// cache, then fall through to the network batch for the rest.
|
||||
void this.placeNameResolver.resolveInBackground(entries).then(() => {
|
||||
this.items = [...this.items];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,7 @@ export default class ImageProcessorService extends Service {
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to process image: ${e.message}`);
|
||||
throw new Error(`Failed to process image: ${e.message}`, { cause: e });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import localforage from 'localforage';
|
||||
import Service from '@ember/service';
|
||||
|
||||
const DB_NAME = 'marco';
|
||||
|
||||
/**
|
||||
* Thin async-only wrapper around `localforage` that exposes namespaced
|
||||
* object stores under a single IndexedDB database (`marco`).
|
||||
*
|
||||
* Each "store" is a `localforage` instance created with a unique
|
||||
* `storeName`, so callers can keep data isolated (e.g. OSM cache vs.
|
||||
* contributions name cache) without managing their own connections.
|
||||
*
|
||||
* All methods return Promises — IndexedDB is inherently async, so callers
|
||||
* must `await` every read/write.
|
||||
*/
|
||||
export default class LocalForageService extends Service {
|
||||
_instances = new Map();
|
||||
|
||||
_instance(storeName) {
|
||||
let instance = this._instances.get(storeName);
|
||||
if (!instance) {
|
||||
instance = localforage.createInstance({
|
||||
name: DB_NAME,
|
||||
storeName,
|
||||
});
|
||||
this._instances.set(storeName, instance);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
async get(storeName, key) {
|
||||
return this._instance(storeName).getItem(key);
|
||||
}
|
||||
|
||||
async set(storeName, key, value) {
|
||||
return this._instance(storeName).setItem(key, value);
|
||||
}
|
||||
|
||||
async remove(storeName, key) {
|
||||
return this._instance(storeName).removeItem(key);
|
||||
}
|
||||
|
||||
async keys(storeName) {
|
||||
return this._instance(storeName).keys();
|
||||
}
|
||||
|
||||
async clear(storeName) {
|
||||
return this._instance(storeName).clear();
|
||||
}
|
||||
|
||||
async iterate(storeName, fn) {
|
||||
return this._instance(storeName).iterate(fn);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ const STORAGE_KEY_CONNECT_LOCAL_KEY = 'marco:nostr_connect_local_key';
|
||||
const STORAGE_KEY_CONNECT_REMOTE_PUBKEY = 'marco:nostr_connect_remote_pubkey';
|
||||
const STORAGE_KEY_CONNECT_RELAY = 'marco:nostr_connect_relay';
|
||||
|
||||
const DEFAULT_CONNECT_RELAY = 'wss://relay.nsec.app';
|
||||
const DEFAULT_CONNECT_RELAY = 'wss://nostr.kosmos.org';
|
||||
|
||||
import { isMobile } from '../utils/device';
|
||||
|
||||
@@ -150,9 +150,6 @@ export default class NostrAuthService extends Service {
|
||||
const relay = DEFAULT_CONNECT_RELAY;
|
||||
localStorage.setItem(STORAGE_KEY_CONNECT_RELAY, relay);
|
||||
|
||||
// Override aggressive 10s EOSE timeout to allow time for QR scanning
|
||||
this.nostrRelay.pool.relay(relay).eoseTimeout = 180000; // 3 minutes
|
||||
|
||||
this._signerInstance = new NostrConnectSigner({
|
||||
pool: this.nostrRelay.pool,
|
||||
relays: [relay],
|
||||
@@ -235,9 +232,6 @@ export default class NostrAuthService extends Service {
|
||||
|
||||
const localSigner = this._getLocalSigner();
|
||||
|
||||
// Override aggressive 10s EOSE timeout to allow time for QR scanning
|
||||
this.nostrRelay.pool.relay(relay).eoseTimeout = 180000; // 3 minutes
|
||||
|
||||
this._signerInstance = new NostrConnectSigner({
|
||||
pool: this.nostrRelay.pool,
|
||||
relays: [relay],
|
||||
|
||||
+543
-50
@@ -1,52 +1,123 @@
|
||||
import Service, { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { EMPTY, from, timeout, filter, connect, take, takeUntil } 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 { ContactsModel } from 'applesauce-core/models/contacts';
|
||||
import { isEventPointer, npubEncode } from 'applesauce-core/helpers/pointers';
|
||||
import { persistEventsToCache } from 'applesauce-core/helpers/event-cache';
|
||||
import { createEventLoaderForStore } from 'applesauce-loaders/loaders';
|
||||
import { RelayGroup } from 'applesauce-relay';
|
||||
import { NostrIDB, openDB } from 'nostr-idb';
|
||||
import {
|
||||
excludeRequiredRelays,
|
||||
mergeRequiredRelays,
|
||||
normalizeRelayUrl,
|
||||
relayUrlMatches,
|
||||
uniqNormalizedRelays,
|
||||
} from '../utils/nostr';
|
||||
import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage';
|
||||
import { DEFAULT_BLOSSOM_SERVER } from './blossom';
|
||||
|
||||
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'];
|
||||
const DEFAULT_WRITE_RELAYS = [];
|
||||
const DEFAULT_WRITE_RELAYS = []; // TODO nostr.kosmos.org/marco
|
||||
|
||||
// Content event kinds subject to relay-trust filtering. Adding `30360`
|
||||
// (place reviews) here is the only change needed to extend the feature.
|
||||
const TRUSTED_CONTENT_KINDS = [360];
|
||||
|
||||
// localforage store name for persisted relay provenance.
|
||||
const PROVENANCE_STORE = 'event-relay-provenance';
|
||||
|
||||
// Replicates applesauce-relay's `completeWhen`: completes the source when the
|
||||
// `operator` emits a truthy value. `completeWhen` is used internally by
|
||||
// RelayGroup but not re-exported, so we recreate it from rxjs primitives.
|
||||
function completeWhen(operator) {
|
||||
return connect((shared$) => {
|
||||
const complete$ = shared$.pipe(operator, filter(Boolean), take(1));
|
||||
return shared$.pipe(takeUntil(complete$));
|
||||
});
|
||||
}
|
||||
|
||||
export default class NostrDataService extends Service {
|
||||
@service nostrRelay;
|
||||
@service nostrAuth;
|
||||
@service settings;
|
||||
@service localForage;
|
||||
|
||||
store = new EventStore();
|
||||
|
||||
@tracked profile = null;
|
||||
@tracked mailboxes = null;
|
||||
@tracked contacts = null;
|
||||
@tracked blossomServers = [];
|
||||
@tracked placePhotos = [];
|
||||
@tracked myContributionEvents = [];
|
||||
@tracked profiles = {};
|
||||
@tracked zapReceipts = {};
|
||||
|
||||
// Relay-provenance trust state. `_eventRelays` maps eventId -> Set<relayUrl>
|
||||
// and accumulates every relay an event was seen on (unconditionally, so an
|
||||
// event first seen on an untrusted relay can be upgraded to trusted when it
|
||||
// later arrives from a trusted one). Hydrated from IDB at init so cached
|
||||
// events render with correct trust state instantly.
|
||||
_eventRelays = new Map();
|
||||
_provenanceReady = null;
|
||||
|
||||
// Set of pubkeys from the user's follow list (kind 3 contacts) for O(1)
|
||||
// trust lookups. Rebuilt whenever contacts change.
|
||||
_contactPubkeys = null;
|
||||
|
||||
// Session-only reveal toggle for untrusted content. Not persisted.
|
||||
@tracked showUntrustedContent = false;
|
||||
// Count of currently-hidden (untrusted) place photos for the selected place.
|
||||
@tracked untrustedContentCount = 0;
|
||||
_allPlacePhotos = [];
|
||||
|
||||
_profileSub = null;
|
||||
_mailboxesSub = null;
|
||||
_contactsSub = null;
|
||||
_blossomSub = null;
|
||||
_photosSub = null;
|
||||
_contributionsSub = null;
|
||||
_deletionsSub = null;
|
||||
_profileModelSubs = new Map();
|
||||
|
||||
_zapReceiptsSub = null;
|
||||
_zapReceiptsNetworkSub = null;
|
||||
_zapRefreshTimer = null;
|
||||
_lastPhotoIds = new Set();
|
||||
_incomingZapsNetworkSub = null;
|
||||
|
||||
_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, {
|
||||
@@ -60,14 +131,16 @@ export default class NostrDataService extends Service {
|
||||
this._stopPersisting = persistEventsToCache(
|
||||
this.store,
|
||||
async (events) => {
|
||||
// Only cache profiles, mailboxes, blossom servers, and place photos, and deletions
|
||||
// Only cache profiles, mailboxes, contacts, blossom servers, place photos, and deletions
|
||||
const toCache = events.filter(
|
||||
(e) =>
|
||||
e.kind === 0 ||
|
||||
e.kind === 3 ||
|
||||
e.kind === 5 ||
|
||||
e.kind === 10002 ||
|
||||
e.kind === 10063 ||
|
||||
e.kind === 360
|
||||
e.kind === 360 ||
|
||||
e.kind === 9735
|
||||
);
|
||||
|
||||
if (toCache.length > 0) {
|
||||
@@ -81,6 +154,22 @@ export default class NostrDataService extends Service {
|
||||
);
|
||||
});
|
||||
|
||||
// Hydrate relay provenance from IDB so cached events can be trust-checked
|
||||
// instantly without waiting for relay connections.
|
||||
this._provenanceReady = this._hydrateProvenance();
|
||||
|
||||
// Centralized kind-5 deletion handling: the store routes kind-5 events to
|
||||
// its DeleteManager (never into the event database), so listen to the
|
||||
// deletion stream rather than a timeline. Drop provenance entries for
|
||||
// deleted events so the trust map and IDB don't accumulate dead entries.
|
||||
this._deletionsSub = this.store.deletes.deleted$.subscribe(
|
||||
({ pointer }) => {
|
||||
if (isEventPointer(pointer)) {
|
||||
this._removeProvenance(pointer.id);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Feed events from the relay pool into the event store
|
||||
this.nostrRelay.pool.relays$.subscribe(() => {
|
||||
// Setup relay subscription tracking if needed, or we just rely on request()
|
||||
@@ -146,6 +235,133 @@ export default class NostrDataService extends Service {
|
||||
);
|
||||
}
|
||||
|
||||
// Relays treated as "moderated" for content-trust filtering. Always includes
|
||||
// the required read relays (e.g. nostr.kosmos.org); `nostrTrustedRelays`
|
||||
// adds user-marked custom relays on top. Default (null) = required relays
|
||||
// only, so the app only surfaces content verified by those relays.
|
||||
get trustedRelays() {
|
||||
const configured = this.settings.nostrTrustedRelays || [];
|
||||
return uniqNormalizedRelays([...this.requiredReadRelays, ...configured]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if an event should be considered trusted:
|
||||
* - authored by the connected user (own uploads), OR
|
||||
* - authored by a pubkey the user follows (kind 3 contacts), OR
|
||||
* - seen on at least one trusted (moderated) relay.
|
||||
*/
|
||||
isTrustedEvent(event) {
|
||||
if (!event) return false;
|
||||
const myPubkey = this.nostrAuth?.pubkey;
|
||||
if (myPubkey && event.pubkey === myPubkey) return true;
|
||||
|
||||
if (this._contactPubkeys?.has(event.pubkey)) return true;
|
||||
|
||||
const relays = this._eventRelays.get(event.id);
|
||||
if (!relays || relays.size === 0) return false;
|
||||
const trusted = this.trustedRelays;
|
||||
for (const url of relays) {
|
||||
if (relayUrlMatches(url, trusted)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits content events into trusted / untrusted buckets. Non-content kinds
|
||||
* (e.g. kind 5 deletions) are always treated as trusted so they pass through.
|
||||
*/
|
||||
partitionByTrust(events) {
|
||||
const trusted = [];
|
||||
const untrusted = [];
|
||||
for (const event of events) {
|
||||
if (
|
||||
!TRUSTED_CONTENT_KINDS.includes(event.kind) ||
|
||||
this.isTrustedEvent(event)
|
||||
) {
|
||||
trusted.push(event);
|
||||
} else {
|
||||
untrusted.push(event);
|
||||
}
|
||||
}
|
||||
return { trusted, untrusted };
|
||||
}
|
||||
|
||||
// Hydrate the in-memory provenance map from the IDB store.
|
||||
async _hydrateProvenance() {
|
||||
try {
|
||||
await this.localForage.iterate(PROVENANCE_STORE, (value, key) => {
|
||||
this._eventRelays.set(key, new Set(value || []));
|
||||
});
|
||||
} catch (e) {
|
||||
console.debug('[nostr-data] Failed to hydrate relay provenance', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Record that `eventId` was seen on `relayUrl`. Merges into the existing
|
||||
// set (accumulates across sightings) and persists fire-and-forget.
|
||||
_recordProvenance(eventId, relayUrl) {
|
||||
if (!eventId || !relayUrl) return;
|
||||
let set = this._eventRelays.get(eventId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this._eventRelays.set(eventId, set);
|
||||
}
|
||||
if (set.has(relayUrl)) return;
|
||||
set.add(relayUrl);
|
||||
const urls = Array.from(set);
|
||||
// Fire-and-forget persistence; never blocks the render path.
|
||||
this.localForage.set(PROVENANCE_STORE, eventId, urls).catch((e) => {
|
||||
console.debug('[nostr-data] Failed to persist relay provenance', e);
|
||||
});
|
||||
}
|
||||
|
||||
// Remove provenance for a deleted event (called on kind-5 processing).
|
||||
_removeProvenance(eventId) {
|
||||
if (!eventId) return;
|
||||
if (!this._eventRelays.delete(eventId)) return;
|
||||
this.localForage.remove(PROVENANCE_STORE, eventId).catch((e) => {
|
||||
console.debug('[nostr-data] Failed to remove relay provenance', e);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Request content events from relays while capturing full provenance.
|
||||
*
|
||||
* This mirrors `RelayGroup.request()` completion semantics (wait for the
|
||||
* first relay EOSE + 5s grace period, or all relays EOSE; 30s hard
|
||||
* timeout) but uses `pool.req()` so each `EVENT` message retains its
|
||||
* `from` relay URL. Every sighting is recorded in the provenance map
|
||||
* (unconditionally, including duplicates) so an event first seen on an
|
||||
* untrusted relay can be upgraded to trusted when it later arrives from
|
||||
* a trusted one. Events are always added to the store regardless of trust
|
||||
* status; filtering happens at presentation time.
|
||||
*/
|
||||
_requestContentWithProvenance(relays, filters, errorLabel) {
|
||||
const complete = RelayGroup.completeOnAny(
|
||||
RelayGroup.completeAfterFirstRelay(5_000),
|
||||
RelayGroup.completeOnAllEose()
|
||||
);
|
||||
return this.nostrRelay.pool
|
||||
.req(relays, filters)
|
||||
.pipe(
|
||||
completeWhen(complete),
|
||||
timeout({ first: 30_000 }),
|
||||
// Only EVENT messages carry content; ignore OPEN/EOSE/CLOSED/ERROR.
|
||||
// We deliberately do NOT dedupe so we see every relay's copy and can
|
||||
// accumulate full provenance across relays.
|
||||
filter((message) => message.type === 'EVENT')
|
||||
)
|
||||
.subscribe({
|
||||
next: (message) => {
|
||||
this._recordProvenance(message.event.id, message.from);
|
||||
this.store.add(message.event);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(errorLabel, err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async loadPlacesInBounds(bbox) {
|
||||
const requiredPrefixes = getGeohashPrefixesInBbox(bbox);
|
||||
|
||||
@@ -184,25 +400,13 @@ export default class NostrDataService extends Service {
|
||||
);
|
||||
}
|
||||
|
||||
// Fire network request for new prefixes
|
||||
this.nostrRelay.pool
|
||||
.request(this.activeReadRelays, [
|
||||
{
|
||||
kinds: [360],
|
||||
'#g': missingPrefixes,
|
||||
},
|
||||
])
|
||||
.subscribe({
|
||||
next: (event) => {
|
||||
this.store.add(event);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(
|
||||
'[nostr-data] Error fetching place photos by geohash:',
|
||||
err
|
||||
);
|
||||
},
|
||||
});
|
||||
// Fire network request for new prefixes (captures relay provenance for
|
||||
// trust filtering; events are added to the store regardless of trust).
|
||||
this._requestContentWithProvenance(
|
||||
this.activeReadRelays,
|
||||
[{ kinds: [360], '#g': missingPrefixes }],
|
||||
'[nostr-data] Error fetching place photos by geohash:'
|
||||
);
|
||||
|
||||
for (const p of missingPrefixes) {
|
||||
this.loadedGeohashPrefixes.add(p);
|
||||
@@ -210,19 +414,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([
|
||||
@@ -232,7 +453,11 @@ export default class NostrDataService extends Service {
|
||||
},
|
||||
])
|
||||
.subscribe((events) => {
|
||||
this.placePhotos = events;
|
||||
this._allPlacePhotos = events;
|
||||
this._updatePlacePhotos();
|
||||
const pubkeys = [...new Set(events.map((e) => e.pubkey))];
|
||||
this.loadProfiles(pubkeys);
|
||||
this._scheduleZapReceiptRefresh(events);
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -257,25 +482,149 @@ export default class NostrDataService extends Service {
|
||||
);
|
||||
}
|
||||
|
||||
// Fire network request specifically for this place
|
||||
this.nostrRelay.pool
|
||||
.request(this.activeReadRelays, [
|
||||
{
|
||||
kinds: [360, 5],
|
||||
'#i': [entityId],
|
||||
},
|
||||
])
|
||||
.subscribe({
|
||||
next: (event) => {
|
||||
this.store.add(event);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(
|
||||
'[nostr-data] Error fetching place photos for place:',
|
||||
err
|
||||
);
|
||||
},
|
||||
// Fire network request specifically for this place (captures provenance).
|
||||
this._requestContentWithProvenance(
|
||||
this.activeReadRelays,
|
||||
[{ kinds: [360, 5], '#i': [entityId] }],
|
||||
'[nostr-data] Error fetching place photos for place:'
|
||||
);
|
||||
}
|
||||
|
||||
// Recompute `placePhotos` from `_allPlacePhotos` applying the trust filter
|
||||
// and the session-only reveal toggle. Called whenever the timeline emits or
|
||||
// the user flips `showUntrustedContent`.
|
||||
_updatePlacePhotos() {
|
||||
const events = this._allPlacePhotos;
|
||||
const { trusted, untrusted } = this.partitionByTrust(events);
|
||||
this.untrustedContentCount = untrusted.length;
|
||||
this.placePhotos = this.showUntrustedContent ? events : trusted;
|
||||
}
|
||||
|
||||
@action
|
||||
toggleShowUntrustedContent() {
|
||||
this.showUntrustedContent = !this.showUntrustedContent;
|
||||
this._updatePlacePhotos();
|
||||
}
|
||||
|
||||
async loadMyContributions(pubkey) {
|
||||
if (!pubkey) return;
|
||||
|
||||
// Reset state and unsubscribe from any previous subscription
|
||||
this.myContributionEvents = [];
|
||||
if (this._contributionsSub) {
|
||||
this._contributionsSub.unsubscribe();
|
||||
this._contributionsSub = null;
|
||||
}
|
||||
|
||||
const filters = [{ kinds: [360, 5], authors: [pubkey] }];
|
||||
|
||||
// 1. Set up reactive store subscription so the timeline updates as events arrive
|
||||
this._contributionsSub = this.store
|
||||
.timeline(filters)
|
||||
.subscribe((events) => {
|
||||
this.myContributionEvents = events;
|
||||
});
|
||||
|
||||
// 2. Populate the store from the local Nostr IDB cache (instant)
|
||||
try {
|
||||
await this._cachePromise;
|
||||
|
||||
const cachedEvents = await this.cache.query(filters);
|
||||
|
||||
if (cachedEvents && cachedEvents.length > 0) {
|
||||
for (const event of cachedEvents) {
|
||||
this.store.add(event);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
'[nostr-data] Failed to read my contributions from local Nostr IDB cache',
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Request fresh events from the network in the background (captures
|
||||
// provenance; own-author events bypass trust filtering anyway).
|
||||
this._requestContentWithProvenance(
|
||||
this.activeReadRelays,
|
||||
filters,
|
||||
'[nostr-data] Error fetching my contribution events:'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrates the store with incoming zap receipts (kind 9735) where the user is
|
||||
* the recipient (`#p` filter). Loads from the IDB cache first (instant), then
|
||||
* fires a network request. Called by the `activity` service.
|
||||
*
|
||||
* @param {string} pubkey The user's Nostr pubkey
|
||||
*/
|
||||
async loadIncomingZaps(pubkey) {
|
||||
if (!pubkey) return;
|
||||
|
||||
const filters = [{ kinds: [9735], '#p': [pubkey] }];
|
||||
|
||||
console.debug('[nostr-data] Requesting incoming zap receipts from relays', {
|
||||
filters,
|
||||
relays: this.activeReadRelays,
|
||||
});
|
||||
|
||||
if (this._incomingZapsNetworkSub) {
|
||||
this._incomingZapsNetworkSub.unsubscribe();
|
||||
this._incomingZapsNetworkSub = null;
|
||||
}
|
||||
|
||||
// 1. Populate the store from the local Nostr IDB cache (instant)
|
||||
try {
|
||||
await this._cachePromise;
|
||||
|
||||
const cachedEvents = await this.cache.query(filters);
|
||||
|
||||
if (cachedEvents && cachedEvents.length > 0) {
|
||||
for (const event of cachedEvents) {
|
||||
this.store.add(event);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
'[nostr-data] Failed to read incoming zap receipts from local Nostr IDB cache',
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Request fresh events from the network in the background
|
||||
this._incomingZapsNetworkSub = this._requestContentWithProvenance(
|
||||
this.activeReadRelays,
|
||||
filters,
|
||||
'[nostr-data] Error fetching incoming zap receipts:'
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -284,6 +633,8 @@ export default class NostrDataService extends Service {
|
||||
// Reset state
|
||||
this.profile = null;
|
||||
this.mailboxes = null;
|
||||
this.contacts = null;
|
||||
this._contactPubkeys = null;
|
||||
this.blossomServers = [];
|
||||
|
||||
this._cleanupSubscriptions();
|
||||
@@ -302,6 +653,14 @@ export default class NostrDataService extends Service {
|
||||
this.mailboxes = mailboxesData;
|
||||
});
|
||||
|
||||
this._contactsSub = this.store
|
||||
.model(ContactsModel, pubkey)
|
||||
.subscribe((contacts) => {
|
||||
this.contacts = contacts;
|
||||
this._contactPubkeys = new Set(contacts.map((c) => c.pubkey));
|
||||
this._updatePlacePhotos();
|
||||
});
|
||||
|
||||
this._blossomSub = this.store
|
||||
.replaceable(10063, pubkey)
|
||||
.subscribe((event) => {
|
||||
@@ -312,6 +671,16 @@ export default class NostrDataService extends Service {
|
||||
} else {
|
||||
this.blossomServers = [];
|
||||
}
|
||||
|
||||
if (this.blossomServers.length > 0) {
|
||||
const current = this.settings.nostrMediaServer;
|
||||
if (
|
||||
current === DEFAULT_BLOSSOM_SERVER ||
|
||||
!this.blossomServers.includes(current)
|
||||
) {
|
||||
this.settings.update('nostrMediaServer', this.blossomServers[0]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 1. Await cache initialization and populate the EventStore with local data
|
||||
@@ -321,7 +690,7 @@ export default class NostrDataService extends Service {
|
||||
const cachedEvents = await this.cache.query([
|
||||
{
|
||||
authors: [pubkey],
|
||||
kinds: [0, 10002, 10063],
|
||||
kinds: [0, 3, 10002, 10063],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -342,7 +711,7 @@ export default class NostrDataService extends Service {
|
||||
.request(profileRelays, [
|
||||
{
|
||||
authors: [pubkey],
|
||||
kinds: [0, 10002, 10063],
|
||||
kinds: [0, 3, 10002, 10063],
|
||||
},
|
||||
])
|
||||
.subscribe({
|
||||
@@ -388,6 +757,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();
|
||||
@@ -401,6 +873,11 @@ export default class NostrDataService extends Service {
|
||||
this._mailboxesSub.unsubscribe();
|
||||
this._mailboxesSub = null;
|
||||
}
|
||||
if (this._contactsSub) {
|
||||
this._contactsSub.unsubscribe();
|
||||
this._contactsSub = null;
|
||||
this._contactPubkeys = null;
|
||||
}
|
||||
if (this._blossomSub) {
|
||||
this._blossomSub.unsubscribe();
|
||||
this._blossomSub = null;
|
||||
@@ -409,11 +886,27 @@ export default class NostrDataService extends Service {
|
||||
this._photosSub.unsubscribe();
|
||||
this._photosSub = null;
|
||||
}
|
||||
if (this._contributionsSub) {
|
||||
this._contributionsSub.unsubscribe();
|
||||
this._contributionsSub = null;
|
||||
}
|
||||
this._cleanupZapReceiptSubs();
|
||||
this._clearZapRefreshTimer();
|
||||
if (this._incomingZapsNetworkSub) {
|
||||
this._incomingZapsNetworkSub.unsubscribe();
|
||||
this._incomingZapsNetworkSub = null;
|
||||
}
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
super.willDestroy(...arguments);
|
||||
this._cleanupSubscriptions();
|
||||
this._clearProfileSubs();
|
||||
|
||||
if (this._deletionsSub) {
|
||||
this._deletionsSub.unsubscribe();
|
||||
this._deletionsSub = null;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,9 @@ export default class OsmAuthService extends Service {
|
||||
clientId: clientId,
|
||||
redirectUrl: redirectUrl,
|
||||
storeRefreshToken: true,
|
||||
onAccessTokenExpiry() {
|
||||
return this.exchangeRefreshTokenForAccessToken();
|
||||
},
|
||||
},
|
||||
new MarcoOsmAuthStorage()
|
||||
);
|
||||
|
||||
+245
-20
@@ -4,12 +4,110 @@ import { getCategoryById } from '../utils/poi-categories';
|
||||
|
||||
export default class OsmService extends Service {
|
||||
@service settings;
|
||||
@service localForage;
|
||||
|
||||
controller = null;
|
||||
cachedResults = null;
|
||||
lastQueryKey = null;
|
||||
cachedPlaces = new Map();
|
||||
|
||||
// Long-term cache for OSM place metadata, persisted to IndexedDB (via the
|
||||
// `localForage` service) so that names and basic info survive across
|
||||
// sessions and can be rendered instantly without waiting on the OSM API.
|
||||
// Entries are refreshed in the background.
|
||||
static STORE_NAME = 'osm-cache';
|
||||
static CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
static IN_MEMORY_TTL_MS = 10000; // 10 seconds
|
||||
|
||||
_buildCacheKey(osmType, osmId) {
|
||||
return `${osmType}:${osmId}`;
|
||||
}
|
||||
|
||||
async _readLocalCache(osmType, osmId) {
|
||||
const key = this._buildCacheKey(osmType, osmId);
|
||||
try {
|
||||
const raw = await this.localForage.get(OsmService.STORE_NAME, key);
|
||||
if (!raw) return null;
|
||||
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed.timestamp !== 'number' ||
|
||||
Date.now() - parsed.timestamp > OsmService.CACHE_TTL_MS
|
||||
) {
|
||||
await this.localForage.remove(OsmService.STORE_NAME, key);
|
||||
return null;
|
||||
}
|
||||
return parsed.data;
|
||||
} catch {
|
||||
try {
|
||||
await this.localForage.remove(OsmService.STORE_NAME, key);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async _writeLocalCache(osmType, osmId, data) {
|
||||
if (!data) return;
|
||||
const key = this._buildCacheKey(osmType, osmId);
|
||||
try {
|
||||
await this.localForage.set(OsmService.STORE_NAME, key, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch (e) {
|
||||
console.debug('[osm] Failed to write IndexedDB cache entry', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Async lookup for an OSM place. Checks the short-lived in-memory cache
|
||||
* first, then the persistent IndexedDB cache. Returns `null` if not
|
||||
* cached.
|
||||
*
|
||||
* Use this for instant rendering (e.g. place names in a list) and fall
|
||||
* back to `fetchOsmObject` for a fresh fetch + background refresh.
|
||||
*
|
||||
* @param {string} osmType 'node' | 'way' | 'relation'
|
||||
* @param {string} osmId
|
||||
* @returns {Promise<object|null>} Normalized OSM place data
|
||||
*/
|
||||
async getCachedOsmObject(osmType, osmId) {
|
||||
if (!osmType || !osmId) return null;
|
||||
|
||||
const cacheKey = this._buildCacheKey(osmType, osmId);
|
||||
const memoryEntry = this.cachedPlaces.get(cacheKey);
|
||||
if (
|
||||
memoryEntry &&
|
||||
Date.now() - memoryEntry.timestamp < OsmService.IN_MEMORY_TTL_MS
|
||||
) {
|
||||
return memoryEntry.data;
|
||||
}
|
||||
|
||||
return this._readLocalCache(osmType, osmId);
|
||||
}
|
||||
|
||||
_storeInMemoryAndLocalStorage(cacheKey, osmType, osmId, data) {
|
||||
if (!data || !data.title) {
|
||||
console.debug(
|
||||
`[osm] Skipping cache write for ${cacheKey}: data has no title`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.cachedPlaces.set(cacheKey, { data, timestamp: Date.now() });
|
||||
|
||||
// Auto-evict from the short-lived in-memory cache so it doesn't grow unbounded.
|
||||
setTimeout(() => {
|
||||
this.cachedPlaces.delete(cacheKey);
|
||||
}, OsmService.IN_MEMORY_TTL_MS);
|
||||
|
||||
// Fire-and-forget the persistent write — the in-memory cache covers the
|
||||
// next immediate read, and the IndexedDB write happens in the background.
|
||||
void this._writeLocalCache(osmType, osmId, data);
|
||||
}
|
||||
|
||||
cancelAll() {
|
||||
if (this.controller) {
|
||||
this.controller.abort();
|
||||
@@ -230,16 +328,57 @@ out center;
|
||||
return this.normalizePoi(data.elements[0]);
|
||||
}
|
||||
|
||||
async fetchOsmObject(osmId, osmType) {
|
||||
async fetchOsmObject(osmId, osmType, { forceFresh = false } = {}) {
|
||||
if (!osmId || !osmType) return null;
|
||||
|
||||
const cacheKey = `${osmType}:${osmId}`;
|
||||
const cacheKey = this._buildCacheKey(osmType, osmId);
|
||||
|
||||
// Force a fresh fetch from the API, bypassing the cache. Used by callers
|
||||
// that need genuinely current data (e.g. storage.refreshPlace, which
|
||||
// diffs the bookmark against freshly-fetched OSM data).
|
||||
if (forceFresh) {
|
||||
return this._fetchAndCacheOsmObject(osmId, osmType, cacheKey);
|
||||
}
|
||||
|
||||
// Cached-first path: return the in-memory entry if it's still warm.
|
||||
const cached = this.cachedPlaces.get(cacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < 10000) {
|
||||
if (cached && Date.now() - cached.timestamp < OsmService.IN_MEMORY_TTL_MS) {
|
||||
console.debug(`Using in-memory cached OSM object for ${cacheKey}`);
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
// Otherwise return the persistent IndexedDB entry and refresh in the
|
||||
// background so the next visit is fresh.
|
||||
const localCached = await this._readLocalCache(osmType, osmId);
|
||||
if (localCached) {
|
||||
console.debug(`Using IndexedDB cached OSM object for ${cacheKey}`);
|
||||
// Refresh in the background, but don't block the caller.
|
||||
this._refreshOsmObject(osmId, osmType, cacheKey).catch((e) => {
|
||||
console.debug('[osm] Background refresh failed for', cacheKey, e);
|
||||
});
|
||||
return localCached;
|
||||
}
|
||||
|
||||
return this._fetchAndCacheOsmObject(osmId, osmType, cacheKey);
|
||||
}
|
||||
|
||||
async _refreshOsmObject(osmId, osmType, cacheKey) {
|
||||
const fresh = await this._fetchOsmObjectFromApi(osmId, osmType);
|
||||
if (fresh) {
|
||||
this._storeInMemoryAndLocalStorage(cacheKey, osmType, osmId, fresh);
|
||||
}
|
||||
return fresh;
|
||||
}
|
||||
|
||||
async _fetchAndCacheOsmObject(osmId, osmType, cacheKey) {
|
||||
const data = await this._fetchOsmObjectFromApi(osmId, osmType);
|
||||
if (data) {
|
||||
this._storeInMemoryAndLocalStorage(cacheKey, osmType, osmId, data);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async _fetchOsmObjectFromApi(osmId, osmType) {
|
||||
let url;
|
||||
if (osmType === 'node') {
|
||||
url = `https://www.openstreetmap.org/api/0.6/node/${osmId}.json`;
|
||||
@@ -263,29 +402,115 @@ out center;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const normalizedData = this.normalizeOsmApiData(
|
||||
data.elements,
|
||||
osmId,
|
||||
osmType
|
||||
);
|
||||
|
||||
this.cachedPlaces.set(cacheKey, {
|
||||
data: normalizedData,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Cleanup cache entry automatically after 10 seconds
|
||||
setTimeout(() => {
|
||||
this.cachedPlaces.delete(cacheKey);
|
||||
}, 10000);
|
||||
|
||||
return normalizedData;
|
||||
return this.normalizeOsmApiData(data.elements, osmId, osmType);
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch OSM object:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-fetches multiple OSM objects, using cached data where available and
|
||||
* only making network requests for uncached IDs.
|
||||
*
|
||||
* @param {Array<{osmType: string, osmId: string}>} items
|
||||
* @returns {Promise<Map<string, object>>} Map keyed by `${osmType}:${osmId}`
|
||||
*/
|
||||
async fetchOsmObjectsBatch(items) {
|
||||
const result = new Map();
|
||||
if (!items || items.length === 0) return result;
|
||||
|
||||
// 1. Collect whatever is already cached
|
||||
const missing = [];
|
||||
for (const { osmType, osmId } of items) {
|
||||
if (!osmType || !osmId) continue;
|
||||
const cacheKey = this._buildCacheKey(osmType, osmId);
|
||||
const cached = await this.getCachedOsmObject(osmType, osmId);
|
||||
if (cached) {
|
||||
result.set(cacheKey, cached);
|
||||
} else {
|
||||
missing.push({ osmType, osmId, cacheKey });
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length === 0) return result;
|
||||
|
||||
// 2. Group missing items by OSM type for batched API requests
|
||||
const byType = new Map();
|
||||
for (const item of missing) {
|
||||
if (!byType.has(item.osmType)) byType.set(item.osmType, []);
|
||||
byType.get(item.osmType).push(item);
|
||||
}
|
||||
|
||||
// 3. Fetch each type group from the OSM API (one request per type)
|
||||
const fetchPromises = [];
|
||||
for (const [osmType, group] of byType) {
|
||||
fetchPromises.push(this._fetchOsmObjectsByType(osmType, group, result));
|
||||
}
|
||||
|
||||
await Promise.allSettled(fetchPromises);
|
||||
return result;
|
||||
}
|
||||
|
||||
async _fetchOsmObjectsByType(osmType, group, result) {
|
||||
// OSM API supports fetching multiple elements of the same type in a single
|
||||
// request using comma-separated IDs (max ~50 per request).
|
||||
//
|
||||
// Note: the multi-fetch endpoint returns ways/relations WITHOUT their child
|
||||
// nodes, so normalized data may lack lat/lon and geometry.
|
||||
// We only write complete results (those with lat/lon) to the general OSM
|
||||
// cache. Nodes from the batch endpoint always include coordinates, so they
|
||||
// are cached. Ways/relations without child node coordinates are not cached,
|
||||
// preserving the full single-object endpoint for place detail navigation.
|
||||
const MAX_IDS_PER_REQUEST = 50;
|
||||
|
||||
for (let i = 0; i < group.length; i += MAX_IDS_PER_REQUEST) {
|
||||
const chunk = group.slice(i, i + MAX_IDS_PER_REQUEST);
|
||||
const idsParam = chunk.map((c) => c.osmId).join(',');
|
||||
const plural = osmType === 'way' ? 'ways' : osmType + 's';
|
||||
const url = `https://www.openstreetmap.org/api/0.6/${plural}.json?${plural}=${idsParam}`;
|
||||
|
||||
try {
|
||||
const res = await this.fetchWithRetry(url);
|
||||
if (!res.ok) {
|
||||
console.warn(
|
||||
`[osm] Batch fetch failed for ${osmType} (${res.status})`,
|
||||
idsParam
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const data = await res.json();
|
||||
for (const item of chunk) {
|
||||
const normalized = this.normalizeOsmApiData(
|
||||
data.elements,
|
||||
item.osmId,
|
||||
osmType
|
||||
);
|
||||
if (normalized) {
|
||||
result.set(item.cacheKey, normalized);
|
||||
// Warm the OSM cache for complete results (nodes always have
|
||||
// lat/lon from the batch endpoint; ways/relations only when
|
||||
// child nodes are included in the response).
|
||||
if (
|
||||
normalized.lat != null &&
|
||||
normalized.lon != null &&
|
||||
normalized.title
|
||||
) {
|
||||
this._storeInMemoryAndLocalStorage(
|
||||
item.cacheKey,
|
||||
osmType,
|
||||
item.osmId,
|
||||
normalized
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[osm] Batch fetch error for ${osmType}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
normalizeOsmApiData(elements, targetId, targetType) {
|
||||
if (!elements || elements.length === 0) return null;
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import Service, { service } from '@ember/service';
|
||||
|
||||
const NAME_CACHE_STORE = 'place-name-cache';
|
||||
|
||||
export default class PlaceNameResolverService extends Service {
|
||||
@service storage;
|
||||
@service osm;
|
||||
@service localForage;
|
||||
|
||||
_pendingBatchPromise = null;
|
||||
_lastBatchSignature = '';
|
||||
_unresolvable = new Set();
|
||||
|
||||
/**
|
||||
* Synchronous bookmark lookup. Returns the bookmark title if found, null otherwise.
|
||||
*
|
||||
* @param {string} osmId The OSM entity ID (e.g. "123456")
|
||||
* @returns {string|null}
|
||||
*/
|
||||
resolveBookmark(osmId) {
|
||||
const bookmark = this.storage.findPlaceById(osmId);
|
||||
return bookmark?.title || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronous name resolution from caches (localForage → OSM IDB cache).
|
||||
* Returns the resolved name or null if not in cache.
|
||||
*
|
||||
* @param {object} entry Entry with placeIdentifier, osmType, osmId
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
async _resolveCachedName(entry) {
|
||||
// 1. Try the persistent name cache (IndexedDB, per-entry, survives sessions)
|
||||
const cachedName = await this.localForage.get(
|
||||
NAME_CACHE_STORE,
|
||||
entry.placeIdentifier
|
||||
);
|
||||
if (cachedName) return cachedName;
|
||||
|
||||
// 2. Try OSM IndexedDB cache (from place detail visits)
|
||||
const cached = await this.osm.getCachedOsmObject(
|
||||
entry.osmType,
|
||||
entry.osmId
|
||||
);
|
||||
return cached?.title || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves names for entries in the background.
|
||||
* Fire-and-forget: resolves from caches, then falls through to batch OSM fetch
|
||||
* for anything still unresolved. Mutates entry.placeName and entry.placeNameLoading.
|
||||
* The caller is responsible for triggering a re-render (e.g. `this.items = [...this.items]`).
|
||||
*
|
||||
* @param {Array} entries Entries with placeIdentifier, osmType, osmId, placeName, placeNameLoading
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async resolveInBackground(entries) {
|
||||
const pending = entries.filter((e) => e.placeNameLoading);
|
||||
if (pending.length === 0) {
|
||||
this._maybeBatchFetchNames(entries);
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
pending.map(async (entry) => {
|
||||
const name = await this._resolveCachedName(entry);
|
||||
if (name) {
|
||||
entry.placeName = name;
|
||||
entry.placeNameLoading = false;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Trigger re-render via caller, then kick off the network batch for
|
||||
// entries that are still loading.
|
||||
this._maybeBatchFetchNames(entries);
|
||||
}
|
||||
|
||||
_isUnresolvable(entry) {
|
||||
return this._unresolvable.has(entry.placeIdentifier);
|
||||
}
|
||||
|
||||
_maybeBatchFetchNames(entries) {
|
||||
const unresolved = entries.filter(
|
||||
(e) => e.placeNameLoading && !this._isUnresolvable(e)
|
||||
);
|
||||
|
||||
// Apply fallbacks for items already marked unresolvable in this session
|
||||
for (const entry of entries) {
|
||||
if (entry.placeNameLoading && this._isUnresolvable(entry)) {
|
||||
entry.placeName = this._fallbackName(entry);
|
||||
entry.placeNameLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (unresolved.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build a stable signature so we only fire one batch request per unique set
|
||||
const signature = unresolved
|
||||
.map((e) => e.placeIdentifier)
|
||||
.sort()
|
||||
.join('|');
|
||||
if (signature === this._lastBatchSignature && this._pendingBatchPromise) {
|
||||
return;
|
||||
}
|
||||
this._lastBatchSignature = signature;
|
||||
|
||||
this._pendingBatchPromise = this._batchResolveNames(unresolved)
|
||||
.then((nameMap) => {
|
||||
// Merge resolved names back into the entries
|
||||
for (const entry of entries) {
|
||||
if (!entry.placeNameLoading) continue;
|
||||
if (nameMap.has(entry.placeIdentifier)) {
|
||||
const name = nameMap.get(entry.placeIdentifier);
|
||||
entry.placeName = name;
|
||||
entry.placeNameLoading = false;
|
||||
} else {
|
||||
// Could not be resolved (deleted object, network error for this item).
|
||||
// Mark as unresolvable for this session and use a fallback.
|
||||
this._unresolvable.add(entry.placeIdentifier);
|
||||
entry.placeName = this._fallbackName(entry);
|
||||
entry.placeNameLoading = false;
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('[place-name-resolver] Batch name resolution failed', e);
|
||||
// On a total failure, mark all as unresolvable and apply fallbacks
|
||||
for (const entry of entries) {
|
||||
if (entry.placeNameLoading) {
|
||||
this._unresolvable.add(entry.placeIdentifier);
|
||||
entry.placeName = this._fallbackName(entry);
|
||||
entry.placeNameLoading = false;
|
||||
}
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this._pendingBatchPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
_fallbackName(entry) {
|
||||
return `OSM ${entry.osmType} ${entry.osmId}`;
|
||||
}
|
||||
|
||||
async _batchResolveNames(entries) {
|
||||
const nameMap = new Map();
|
||||
const toFetch = [];
|
||||
|
||||
// Re-check cache in case it was populated between the trigger and now
|
||||
for (const entry of entries) {
|
||||
const cached = await this._resolveCachedName(entry);
|
||||
if (cached) {
|
||||
nameMap.set(entry.placeIdentifier, cached);
|
||||
} else {
|
||||
toFetch.push({ osmType: entry.osmType, osmId: entry.osmId });
|
||||
}
|
||||
}
|
||||
|
||||
if (toFetch.length === 0) return nameMap;
|
||||
|
||||
const places = await this.osm.fetchOsmObjectsBatch(toFetch);
|
||||
|
||||
// Persist each newly-resolved name to the IndexedDB name cache as its own
|
||||
// entry (per-placeIdentifier key) so we don't re-fetch next session.
|
||||
const writePromises = [];
|
||||
for (const entry of entries) {
|
||||
if (nameMap.has(entry.placeIdentifier)) continue;
|
||||
const cacheKey = `${entry.osmType}:${entry.osmId}`;
|
||||
const place = places.get(cacheKey);
|
||||
if (place?.title) {
|
||||
nameMap.set(entry.placeIdentifier, place.title);
|
||||
writePromises.push(
|
||||
this.localForage.set(
|
||||
NAME_CACHE_STORE,
|
||||
entry.placeIdentifier,
|
||||
place.title
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
await Promise.all(writePromises);
|
||||
return nameMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets per-session state. Called when the owning service stops.
|
||||
*/
|
||||
reset() {
|
||||
this._unresolvable.clear();
|
||||
this._lastBatchSignature = '';
|
||||
this._pendingBatchPromise = null;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,23 @@
|
||||
import Service from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
import { DEFAULT_BLOSSOM_SERVER } from './blossom';
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
overpassApi: 'https://overpass-api.de/api/interpreter',
|
||||
mapKinetic: true,
|
||||
photonApi: 'https://photon.komoot.io/api/',
|
||||
showQuickSearchButtons: true,
|
||||
nostrPhotoFallbackUploads: false,
|
||||
nostrMediaServer: DEFAULT_BLOSSOM_SERVER,
|
||||
nostrReadRelays: null,
|
||||
nostrWriteRelays: null,
|
||||
nostrReadRelayExclusions: null,
|
||||
nostrWriteRelayExclusions: null,
|
||||
// Relays treated as "moderated" for content-trust filtering. When null,
|
||||
// the app falls back to the required read relays (e.g. nostr.kosmos.org),
|
||||
// so default behavior only surfaces content verified by those relays.
|
||||
nostrTrustedRelays: null,
|
||||
experimentalEnablePhotoDeletion: false,
|
||||
};
|
||||
|
||||
@@ -21,11 +28,13 @@ export default class SettingsService extends Service {
|
||||
@tracked showQuickSearchButtons = DEFAULT_SETTINGS.showQuickSearchButtons;
|
||||
@tracked nostrPhotoFallbackUploads =
|
||||
DEFAULT_SETTINGS.nostrPhotoFallbackUploads;
|
||||
@tracked nostrMediaServer = DEFAULT_SETTINGS.nostrMediaServer;
|
||||
@tracked nostrReadRelays = DEFAULT_SETTINGS.nostrReadRelays;
|
||||
@tracked nostrWriteRelays = DEFAULT_SETTINGS.nostrWriteRelays;
|
||||
@tracked nostrReadRelayExclusions = DEFAULT_SETTINGS.nostrReadRelayExclusions;
|
||||
@tracked nostrWriteRelayExclusions =
|
||||
DEFAULT_SETTINGS.nostrWriteRelayExclusions;
|
||||
@tracked nostrTrustedRelays = DEFAULT_SETTINGS.nostrTrustedRelays;
|
||||
@tracked experimentalEnablePhotoDeletion =
|
||||
DEFAULT_SETTINGS.experimentalEnablePhotoDeletion;
|
||||
|
||||
@@ -114,10 +123,12 @@ export default class SettingsService extends Service {
|
||||
this.photonApi = finalSettings.photonApi;
|
||||
this.showQuickSearchButtons = finalSettings.showQuickSearchButtons;
|
||||
this.nostrPhotoFallbackUploads = finalSettings.nostrPhotoFallbackUploads;
|
||||
this.nostrMediaServer = finalSettings.nostrMediaServer;
|
||||
this.nostrReadRelays = finalSettings.nostrReadRelays;
|
||||
this.nostrWriteRelays = finalSettings.nostrWriteRelays;
|
||||
this.nostrReadRelayExclusions = finalSettings.nostrReadRelayExclusions;
|
||||
this.nostrWriteRelayExclusions = finalSettings.nostrWriteRelayExclusions;
|
||||
this.nostrTrustedRelays = finalSettings.nostrTrustedRelays;
|
||||
this.experimentalEnablePhotoDeletion =
|
||||
finalSettings.experimentalEnablePhotoDeletion;
|
||||
|
||||
@@ -132,10 +143,12 @@ export default class SettingsService extends Service {
|
||||
photonApi: this.photonApi,
|
||||
showQuickSearchButtons: this.showQuickSearchButtons,
|
||||
nostrPhotoFallbackUploads: this.nostrPhotoFallbackUploads,
|
||||
nostrMediaServer: this.nostrMediaServer,
|
||||
nostrReadRelays: this.nostrReadRelays,
|
||||
nostrWriteRelays: this.nostrWriteRelays,
|
||||
nostrReadRelayExclusions: this.nostrReadRelayExclusions,
|
||||
nostrWriteRelayExclusions: this.nostrWriteRelayExclusions,
|
||||
nostrTrustedRelays: this.nostrTrustedRelays,
|
||||
experimentalEnablePhotoDeletion: this.experimentalEnablePhotoDeletion,
|
||||
};
|
||||
localStorage.setItem('marco:settings', JSON.stringify(settings));
|
||||
|
||||
@@ -61,6 +61,7 @@ export default class StorageService extends Service {
|
||||
this.rs.on('connected', () => {
|
||||
this.connected = true;
|
||||
this.userAddress = this.rs.remote.userAddress;
|
||||
this.initialSyncDone = false;
|
||||
|
||||
if (this.isNewConnection) {
|
||||
this.toast.show('Remote storage connected', 3000);
|
||||
@@ -71,6 +72,7 @@ export default class StorageService extends Service {
|
||||
});
|
||||
|
||||
this.rs.on('not-connected', () => {
|
||||
this.initialSyncDone = true;
|
||||
this.loadLists();
|
||||
});
|
||||
|
||||
@@ -426,7 +428,8 @@ export default class StorageService extends Service {
|
||||
console.debug(`Checking for updates for ${place.title} (${place.osmId})`);
|
||||
const freshData = await this.osm.fetchOsmObject(
|
||||
place.osmId,
|
||||
place.osmType
|
||||
place.osmType,
|
||||
{ forceFresh: true }
|
||||
);
|
||||
|
||||
if (!freshData) {
|
||||
|
||||
+571
-18
@@ -13,6 +13,10 @@
|
||||
--danger-color: var(--marker-color-primary);
|
||||
--danger-color-dark: var(--marker-color-dark);
|
||||
--default-list-color: #fc3;
|
||||
--secondary-text-color: #898989;
|
||||
--border-color: #d8d8d8;
|
||||
--success-color: #2e7d32;
|
||||
--secondary-background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
html,
|
||||
@@ -245,13 +249,6 @@ body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.photo-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.photo-upload-item {
|
||||
position: relative;
|
||||
aspect-ratio: 4 / 3;
|
||||
@@ -259,6 +256,7 @@ body {
|
||||
overflow: hidden;
|
||||
background: #1e262e;
|
||||
width: 100%;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.photo-upload-item img {
|
||||
@@ -617,14 +615,28 @@ body {
|
||||
|
||||
.relay-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0.25rem 0;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.relay-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.relay-list-hint {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.btn-remove-relay {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -645,12 +657,19 @@ body {
|
||||
stroke: currentcolor;
|
||||
}
|
||||
|
||||
.btn-remove-relay:hover,
|
||||
.btn-remove-relay:active {
|
||||
.btn-remove-relay:hover:not(:disabled),
|
||||
.btn-remove-relay:active:not(:disabled) {
|
||||
background-color: var(--danger-color);
|
||||
color: var(--primary-background-color);
|
||||
}
|
||||
|
||||
.btn-remove-relay:disabled {
|
||||
border-color: var(--border-color);
|
||||
color: var(--secondary-text-color);
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.add-relay-input {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -661,6 +680,64 @@ body {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn-trust-relay {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--primary-background-color);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--secondary-text-color);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: all 0.1s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-trust-relay svg {
|
||||
stroke: currentcolor;
|
||||
}
|
||||
|
||||
.btn-trust-relay.is-trusted {
|
||||
border-color: var(--success-color);
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.btn-trust-relay:hover,
|
||||
.btn-trust-relay:active {
|
||||
background-color: var(--secondary-background-color);
|
||||
}
|
||||
|
||||
.btn-trust-relay:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: fixed;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--body-text-color);
|
||||
color: var(--primary-background-color);
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
animation: tooltip-fade-in 0.15s ease;
|
||||
}
|
||||
|
||||
@keyframes tooltip-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes details-slide-down {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -758,6 +835,25 @@ select.form-control {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.about-section p + ul {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.about-section ul {
|
||||
padding-inline-start: 1.2em;
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
|
||||
.about-section li {
|
||||
padding-inline-start: 0.5rem;
|
||||
margin-block: 0.15rem;
|
||||
}
|
||||
|
||||
.about-section li::marker {
|
||||
content: '♥';
|
||||
color: #898989;
|
||||
}
|
||||
|
||||
.btn-full {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1080,6 +1176,11 @@ abbr[title] {
|
||||
}
|
||||
|
||||
.btn {
|
||||
/* TODO: remove this scoped rule in favor of a global box-sizing reset
|
||||
(e.g. `*, *::before, *::after { box-sizing: border-box; }`) at the top
|
||||
of this file. Needed so `width: 100%` .btn elements like <a class="btn-full">
|
||||
include padding within their width like <button> does. */
|
||||
box-sizing: border-box;
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
@@ -1530,7 +1631,6 @@ button.create-place {
|
||||
color: #5f6368;
|
||||
border-radius: 50%;
|
||||
margin-left: 4px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.search-submit-btn:hover {
|
||||
@@ -1867,8 +1967,12 @@ button.create-place {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.place-photo-upload .form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.photo-tag-suggestions {
|
||||
margin: 1rem 0 1.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.photo-tag-suggestions-title {
|
||||
@@ -2135,17 +2239,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) */
|
||||
@@ -2261,3 +2416,401 @@ button.create-place {
|
||||
justify-content: center;
|
||||
padding: 4rem 1rem;
|
||||
}
|
||||
|
||||
/* Contributions Timeline */
|
||||
.contributions-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: -1rem -1rem 0;
|
||||
}
|
||||
|
||||
.contribution-item {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
background: var(--primary-background-color);
|
||||
color: var(--body-text-color);
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
font-family: inherit;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.contribution-item:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.contribution-thumb {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.contribution-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.contribution-thumb-badge {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
right: 2px;
|
||||
background: rgb(0 0 0 / 65%);
|
||||
color: #fff;
|
||||
font-size: 0.7rem;
|
||||
font-weight: bold;
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.contribution-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.contribution-place {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.contribution-name-loading {
|
||||
color: #999;
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.contribution-meta {
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.contribution-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.contribution-tag {
|
||||
background: #f0f0f0;
|
||||
color: #555;
|
||||
font-size: 0.72rem;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/* Activity Timeline — zap activity list rendered in the sidebar */
|
||||
.activity-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: -1rem -1rem 0;
|
||||
}
|
||||
|
||||
.activity-zap-item {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
background: var(--primary-background-color);
|
||||
color: var(--body-text-color);
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
font-family: inherit;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
& .zap-sender-avatar {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
& .zap-sender-avatar-placeholder {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #e0e0e0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
& .zap-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
& .zap-sender-line {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.95rem;
|
||||
|
||||
& .zap-sender-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
& .zap-action {
|
||||
color: #666;
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
|
||||
& .zap-amount {
|
||||
flex-shrink: 0;
|
||||
font-weight: bold;
|
||||
font-size: 0.95rem;
|
||||
color: var(--body-text-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
& .zap-message {
|
||||
color: var(--body-text-color);
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
position: relative;
|
||||
background: var(--secondary-background-color);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-top: 4px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
left: 11px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: var(--secondary-background-color);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
|
||||
& .zap-context {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #999;
|
||||
font-size: 0.8rem;
|
||||
margin-top: 8px;
|
||||
|
||||
& .zap-context-images {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
& .zap-context-thumb {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #f0f0f0;
|
||||
|
||||
& img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
& .zap-context-text {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
|
||||
& .zap-place-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
& .zap-date {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import ActivityTimeline from '#components/activity-timeline';
|
||||
|
||||
<template>
|
||||
{{#if @controller.mapUi.isSidebarVisible}}
|
||||
<ActivityTimeline
|
||||
@items={{@controller.items}}
|
||||
@isLoading={{@controller.loadTask.isRunning}}
|
||||
@isConnected={{@controller.isConnected}}
|
||||
@scrollTop={{@controller.scrollTop}}
|
||||
@onSelect={{@controller.selectItem}}
|
||||
@onBack={{@controller.backToMenu}}
|
||||
@onClose={{@controller.close}}
|
||||
@onNostrConnected={{@controller.onNostrConnected}}
|
||||
/>
|
||||
{{/if}}
|
||||
</template>
|
||||
@@ -13,7 +13,7 @@ export default class ApplicationComponent extends Component {
|
||||
@service router;
|
||||
|
||||
get isSidebarOpen() {
|
||||
// We consider the sidebar "open" if we are in search, menu, lists or place routes AND it's visible.
|
||||
// We consider the sidebar "open" if we are in search, menu, lists, contributions or place routes AND it's visible.
|
||||
// This helps the map know if it should shift the center or adjust view.
|
||||
const name = this.router.currentRouteName;
|
||||
return (
|
||||
@@ -22,6 +22,8 @@ export default class ApplicationComponent extends Component {
|
||||
name === 'place.new' ||
|
||||
name === 'search' ||
|
||||
name === 'menu' ||
|
||||
name === 'contributions' ||
|
||||
name === 'activity' ||
|
||||
name.startsWith('lists'))
|
||||
);
|
||||
}
|
||||
@@ -49,11 +51,18 @@ export default class ApplicationComponent extends Component {
|
||||
name === 'search' ||
|
||||
name === 'place' ||
|
||||
name === 'menu' ||
|
||||
name === 'contributions' ||
|
||||
name === 'activity' ||
|
||||
name.startsWith('lists')
|
||||
) {
|
||||
this.mapUi.clearSelection();
|
||||
this.mapUi.hideSidebar();
|
||||
if (name === 'menu' || name.startsWith('lists')) {
|
||||
if (
|
||||
name === 'menu' ||
|
||||
name === 'contributions' ||
|
||||
name === 'activity' ||
|
||||
name.startsWith('lists')
|
||||
) {
|
||||
this.router.transitionTo('index');
|
||||
} else if (name === 'place') {
|
||||
if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import ContributionsTimeline from '#components/contributions-timeline';
|
||||
|
||||
<template>
|
||||
{{#if @controller.mapUi.isSidebarVisible}}
|
||||
<ContributionsTimeline
|
||||
@items={{@controller.items}}
|
||||
@isLoading={{@controller.loadContributionsTask.isRunning}}
|
||||
@isConnected={{@controller.isConnected}}
|
||||
@scrollTop={{@controller.scrollTop}}
|
||||
@onSelect={{@controller.selectContribution}}
|
||||
@onBack={{@controller.backToMenu}}
|
||||
@onClose={{@controller.close}}
|
||||
@onNostrConnected={{@controller.onNostrConnected}}
|
||||
/>
|
||||
{{/if}}
|
||||
</template>
|
||||
@@ -81,7 +81,11 @@ export default class PlaceTemplate extends Component {
|
||||
if (this.mapUi.returnToRoute) {
|
||||
this.mapUi.showSidebar();
|
||||
const { name, model } = this.mapUi.returnToRoute;
|
||||
this.router.transitionTo(name, model);
|
||||
if (model !== undefined) {
|
||||
this.router.transitionTo(name, model);
|
||||
} else {
|
||||
this.router.transitionTo(name);
|
||||
}
|
||||
}
|
||||
// If we have an active search context, return to it (UP navigation)
|
||||
else if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Utilities for parsing social activity events into timeline entries.
|
||||
*
|
||||
* An `ActivityEntry` represents a single social interaction related to the
|
||||
* user's content (e.g. a zap received on one of their photos). Entries are
|
||||
* ordered newest-first.
|
||||
*/
|
||||
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import {
|
||||
getZapSender,
|
||||
getZapRecipient,
|
||||
getZapPayment,
|
||||
getZapEventPointer,
|
||||
getZapRequest,
|
||||
} from 'applesauce-common/helpers';
|
||||
import { parsePhotoFromEvent } from './contributions';
|
||||
|
||||
/**
|
||||
* A single activity timeline entry.
|
||||
*
|
||||
* `senderName`, `senderAvatar`, `senderProfileLoading`, `placeName`,
|
||||
* and `placeNameLoading` are tracked so the row re-renders when the sender's
|
||||
* profile or place name is resolved asynchronously.
|
||||
*/
|
||||
export class ActivityEntry {
|
||||
type = 'zap';
|
||||
photoEventId;
|
||||
photo;
|
||||
placeIdentifier;
|
||||
osmType;
|
||||
osmId;
|
||||
senderPubkey;
|
||||
amountSats;
|
||||
message;
|
||||
createdAt;
|
||||
@tracked senderName = null;
|
||||
@tracked senderAvatar = null;
|
||||
@tracked senderProfileLoading = true;
|
||||
@tracked placeName = null;
|
||||
@tracked placeNameLoading = true;
|
||||
|
||||
constructor({
|
||||
photoEventId,
|
||||
photo,
|
||||
placeIdentifier,
|
||||
senderPubkey,
|
||||
amountSats,
|
||||
message,
|
||||
createdAt,
|
||||
}) {
|
||||
this.photoEventId = photoEventId;
|
||||
this.photo = photo;
|
||||
this.placeIdentifier = placeIdentifier;
|
||||
this.senderPubkey = senderPubkey;
|
||||
this.amountSats = amountSats;
|
||||
this.message = message;
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Place-name resolution. Add a shared place-name-resolver util co-used by
|
||||
// services/contributions.js and this service. Each ActivityEntry should get
|
||||
// @tracked placeName / placeNameLoading, resolved via the existing cascade
|
||||
// (bookmarks → localForage cache → osm cache → batch OSM fetch). Extract from
|
||||
// contributions.js rather than duplicating.
|
||||
|
||||
/**
|
||||
* Parses a kind 9735 (Zap Receipt) event into an `ActivityEntry`, given the
|
||||
* user's pubkey (to confirm they are the recipient).
|
||||
*
|
||||
* Returns `null` if the receipt is malformed, not directed at the user, or
|
||||
* does not reference a zapped event (kind 360 photo).
|
||||
*
|
||||
* @param {object} receipt A NIP-57 zap receipt event (kind 9735)
|
||||
* @param {string} userPubkey The logged-in user's pubkey
|
||||
* @returns {ActivityEntry|null}
|
||||
*/
|
||||
export function parseZapReceipt(receipt, userPubkey) {
|
||||
if (!receipt || receipt.kind !== 9735) return null;
|
||||
|
||||
const recipient = getZapRecipient(receipt);
|
||||
if (!recipient || recipient !== userPubkey) return null;
|
||||
|
||||
const sender = getZapSender(receipt);
|
||||
if (!sender) return null;
|
||||
|
||||
const payment = getZapPayment(receipt);
|
||||
if (!payment || !payment.amount) return null;
|
||||
const amountSats = Math.round(payment.amount / 1000);
|
||||
|
||||
const eventPointer = getZapEventPointer(receipt);
|
||||
if (!eventPointer || !eventPointer.id) return null;
|
||||
|
||||
const zapRequest = getZapRequest(receipt);
|
||||
const message = zapRequest?.content || null;
|
||||
|
||||
return new ActivityEntry({
|
||||
photoEventId: eventPointer.id,
|
||||
photo: null,
|
||||
placeIdentifier: null,
|
||||
senderPubkey: sender,
|
||||
amountSats,
|
||||
message,
|
||||
createdAt: receipt.created_at,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enriches an `ActivityEntry` with photo and place data from the zapped kind
|
||||
* 360 event, if it is available in the store.
|
||||
*
|
||||
* Returns `true` if the entry was enriched (i.e. the zapped event was found
|
||||
* and is a kind 360 authored by the user), or `false` if the entry should be
|
||||
* discarded (the zapped event is not one of the user's photos).
|
||||
*
|
||||
* @param {ActivityEntry} entry
|
||||
* @param {object} store The applesauce EventStore to look up the zapped event
|
||||
* @param {string} userPubkey The logged-in user's pubkey
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function enrichWithPhoto(entry, store, userPubkey) {
|
||||
const event = store.getEvent?.(entry.photoEventId);
|
||||
if (!event || event.kind !== 360 || event.pubkey !== userPubkey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const photo = parsePhotoFromEvent(event);
|
||||
if (!photo) return false;
|
||||
|
||||
entry.photo = photo;
|
||||
entry.placeIdentifier = photo.placeIdentifier || null;
|
||||
|
||||
// Parse osmType and osmId from placeIdentifier (e.g. "osm:node:123" → "node", "123")
|
||||
if (entry.placeIdentifier) {
|
||||
const [, osmType, osmId] = entry.placeIdentifier.split(':');
|
||||
entry.osmType = osmType;
|
||||
entry.osmId = osmId;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Utilities for grouping the user's Nostr contribution events into timeline entries.
|
||||
*
|
||||
* A contribution "entry" groups one or more related Nostr events that are part of the
|
||||
* same logical action (e.g. uploading several photos of the same place in quick
|
||||
* succession). Entries are ordered newest-first.
|
||||
*/
|
||||
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
const HOUR_IN_SECONDS = 60 * 60;
|
||||
|
||||
/**
|
||||
* A single contribution timeline entry.
|
||||
*
|
||||
* `placeName` and `placeNameLoading` are tracked so that mutating them after
|
||||
* the entry has been rendered (e.g. when the background OSM batch fetch
|
||||
* resolves a place name) re-renders the consuming component. The remaining
|
||||
* fields are static data and do not need to be tracked.
|
||||
*/
|
||||
export class ContributionEntry {
|
||||
type = 'photo';
|
||||
placeIdentifier;
|
||||
osmType;
|
||||
osmId;
|
||||
photos;
|
||||
createdAt;
|
||||
eventCount;
|
||||
@tracked placeName = null;
|
||||
@tracked placeNameLoading = true;
|
||||
|
||||
constructor({
|
||||
placeIdentifier,
|
||||
osmType,
|
||||
osmId,
|
||||
photos,
|
||||
createdAt,
|
||||
eventCount,
|
||||
}) {
|
||||
this.placeIdentifier = placeIdentifier;
|
||||
this.osmType = osmType;
|
||||
this.osmId = osmId;
|
||||
this.photos = photos;
|
||||
this.createdAt = createdAt;
|
||||
this.eventCount = eventCount;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a single kind 360 (Place Photo) event's `imeta` tag into a photo object.
|
||||
* Reuses the same field shape as `parsePlacePhotos` in `utils/nostr.js` but operates
|
||||
* on a single event (the NIP mandates exactly one `imeta` tag per event).
|
||||
*
|
||||
* @param {object} event A NIP-360 (kind 360) Nostr event
|
||||
* @returns {object|null} A photo object, or null if no usable `imeta` was found
|
||||
*/
|
||||
export function parsePhotoFromEvent(event) {
|
||||
const tags = event.tags || [];
|
||||
|
||||
const eventTags = tags
|
||||
.filter((t) => t[0] === 't')
|
||||
.map((t) => t[1])
|
||||
.filter(Boolean);
|
||||
|
||||
const imeta = tags.find((t) => t[0] === 'imeta');
|
||||
if (!imeta) return null;
|
||||
|
||||
let url = null;
|
||||
let thumbUrl = null;
|
||||
let blurhash = null;
|
||||
let isLandscape = false;
|
||||
let aspectRatio = 16 / 9;
|
||||
let altText = null;
|
||||
|
||||
for (const tag of imeta.slice(1)) {
|
||||
if (tag.startsWith('url ')) {
|
||||
url = tag.substring(4);
|
||||
} else if (tag.startsWith('thumb ')) {
|
||||
thumbUrl = tag.substring(6);
|
||||
} else if (tag.startsWith('blurhash ')) {
|
||||
blurhash = tag.substring(9);
|
||||
} else if (tag.startsWith('dim ')) {
|
||||
const [width, height] = tag.substring(4).split('x').map(Number);
|
||||
if (width && height) {
|
||||
aspectRatio = width / height;
|
||||
if (width > height) isLandscape = true;
|
||||
}
|
||||
} else if (tag.startsWith('alt ')) {
|
||||
const alt = tag.substring(4).trim();
|
||||
altText = alt === 'A photo of a place' ? null : alt || null;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
isLandscape,
|
||||
aspectRatio,
|
||||
placeIdentifier,
|
||||
tags: eventTags,
|
||||
alt: altText,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies kind 5 deletion events to a list of kind 360 events, returning the
|
||||
* surviving kind 360 events.
|
||||
*
|
||||
* @param {Array} events Mixed kind 360 and kind 5 events
|
||||
* @returns {Array} Surviving kind 360 events
|
||||
*/
|
||||
function applyDeletions(events) {
|
||||
const deletedIds = new Set();
|
||||
for (const event of events) {
|
||||
if (event.kind === 5) {
|
||||
for (const tag of event.tags || []) {
|
||||
if (tag[0] === 'e' && tag[1]) deletedIds.add(tag[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return events.filter(
|
||||
(event) => event.kind === 360 && !deletedIds.has(event.id)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups kind 360 (Place Photo) events into contribution entries.
|
||||
*
|
||||
* Grouping rules:
|
||||
* 1. Events are grouped by their OSM entity identifier (the `i` tag).
|
||||
* 2. Within an entity, events are sub-grouped by time proximity: a new
|
||||
* sub-group starts whenever the gap between two consecutive events
|
||||
* exceeds `thresholdHours`.
|
||||
* 3. Each sub-group becomes a single contribution entry with one or more
|
||||
* photos, sorted by `created_at` descending (newest entry first).
|
||||
*
|
||||
* @param {Array} events Mixed kind 360 / kind 5 events from the user
|
||||
* @param {number} [thresholdHours=3] Max gap in hours within a sub-group
|
||||
* @returns {Array} Sorted contribution entries (newest first)
|
||||
*/
|
||||
export function groupPhotoContributions(events, thresholdHours = 3) {
|
||||
if (!events || events.length === 0) return [];
|
||||
|
||||
const photoEvents = applyDeletions(events);
|
||||
if (photoEvents.length === 0) return [];
|
||||
|
||||
// Group by OSM entity identifier
|
||||
const byEntity = new Map();
|
||||
for (const event of photoEvents) {
|
||||
const entityTag = (event.tags || []).find((t) => t[0] === 'i');
|
||||
const entityId = entityTag?.[1];
|
||||
if (!entityId) continue;
|
||||
|
||||
if (!byEntity.has(entityId)) byEntity.set(entityId, []);
|
||||
byEntity.get(entityId).push(event);
|
||||
}
|
||||
|
||||
const thresholdSeconds = thresholdHours * HOUR_IN_SECONDS;
|
||||
const entries = [];
|
||||
|
||||
for (const [entityId, entityEvents] of byEntity) {
|
||||
// Sort newest-first within the entity
|
||||
const sorted = [...entityEvents].sort(
|
||||
(a, b) => b.created_at - a.created_at
|
||||
);
|
||||
|
||||
// Walk newest -> oldest, starting a new sub-group when the gap to the
|
||||
// previous event exceeds the threshold.
|
||||
let currentGroup = [];
|
||||
let prevTime = null;
|
||||
|
||||
for (const event of sorted) {
|
||||
if (prevTime !== null && prevTime - event.created_at > thresholdSeconds) {
|
||||
entries.push(buildEntry(entityId, currentGroup));
|
||||
currentGroup = [];
|
||||
}
|
||||
currentGroup.push(event);
|
||||
prevTime = event.created_at;
|
||||
}
|
||||
if (currentGroup.length > 0) {
|
||||
entries.push(buildEntry(entityId, currentGroup));
|
||||
}
|
||||
}
|
||||
|
||||
// Sort entries by their newest event's created_at, descending. Entries with
|
||||
// no usable photos (e.g. malformed imeta) are filtered out.
|
||||
return entries
|
||||
.filter((e) => e.photos.length > 0)
|
||||
.sort((a, b) => b.createdAt - a.createdAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a single contribution entry object from a group of events for the
|
||||
* same OSM entity.
|
||||
*
|
||||
* @param {string} placeIdentifier e.g. "osm:node:123456"
|
||||
* @param {Array} events Kind 360 events in this sub-group
|
||||
* @returns {object} Contribution entry
|
||||
*/
|
||||
function buildEntry(placeIdentifier, events) {
|
||||
const photos = events
|
||||
.map(parsePhotoFromEvent)
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.createdAt - b.createdAt);
|
||||
|
||||
const createdAt = events.reduce(
|
||||
(max, e) => (e.created_at > max ? e.created_at : max),
|
||||
0
|
||||
);
|
||||
|
||||
const [, osmType, osmId] = placeIdentifier.split(':');
|
||||
|
||||
return new ContributionEntry({
|
||||
placeIdentifier,
|
||||
osmType,
|
||||
osmId,
|
||||
photos,
|
||||
createdAt,
|
||||
eventCount: events.length,
|
||||
});
|
||||
}
|
||||
@@ -12,3 +12,24 @@ export function capitalize(text) {
|
||||
if (typeof text !== 'string' || !text) return '';
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
}
|
||||
|
||||
export function formatRelativeDate(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
const date = new Date(timestamp * 1000);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
|
||||
if (diffMin < 1) return 'just now';
|
||||
if (diffMin < 60) return `${diffMin} min ago`;
|
||||
if (diffHr < 24) return `${diffHr} hr ago`;
|
||||
if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
|
||||
const isCurrentYear = date.getFullYear() === now.getFullYear();
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
...(isCurrentYear ? {} : { year: 'numeric' }),
|
||||
});
|
||||
}
|
||||
|
||||
+27
-2
@@ -34,10 +34,12 @@ import plus from 'feather-icons/dist/icons/plus.svg?raw';
|
||||
import search from 'feather-icons/dist/icons/search.svg?raw';
|
||||
import server from 'feather-icons/dist/icons/server.svg?raw';
|
||||
import settings from 'feather-icons/dist/icons/settings.svg?raw';
|
||||
import shield from 'feather-icons/dist/icons/shield.svg?raw';
|
||||
import target from 'feather-icons/dist/icons/target.svg?raw';
|
||||
import trash2 from 'feather-icons/dist/icons/trash-2.svg?raw';
|
||||
import uploadCloud from 'feather-icons/dist/icons/upload-cloud.svg?raw';
|
||||
import user from 'feather-icons/dist/icons/user.svg?raw';
|
||||
import userCheck from 'feather-icons/dist/icons/user-check.svg?raw';
|
||||
import x from 'feather-icons/dist/icons/x.svg?raw';
|
||||
import check from 'feather-icons/dist/icons/check.svg?raw';
|
||||
import alertCircle from 'feather-icons/dist/icons/alert-circle.svg?raw';
|
||||
@@ -47,9 +49,13 @@ import zap from 'feather-icons/dist/icons/zap.svg?raw';
|
||||
* Pinhead icons
|
||||
*/
|
||||
import angelfish from '@waysidemapping/pinhead/dist/icons/angelfish.svg?raw';
|
||||
import anchor from '@waysidemapping/pinhead/dist/icons/anchor.svg?raw';
|
||||
import airportTerminal from '@waysidemapping/pinhead/dist/icons/airport_terminal.svg?raw';
|
||||
import barbell from '@waysidemapping/pinhead/dist/icons/barbell.svg?raw';
|
||||
import barrelWithBunghole from '@waysidemapping/pinhead/dist/icons/barrel_with_bunghole.svg?raw';
|
||||
import climbingWall from '@waysidemapping/pinhead/dist/icons/climbing_wall.svg?raw';
|
||||
import banknote from '@waysidemapping/pinhead/dist/icons/banknote.svg?raw';
|
||||
import banknoteInSlot from '@waysidemapping/pinhead/dist/icons/banknote_in_slot.svg?raw';
|
||||
import badgeShieldWithFire from '@waysidemapping/pinhead/dist/icons/badge_shield_with_fire.svg?raw';
|
||||
import beachUmbrellaInGround from '@waysidemapping/pinhead/dist/icons/beach_umbrella_in_ground.svg?raw';
|
||||
import beerMugWithFoam from '@waysidemapping/pinhead/dist/icons/beer_mug_with_foam.svg?raw';
|
||||
@@ -60,6 +66,7 @@ import camera from '@waysidemapping/pinhead/dist/icons/camera.svg?raw';
|
||||
import boxingGloveUp from '@waysidemapping/pinhead/dist/icons/boxing_glove_up.svg?raw';
|
||||
import car from '@waysidemapping/pinhead/dist/icons/car.svg?raw';
|
||||
import carAndWrench from '@waysidemapping/pinhead/dist/icons/car_and_wrench.svg?raw';
|
||||
import carBetweenRollerBrushes from '@waysidemapping/pinhead/dist/icons/car_between_roller_brushes.svg?raw';
|
||||
import castleKeep from '@waysidemapping/pinhead/dist/icons/castle_keep.svg?raw';
|
||||
import cigaretteWithSmokeCurl from '@waysidemapping/pinhead/dist/icons/cigarette_with_smoke_curl.svg?raw';
|
||||
import cityGate from '@waysidemapping/pinhead/dist/icons/city_gate.svg?raw';
|
||||
@@ -72,11 +79,13 @@ import clothesHanger from '@waysidemapping/pinhead/dist/icons/clothes_hanger.svg
|
||||
import cleaver from '@waysidemapping/pinhead/dist/icons/cleaver.svg?raw';
|
||||
import cloth from '@waysidemapping/pinhead/dist/icons/cloth.svg?raw';
|
||||
import cocktail from '@waysidemapping/pinhead/dist/icons/cocktail.svg?raw';
|
||||
import couch from '@waysidemapping/pinhead/dist/icons/couch.svg?raw';
|
||||
import coffeeBean from '@waysidemapping/pinhead/dist/icons/coffee_bean.svg?raw';
|
||||
import comedyMaskAndTragedyMask from '@waysidemapping/pinhead/dist/icons/comedy_mask_and_tragedy_mask.svg?raw';
|
||||
import croissant from '@waysidemapping/pinhead/dist/icons/croissant.svg?raw';
|
||||
import cupAndSaucer from '@waysidemapping/pinhead/dist/icons/cup_and_saucer.svg?raw';
|
||||
import donut from '@waysidemapping/pinhead/dist/icons/donut.svg?raw';
|
||||
import dollarSwapToEuro from '@waysidemapping/pinhead/dist/icons/dollar_swap_to_euro.svg?raw';
|
||||
import eyeglasses from '@waysidemapping/pinhead/dist/icons/eyeglasses.svg?raw';
|
||||
import fancyMirrorWithReflectionAndStars from '@waysidemapping/pinhead/dist/icons/fancy_mirror_with_reflection_and_stars.svg?raw';
|
||||
import familyRestroomSymbol from '@waysidemapping/pinhead/dist/icons/family_restroom_symbol.svg?raw';
|
||||
@@ -90,6 +99,8 @@ import forkAndKnife from '@waysidemapping/pinhead/dist/icons/fork_and_knife.svg?
|
||||
import gravestone from '@waysidemapping/pinhead/dist/icons/gravestone.svg?raw';
|
||||
import grecianVase from '@waysidemapping/pinhead/dist/icons/grecian_vase.svg?raw';
|
||||
import greekCross from '@waysidemapping/pinhead/dist/icons/greek_cross.svg?raw';
|
||||
import grapes from '@waysidemapping/pinhead/dist/icons/grapes.svg?raw';
|
||||
import gasolinePump from '@waysidemapping/pinhead/dist/icons/gasoline_pump.svg?raw';
|
||||
import iceCreamOnCone from '@waysidemapping/pinhead/dist/icons/ice_cream_on_cone.svg?raw';
|
||||
import industrialBuilding from '@waysidemapping/pinhead/dist/icons/industrial_building.svg?raw';
|
||||
import infoI from '@waysidemapping/pinhead/dist/icons/info_i.svg?raw';
|
||||
@@ -102,7 +113,7 @@ import molarTooth from '@waysidemapping/pinhead/dist/icons/molar_tooth.svg?raw';
|
||||
import needleAndSpoolOfThread from '@waysidemapping/pinhead/dist/icons/needle_and_spool_of_thread.svg?raw';
|
||||
import openBook from '@waysidemapping/pinhead/dist/icons/open_book.svg?raw';
|
||||
import palace from '@waysidemapping/pinhead/dist/icons/palace.svg?raw';
|
||||
import parkingP from '@waysidemapping/pinhead/dist/icons/parking_p.svg?raw';
|
||||
import parkingP from '@waysidemapping/pinhead/dist/icons/p_wide.svg?raw';
|
||||
import personCricketBattingAtCricketBall from '@waysidemapping/pinhead/dist/icons/person_cricket_batting_at_cricket_ball.svg?raw';
|
||||
import personBoardingTramWithDestinationDisplayAndPantographOnTramTrack from '@waysidemapping/pinhead/dist/icons/person_boarding_tram_with_destination_display_and_pantograph_on_tram_track.svg?raw';
|
||||
import personJockeyingRacehorse from '@waysidemapping/pinhead/dist/icons/person_jockeying_racehorse.svg?raw';
|
||||
@@ -118,7 +129,7 @@ import policeOfficerWithStopArm from '@waysidemapping/pinhead/dist/icons/police_
|
||||
import planeTopRight from '@waysidemapping/pinhead/dist/icons/plane_top_right.svg?raw';
|
||||
import roundStructureWithFlag from '@waysidemapping/pinhead/dist/icons/round_structure_with_flag.svg?raw';
|
||||
import sailingShipInWater from '@waysidemapping/pinhead/dist/icons/sailing_ship_in_water.svg?raw';
|
||||
import scissorsOpen from '@waysidemapping/pinhead/dist/icons/scissors_open.svg?raw';
|
||||
import scissorsOpen from '@waysidemapping/pinhead/dist/icons/open_scissors.svg?raw';
|
||||
import shipwreckInWater from '@waysidemapping/pinhead/dist/icons/shipwreck_in_water.svg?raw';
|
||||
import steamTrainOnRailwayTrack from '@waysidemapping/pinhead/dist/icons/steam_train_on_railway_track.svg?raw';
|
||||
import shoppingBag from '@waysidemapping/pinhead/dist/icons/shopping_bag.svg?raw';
|
||||
@@ -131,6 +142,7 @@ import treeAndBenchWithBackrest from '@waysidemapping/pinhead/dist/icons/tree_an
|
||||
import villageBuildings from '@waysidemapping/pinhead/dist/icons/village_buildings.svg?raw';
|
||||
import wallHangingWithMountainsAndSun from '@waysidemapping/pinhead/dist/icons/wall_hanging_with_mountains_and_sun.svg?raw';
|
||||
import windingWayWide from '@waysidemapping/pinhead/dist/icons/winding_way_wide.svg?raw';
|
||||
import waterStandpipeWithHandleAndDrinkingGlass from '@waysidemapping/pinhead/dist/icons/water_standpipe_with_handle_and_drinking_glass.svg?raw';
|
||||
import womensAndMensRestroomSymbol from '@waysidemapping/pinhead/dist/icons/womens_and_mens_restroom_symbol.svg?raw';
|
||||
|
||||
/*
|
||||
@@ -145,9 +157,13 @@ import wikipedia from '../icons/wikipedia.svg?raw';
|
||||
const ICONS = {
|
||||
activity,
|
||||
angelfish,
|
||||
anchor,
|
||||
'airport-terminal': airportTerminal,
|
||||
'arrow-left': arrowLeft,
|
||||
barbell,
|
||||
'barrel-with-bunghole': barrelWithBunghole,
|
||||
banknote,
|
||||
'banknote-in-slot': banknoteInSlot,
|
||||
'badge-shield-with-fire': badgeShieldWithFire,
|
||||
'beach-umbrella-in-ground': beachUmbrellaInGround,
|
||||
'beer-mug-with-foam': beerMugWithFoam,
|
||||
@@ -176,6 +192,7 @@ const ICONS = {
|
||||
cleaver,
|
||||
cloth,
|
||||
cocktail,
|
||||
couch,
|
||||
clock,
|
||||
'coffee-bean': coffeeBean,
|
||||
'comedy-mask-and-tragedy-mask': comedyMaskAndTragedyMask,
|
||||
@@ -183,6 +200,7 @@ const ICONS = {
|
||||
'cup-and-saucer': cupAndSaucer,
|
||||
database,
|
||||
donut,
|
||||
'dollar-swap-to-euro': dollarSwapToEuro,
|
||||
edit,
|
||||
eyeglasses,
|
||||
facebook,
|
||||
@@ -195,8 +213,10 @@ const ICONS = {
|
||||
'flower-bouquet': flowerBouquet,
|
||||
'fork-and-knife': forkAndKnife,
|
||||
fort,
|
||||
'gasoline-pump': gasolinePump,
|
||||
gift,
|
||||
globe,
|
||||
grapes,
|
||||
gravestone,
|
||||
'grecian-vase': grecianVase,
|
||||
'greek-cross': greekCross,
|
||||
@@ -262,18 +282,23 @@ const ICONS = {
|
||||
'upload-cloud': uploadCloud,
|
||||
'tree-and-bench-with-backrest': treeAndBenchWithBackrest,
|
||||
user,
|
||||
'user-check': userCheck,
|
||||
'village-buildings': villageBuildings,
|
||||
'wall-hanging-with-mountains-and-sun': wallHangingWithMountainsAndSun,
|
||||
'womens-and-mens-restroom-symbol': womensAndMensRestroomSymbol,
|
||||
whatsapp,
|
||||
wikipedia,
|
||||
winding_way_wide: windingWayWide,
|
||||
'water-standpipe-with-handle-and-drinking-glass':
|
||||
waterStandpipeWithHandleAndDrinkingGlass,
|
||||
parking_p: parkingP,
|
||||
car,
|
||||
'car-and-wrench': carAndWrench,
|
||||
'car-between-roller-brushes': carBetweenRollerBrushes,
|
||||
'castle-keep': castleKeep,
|
||||
x,
|
||||
zap,
|
||||
shield,
|
||||
'loading-ring': loadingRing,
|
||||
};
|
||||
|
||||
|
||||
@@ -38,6 +38,27 @@ export function excludeRequiredRelays(customRelays = [], requiredRelays = []) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if `relayUrl` matches any entry in `trustedRelays`.
|
||||
*
|
||||
* Comparison is performed on normalized relay URLs (lowercased, `wss://`
|
||||
* scheme ensured, trailing slashes stripped) via `normalizeRelayUrl`, so
|
||||
* callers may pass raw `wss://` URLs received from relays or bare hostnames.
|
||||
*
|
||||
* @param {string} relayUrl The relay URL a content event was seen on
|
||||
* @param {Array<string>} trustedRelays List of trusted relay URLs
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function relayUrlMatches(relayUrl, trustedRelays = []) {
|
||||
if (!relayUrl) return false;
|
||||
const target = normalizeRelayUrl(relayUrl);
|
||||
if (!target) return false;
|
||||
const trustedSet = new Set(
|
||||
uniqNormalizedRelays(trustedRelays || []).filter(Boolean)
|
||||
);
|
||||
return trustedSet.has(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts and normalizes photo data from NIP-360 (Place Photos) events.
|
||||
* Sorts chronologically and guarantees the first landscape photo (or first portrait) is at index 0.
|
||||
@@ -67,7 +88,12 @@ export function parsePlacePhotos(events) {
|
||||
let blurhash = null;
|
||||
let isLandscape = false;
|
||||
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 ')) {
|
||||
@@ -85,6 +111,10 @@ export function parsePlacePhotos(events) {
|
||||
isLandscape = true;
|
||||
}
|
||||
}
|
||||
} else if (tag.startsWith('alt ')) {
|
||||
const alt = tag.substring(4).trim();
|
||||
// Strip the legacy placeholder we used to write on every event
|
||||
altText = alt === 'A photo of a place' ? null : alt || null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +123,7 @@ export function parsePlacePhotos(events) {
|
||||
eventId: event.id,
|
||||
pubkey: event.pubkey,
|
||||
createdAt: event.created_at,
|
||||
publishedAt: publishedAt && publishedAt > 0 ? publishedAt : null,
|
||||
url,
|
||||
thumbUrl,
|
||||
blurhash,
|
||||
@@ -100,6 +131,7 @@ export function parsePlacePhotos(events) {
|
||||
aspectRatio,
|
||||
placeIdentifier,
|
||||
tags: eventTagValues,
|
||||
alt: altText,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ export const POI_ICON_RULES = [
|
||||
{ tags: { cuisine: 'coffee_shop' }, icon: 'coffee-bean' },
|
||||
{ tags: { cuisine: 'coffee' }, icon: 'coffee-bean' },
|
||||
|
||||
{ tags: { craft: 'brewery' }, icon: 'barrel-with-bunghole' },
|
||||
|
||||
// General Amenity/Shop Types
|
||||
{ tags: { amenity: 'ice_cream' }, icon: 'ice-cream-on-cone' },
|
||||
{ tags: { cuisine: 'ice_cream' }, icon: 'ice-cream-on-cone' },
|
||||
@@ -26,14 +28,24 @@ export const POI_ICON_RULES = [
|
||||
{ tags: { amenity: 'social_facility' }, icon: 'family-restroom-symbol' },
|
||||
|
||||
{ tags: { amenity: 'bank' }, icon: 'banknote' },
|
||||
{ tags: { amenity: 'atm' }, icon: 'banknote-in-slot' },
|
||||
{ tags: { amenity: 'bureau_de_change' }, icon: 'dollar-swap-to-euro' },
|
||||
{ tags: { amenity: 'place_of_worship' }, icon: 'place-of-worship-building' },
|
||||
{ tags: { amenity: 'townhall' }, icon: 'classical-building-with-clock' },
|
||||
{ tags: { building: 'townhall' }, icon: 'classical-building-with-clock' },
|
||||
{ tags: { amenity: 'fire_station' }, icon: 'badge-shield-with-fire' },
|
||||
{ tags: { amenity: 'police' }, icon: 'police-officer-with-stop-arm' },
|
||||
{ tags: { amenity: 'toilets' }, icon: 'womens-and-mens-restroom-symbol' },
|
||||
{ tags: { amenity: 'smoking_area' }, icon: 'cigarette-with-smoke-curl' },
|
||||
{ tags: { amenity: 'lounge' }, icon: 'couch' },
|
||||
{
|
||||
tags: { amenity: 'drinking_water' },
|
||||
icon: 'water-standpipe-with-handle-and-drinking-glass',
|
||||
},
|
||||
{ tags: { amenity: 'school' }, icon: 'open-book' },
|
||||
{ tags: { amenity: 'driving_school' }, icon: 'car' },
|
||||
{ tags: { amenity: 'car_wash' }, icon: 'car-between-roller-brushes' },
|
||||
{ tags: { amenity: 'fuel' }, icon: 'gasoline-pump' },
|
||||
|
||||
{ tags: { shop: 'coffee' }, icon: 'coffee-bean' },
|
||||
{ tags: { shop: 'tea' }, icon: 'coffee-bean' },
|
||||
@@ -85,9 +97,12 @@ export const POI_ICON_RULES = [
|
||||
{ tags: { natural: 'beach' }, icon: 'beach-umbrella-in-ground' },
|
||||
{ tags: { leisure: 'park' }, icon: 'tree-and-bench-with-backrest' },
|
||||
{ tags: { leisure: 'playground' }, icon: 'play-structure-with-slide' },
|
||||
{ tags: { leisure: 'marina' }, icon: 'anchor' },
|
||||
{ tags: { landuse: 'vineyard' }, icon: 'grapes' },
|
||||
|
||||
// Transport
|
||||
{ tags: { aeroway: 'aerodrome' }, icon: 'plane-top-right' },
|
||||
{ tags: { aeroway: 'terminal' }, icon: 'airport-terminal' },
|
||||
{ tags: { aeroway: 'heliport' }, icon: 'plane-top-right' },
|
||||
{ tags: { aeroway: 'helipad' }, icon: 'plane-top-right' },
|
||||
{ tags: { highway: 'bus_stop' }, icon: 'bus' },
|
||||
@@ -187,6 +202,7 @@ export const POI_ICON_RULES = [
|
||||
// Buildings
|
||||
{ tags: { building: 'commercial' }, icon: 'commercial-building' },
|
||||
{ tags: { building: 'apartments' }, icon: 'lowrise-building' },
|
||||
{ tags: { building: 'office' }, icon: 'lowrise-building' },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
+2
-2
@@ -34,18 +34,18 @@ export function getLocalizedName(tags, defaultName = 'Untitled Place') {
|
||||
}
|
||||
|
||||
const PLACE_TYPE_KEYS = [
|
||||
'craft',
|
||||
'amenity',
|
||||
'shop',
|
||||
'tourism',
|
||||
'historic',
|
||||
'leisure',
|
||||
'office',
|
||||
'craft',
|
||||
'aeroway',
|
||||
'building',
|
||||
'landuse',
|
||||
'public_transport',
|
||||
'highway',
|
||||
'aeroway',
|
||||
'waterway',
|
||||
'natural',
|
||||
'place',
|
||||
|
||||
@@ -2,11 +2,11 @@ import { POI_CATEGORIES } from './poi-categories';
|
||||
import { getMatchingPoiCategoryIds } from './poi-category-matcher';
|
||||
|
||||
export const CATEGORY_TAGS = {
|
||||
restaurants: ['food', 'menu', 'vibe', 'front'],
|
||||
coffee: ['food', 'menu', 'vibe', 'front'],
|
||||
groceries: ['front', 'food'],
|
||||
'things-to-do': ['architecture', 'amenities', 'vibe', 'front'],
|
||||
accommodation: ['rooms', 'amenities', 'food', 'vibe', 'front'],
|
||||
restaurants: ['front', 'food', 'drinks', 'menu', 'vibe'],
|
||||
coffee: ['front', 'food', 'drinks', 'menu', 'vibe'],
|
||||
groceries: ['front', 'products'],
|
||||
'things-to-do': ['front', 'architecture', 'amenities', 'vibe'],
|
||||
accommodation: ['front', 'rooms', 'amenities', 'food', 'drinks', 'vibe'],
|
||||
};
|
||||
|
||||
export function getSuggestedPhotoTags(place) {
|
||||
|
||||
@@ -45,7 +45,7 @@ export const POI_CATEGORIES = [
|
||||
filter: [
|
||||
'["tourism"~"^(museum|gallery|attraction|viewpoint|zoo|theme_park|aquarium|artwork|information)$"]',
|
||||
'["amenity"~"^(cinema|theatre|arts_centre|planetarium)$"]',
|
||||
'["leisure"~"^(sports_centre|stadium|water_park)$"]',
|
||||
'["leisure"~"^(sports_centre|stadium|water_park|golf_course|marina)$"]',
|
||||
'["historic"]',
|
||||
'["shop"="flea_market"]',
|
||||
],
|
||||
|
||||
@@ -37,8 +37,6 @@ export default {
|
||||
'@babel/plugin-transform-runtime',
|
||||
{
|
||||
absoluteRuntime: dirname(fileURLToPath(import.meta.url)),
|
||||
useESModules: true,
|
||||
regenerator: false,
|
||||
},
|
||||
],
|
||||
...macros.babelMacros,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# Relay Trust in Marco
|
||||
|
||||
## Purpose
|
||||
|
||||
Marco fetches Nostr content (currently kind 360 place photos, later kind 30360 reviews) from the user's read relays. Because anyone can run a relay and publish spam, Marco only displays content by default when it was seen on a **trusted relay** — a relay the user (or Marco's defaults) treats as sufficiently moderated.
|
||||
|
||||
## Trust Model
|
||||
|
||||
| Rule | Description |
|
||||
| ------------------------ | ------------------------------------------------------------------------------------------------------------ |
|
||||
| **Required relays** | Always trusted (e.g. `wss://nostr.kosmos.org`). Cannot be untrusted. |
|
||||
| **Custom relays** | Untrusted by default. Users toggle them to trusted in **Settings → Nostr → Read Relays**. |
|
||||
| **Own content** | Events authored by the connected user's pubkey are always trusted. |
|
||||
| **Non-content kinds** | Kind 5 deletions and metadata always pass through unfiltered. |
|
||||
| **Provenance upgrades** | If an event is first seen on an untrusted relay and later on a trusted one, it becomes visible. |
|
||||
| **Hiding, not blocking** | Untrusted content is cached and stored; it is filtered at presentation time so it can be revealed on demand. |
|
||||
|
||||
## How It Works Under the Hood
|
||||
|
||||
### 1. Provenance Capture
|
||||
|
||||
- Instead of `pool.request()` (which drops relay origin via an internal `map(m => m.event)`), Marco uses `RelayPool.req()` from `applesauce-relay`.
|
||||
- Each `EVENT` message includes `from: relayUrl`.
|
||||
- Every sighting is recorded in an in-memory map: `eventId → Set<relayUrl>`.
|
||||
- Duplicate deliveries are _not_ deduplicated at the stream level, so full provenance across all responding relays is preserved.
|
||||
|
||||
### 2. Persistence
|
||||
|
||||
- Provenance is persisted to IndexedDB via `localForage` under the store `event-relay-provenance`.
|
||||
- On app start, it is hydrated into memory so cached photos render instantly with correct trust state before relay connections are established.
|
||||
|
||||
### 3. Trust Evaluation
|
||||
|
||||
- `trustedRelays` = required read relays + `settings.nostrTrustedRelays` (user-marked custom relays).
|
||||
- An event is trusted if any relay URL in its provenance set matches `trustedRelays` (compared via normalized URLs).
|
||||
- Trust is evaluated when presenting the photo timeline for a place.
|
||||
|
||||
### 4. Presentation Filtering
|
||||
|
||||
- `NostrDataService.placePhotos` contains only trusted events.
|
||||
- `untrustedContentCount` tracks how many are hidden.
|
||||
- A session-only toggle `showUntrustedContent` reveals them via the "Show N hidden photo(s) (untrusted relays)" button in place details.
|
||||
|
||||
### 5. Deletions
|
||||
|
||||
- When a kind 5 deletion event enters the store, provenance entries for the referenced event IDs are removed from memory and IndexedDB.
|
||||
|
||||
## Key Code Paths
|
||||
|
||||
| File | What it does |
|
||||
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `app/services/nostr-data.js` | `_requestContentWithProvenance()`, `isTrustedEvent()`, `partitionByTrust()`, `trustedRelays` getter, provenance hydration/persistence |
|
||||
| `app/services/settings.js` | `nostrTrustedRelays` setting (null = required relays only) |
|
||||
| `app/components/app-menu/settings/nostr.gjs` | Per-relay shield toggle in read-relay list |
|
||||
| `app/components/place-details.gjs` | Hidden-content reveal link in meta section |
|
||||
| `app/utils/nostr.js` | `relayUrlMatches()` normalization/matching helper |
|
||||
| `app/utils/icons.js` | `shield` icon used for trusted state |
|
||||
| `app/styles/app.css` | `.btn-trust-relay` styles in relay settings |
|
||||
|
||||
## Extending the Feature
|
||||
|
||||
- **Add kind 30360 reviews:** Add `30360` to `TRUSTED_CONTENT_KINDS` in `app/services/nostr-data.js`. Reuse `partitionByTrust` in the reviews timeline.
|
||||
- **Followee trust:** Extend `isTrustedEvent()` to also check `event.pubkey` against a contact/follow list.
|
||||
- **Spam eviction from cache:** Provenance data already supports this; future work can purge cached events from `nostr-idb` if they only appear on relays marked untrusted.
|
||||
- **Provenance UI:** The stored `eventId → relayUrls` map can power a "Seen on these relays" tooltip or details pane.
|
||||
|
||||
## Known Limitations / Future Work
|
||||
|
||||
- Provenance store is currently unbounded; add LRU/TTL cleanup.
|
||||
- Untrusting a relay hides its content immediately, but does not purge the `nostr-idb` event cache.
|
||||
- Relay settings UI is currently the only way to manage trust; no global "trust all" toggle.
|
||||
+1
-1
@@ -22,7 +22,7 @@ import eslintConfigPrettier from 'eslint-config-prettier';
|
||||
import qunit from 'eslint-plugin-qunit';
|
||||
import n from 'eslint-plugin-n';
|
||||
|
||||
import babelParser from '@babel/eslint-parser/experimental-worker';
|
||||
import babelParser from '@babel/eslint-parser';
|
||||
|
||||
const esmParserOptions = {
|
||||
ecmaFeatures: { modules: true },
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
<meta name="description" content="Unhosted maps app that respects your privacy and choices.">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<!-- Fediverse account -->
|
||||
<link rel="me" href="https://kosmos.social/@marco">
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="Marco">
|
||||
<meta property="og:description" content="Unhosted maps app that respects your privacy and choices.">
|
||||
|
||||
+58
-55
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "marco",
|
||||
"version": "1.25.0",
|
||||
"version": "1.32.0",
|
||||
"private": true,
|
||||
"description": "Unhosted maps app",
|
||||
"repository": {
|
||||
@@ -39,61 +39,61 @@
|
||||
"version": "pnpm build && git add release/"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.28.5",
|
||||
"@babel/eslint-parser": "^7.28.5",
|
||||
"@babel/plugin-transform-runtime": "^7.28.5",
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"@ember/test-helpers": "^5.4.1",
|
||||
"@ember/test-waiters": "^4.1.1",
|
||||
"@embroider/core": "^4.4.2",
|
||||
"@babel/core": "^8.0.1",
|
||||
"@babel/eslint-parser": "^8.0.1",
|
||||
"@babel/plugin-transform-runtime": "^8.0.1",
|
||||
"@babel/runtime": "^8.0.0",
|
||||
"@ember/test-helpers": "^5.4.3",
|
||||
"@ember/test-waiters": "^4.1.2",
|
||||
"@embroider/core": "^4.6.3",
|
||||
"@embroider/legacy-inspector-support": "^0.1.3",
|
||||
"@embroider/macros": "^1.19.6",
|
||||
"@embroider/macros": "^1.20.6",
|
||||
"@embroider/router": "^3.0.6",
|
||||
"@embroider/vite": "^1.5.0",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@glimmer/component": "^2.0.0",
|
||||
"@embroider/vite": "^1.7.9",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@glimmer/component": "^2.1.1",
|
||||
"@remotestorage/module-places": "~1.3.0",
|
||||
"@rollup/plugin-babel": "^6.1.0",
|
||||
"@warp-drive/core": "~5.8.0",
|
||||
"@warp-drive/ember": "~5.8.0",
|
||||
"@warp-drive/json-api": "~5.8.0",
|
||||
"@warp-drive/legacy": "~5.8.0",
|
||||
"@warp-drive/utilities": "~5.8.0",
|
||||
"babel-plugin-ember-template-compilation": "^3.0.1",
|
||||
"concurrently": "^9.2.1",
|
||||
"decorator-transforms": "^2.3.1",
|
||||
"ember-cli": "^6.10.0",
|
||||
"ember-cli-deprecation-workflow": "^4.0.0",
|
||||
"ember-modifier": "^4.2.2",
|
||||
"@rollup/plugin-babel": "^7.1.0",
|
||||
"@warp-drive/core": "~5.8.2",
|
||||
"@warp-drive/ember": "~5.8.2",
|
||||
"@warp-drive/json-api": "~5.8.2",
|
||||
"@warp-drive/legacy": "~5.8.2",
|
||||
"@warp-drive/utilities": "~5.8.2",
|
||||
"babel-plugin-ember-template-compilation": "^4.0.0",
|
||||
"concurrently": "^10.0.4",
|
||||
"decorator-transforms": "^2.4.0",
|
||||
"ember-cli": "^7.1.0",
|
||||
"ember-cli-deprecation-workflow": "^4.0.1",
|
||||
"ember-modifier": "^4.3.0",
|
||||
"ember-page-title": "^9.0.3",
|
||||
"ember-qunit": "^9.0.4",
|
||||
"ember-resolver": "^13.1.1",
|
||||
"ember-source": "~6.11.0-alpha.6",
|
||||
"ember-qunit": "^9.1.0",
|
||||
"ember-resolver": "^13.2.0",
|
||||
"ember-source": "~7.2.0",
|
||||
"ember-template-lint": "^7.9.3",
|
||||
"ember-truth-helpers": "^5.0.0",
|
||||
"ember-welcome-page": "^8.0.4",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-ember": "^12.7.5",
|
||||
"eslint-plugin-n": "^17.23.1",
|
||||
"eslint-plugin-qunit": "^8.2.5",
|
||||
"eslint-plugin-warp-drive": "^5.8.0",
|
||||
"eslint-plugin-ember": "^13.5.0",
|
||||
"eslint-plugin-n": "^18.3.0",
|
||||
"eslint-plugin-qunit": "^8.2.6",
|
||||
"eslint-plugin-warp-drive": "^5.8.2",
|
||||
"feather-icons": "^4.29.2",
|
||||
"globals": "^16.5.0",
|
||||
"globals": "^17.11.0",
|
||||
"latlon-geohash": "^2.0.0",
|
||||
"ol": "^10.7.0",
|
||||
"ol-mapbox-style": "^13.2.0",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-ember-template-tag": "^2.1.2",
|
||||
"qunit": "^2.25.0",
|
||||
"qunit-dom": "^3.5.0",
|
||||
"ol": "^10.10.0",
|
||||
"ol-mapbox-style": "^13.4.2",
|
||||
"prettier": "^3.9.6",
|
||||
"prettier-plugin-ember-template-tag": "^2.1.7",
|
||||
"qunit": "^2.26.0",
|
||||
"qunit-dom": "^3.6.0",
|
||||
"remotestorage-widget": "^1.8.1",
|
||||
"remotestoragejs": "2.0.0-beta.9",
|
||||
"sinon": "^21.0.1",
|
||||
"stylelint": "^16.26.1",
|
||||
"stylelint-config-standard": "^38.0.0",
|
||||
"testem": "^3.17.0",
|
||||
"vite": "^7.3.0"
|
||||
"remotestoragejs": "2.0.0-beta.10",
|
||||
"sinon": "^22.1.0",
|
||||
"stylelint": "^17.14.1",
|
||||
"stylelint-config-standard": "^40.0.0",
|
||||
"testem": "^3.20.1",
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
@@ -102,18 +102,21 @@
|
||||
"edition": "octane"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@waysidemapping/pinhead": "^15.20.0",
|
||||
"applesauce-core": "^5.2.0",
|
||||
"applesauce-factory": "^4.0.0",
|
||||
"applesauce-relay": "^5.2.0",
|
||||
"applesauce-signers": "^5.2.0",
|
||||
"@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",
|
||||
"ember-concurrency": "^5.2.0",
|
||||
"ember-lifeline": "^7.0.0",
|
||||
"nostr-idb": "^5.0.0",
|
||||
"oauth2-pkce": "^2.1.3",
|
||||
"ember-lifeline": "^7.1.0",
|
||||
"localforage": "^1.10.0",
|
||||
"nostr-idb": "^5.1.0",
|
||||
"oauth2-pkce": "^3.0.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"rxjs": "^7.8.2"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@11.22.0+sha512.1ff870c4c6133dfd88fb2afc46dd13d47f09c9794b438c6fdb47ca98caf3bc16381ee0be93a091b8e3824cf01f889f46d7d9e20910fb0be1ab0fb5baa80dd621"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
diff --git a/async-arrow-task-transform.js b/async-arrow-task-transform.js
|
||||
index 4eb856a2a7753ef583e10e07f2b602c0c1c0c21d..6a808d035834567dcfc491ecaa7f59e77d344887 100644
|
||||
--- a/async-arrow-task-transform.js
|
||||
+++ b/async-arrow-task-transform.js
|
||||
@@ -331,7 +331,7 @@ function cleanupUnusedTaskFactoryImports(programPath, usedTaskFactories) {
|
||||
}
|
||||
|
||||
module.exports = declare((api) => {
|
||||
- api.assertVersion(7);
|
||||
+ // api.assertVersion(7);
|
||||
|
||||
return {
|
||||
name: 'transform-ember-concurrency-async-function-tasks',
|
||||
Generated
+4353
-3158
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
allowBuilds:
|
||||
core-js: true
|
||||
esbuild: true
|
||||
patchedDependencies:
|
||||
ember-concurrency@5.2.0: patches/ember-concurrency@5.2.0.patch
|
||||
@@ -1 +0,0 @@
|
||||
!function(){"use strict";var t=["0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","#","$","%","*","+",",","-",".",":",";","=","?","@","[","]","^","_","{","|","}","~"],e=(e,a)=>{var o="";for(let r=1;r<=a;r++){let h=Math.floor(e)/Math.pow(83,a-r)%83;o+=t[Math.floor(h)]}return o},a=t=>{let e=t/255;return e<=.04045?e/12.92:Math.pow((e+.055)/1.055,2.4)},o=t=>{let e=Math.max(0,Math.min(1,t));return e<=.0031308?Math.trunc(12.92*e*255+.5):Math.trunc(255*(1.055*Math.pow(e,.4166666666666667)-.055)+.5)},r=(t,e)=>(t=>t<0?-1:1)(t)*Math.pow(Math.abs(t),e),h=class extends Error{constructor(t){super(t),this.name="ValidationError",this.message=t}},i=(t,e,o,r)=>{let h=0,i=0,n=0,s=4*e;for(let g=0;g<e;g++){let e=4*g;for(let l=0;l<o;l++){let o=e+l*s,c=r(g,l);h+=c*a(t[o]),i+=c*a(t[o+1]),n+=c*a(t[o+2])}}let l=1/(e*o);return[h*l,i*l,n*l]};self.onmessage=async t=>{if("PROCESS_IMAGE"!==t.data?.type)return;const{id:a,file:n,targetWidth:s,targetHeight:l,quality:g,computeBlurhash:c}=t.data;try{let t,M;try{const e=await createImageBitmap(n,{resizeWidth:s,resizeHeight:l,resizeQuality:"high"});if(t=new OffscreenCanvas(s,l),M=t.getContext("2d"),!M)throw new Error("Failed to get 2d context from OffscreenCanvas");M.drawImage(e,0,0,s,l),e.close()}catch(f){console.warn("Hardware resize failed, falling back to stepped software scaling:",f);const e=await n.arrayBuffer(),a=new Blob([e],{type:n.type}),o=await createImageBitmap(a);let r=o.width,h=o.height,i=new OffscreenCanvas(r,h),g=i.getContext("2d");for(g.imageSmoothingEnabled=!0,g.imageSmoothingQuality="high",g.drawImage(o,0,0);.5*i.width>s&&.5*i.height>l;){const t=new OffscreenCanvas(Math.floor(.5*i.width),Math.floor(.5*i.height)),e=t.getContext("2d");e.imageSmoothingEnabled=!0,e.imageSmoothingQuality="high",e.drawImage(i,0,0,t.width,t.height),i=t}t=new OffscreenCanvas(s,l),M=t.getContext("2d"),M.imageSmoothingEnabled=!0,M.imageSmoothingQuality="high",M.drawImage(i,0,0,s,l),o.close()}let d=null;if(c)try{d=((t,a,n)=>{if(a*n*4!==t.length)throw new h("Width and height must match the pixels array");let s=[];for(let e=0;e<3;e++)for(let o=0;o<4;o++){let r=0==o&&0==e?1:2,h=i(t,a,n,(t,h)=>r*Math.cos(Math.PI*o*t/a)*Math.cos(Math.PI*e*h/n));s.push(h)}let l,g=s[0],c=s.slice(1),f="";if(f+=e(21,1),c.length>0){let t=Math.max(...c.map(t=>Math.max(...t))),a=Math.floor(Math.max(0,Math.min(82,Math.floor(166*t-.5))));l=(a+1)/166,f+=e(a,1)}else l=1,f+=e(0,1);return f+=e((t=>(o(t[0])<<16)+(o(t[1])<<8)+o(t[2]))(g),4),c.forEach(t=>{f+=e(((t,e)=>19*Math.floor(Math.max(0,Math.min(18,Math.floor(9*r(t[0]/e,.5)+9.5))))*19+19*Math.floor(Math.max(0,Math.min(18,Math.floor(9*r(t[1]/e,.5)+9.5))))+Math.floor(Math.max(0,Math.min(18,Math.floor(9*r(t[2]/e,.5)+9.5)))))(t,l),2)}),f})(M.getImageData(0,0,s,l).data,s,l)}catch(m){console.warn("Could not generate blurhash (possible canvas fingerprinting protection):",m)}const u=await t.convertToBlob({type:"image/jpeg",quality:g}),w=`${s}x${l}`;self.postMessage({id:a,success:!0,blob:u,dim:w,blurhash:d})}catch(M){self.postMessage({id:a,success:!1,error:M.message})}}}();
|
||||
@@ -0,0 +1,182 @@
|
||||
var q = [
|
||||
"0",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
"8",
|
||||
"9",
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"D",
|
||||
"E",
|
||||
"F",
|
||||
"G",
|
||||
"H",
|
||||
"I",
|
||||
"J",
|
||||
"K",
|
||||
"L",
|
||||
"M",
|
||||
"N",
|
||||
"O",
|
||||
"P",
|
||||
"Q",
|
||||
"R",
|
||||
"S",
|
||||
"T",
|
||||
"U",
|
||||
"V",
|
||||
"W",
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
"d",
|
||||
"e",
|
||||
"f",
|
||||
"g",
|
||||
"h",
|
||||
"i",
|
||||
"j",
|
||||
"k",
|
||||
"l",
|
||||
"m",
|
||||
"n",
|
||||
"o",
|
||||
"p",
|
||||
"q",
|
||||
"r",
|
||||
"s",
|
||||
"t",
|
||||
"u",
|
||||
"v",
|
||||
"w",
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"#",
|
||||
"$",
|
||||
"%",
|
||||
"*",
|
||||
"+",
|
||||
",",
|
||||
"-",
|
||||
".",
|
||||
":",
|
||||
";",
|
||||
"=",
|
||||
"?",
|
||||
"@",
|
||||
"[",
|
||||
"]",
|
||||
"^",
|
||||
"_",
|
||||
"{",
|
||||
"|",
|
||||
"}",
|
||||
"~"
|
||||
], p = (t, e) => {
|
||||
var a = "";
|
||||
for (let o = 1; o <= e; o++) {
|
||||
let h = Math.floor(t) / Math.pow(83, e - o) % 83;
|
||||
a += q[Math.floor(h)];
|
||||
}
|
||||
return a;
|
||||
}, f = (t) => {
|
||||
let e = t / 255;
|
||||
return e <= .04045 ? e / 12.92 : Math.pow((e + .055) / 1.055, 2.4);
|
||||
}, h = (t) => {
|
||||
let e = Math.max(0, Math.min(1, t));
|
||||
return e <= .0031308 ? Math.trunc(12.92 * e * 255 + .5) : Math.trunc(255 * (1.055 * Math.pow(e, .4166666666666667) - .055) + .5);
|
||||
}, M = (t, e) => ((t) => t < 0 ? -1 : 1)(t) * Math.pow(Math.abs(t), e), d = class extends Error {
|
||||
constructor(t) {
|
||||
super(t), this.name = "ValidationError", this.message = t;
|
||||
}
|
||||
}, D = (t, e, a, o) => {
|
||||
let h = 0, r = 0, n = 0, i = 4 * e;
|
||||
for (let l = 0; l < e; l++) {
|
||||
let e = 4 * l;
|
||||
for (let s = 0; s < a; s++) {
|
||||
let a = e + s * i, g = o(l, s);
|
||||
h += g * f(t[a]), r += g * f(t[a + 1]), n += g * f(t[a + 2]);
|
||||
}
|
||||
}
|
||||
let s = 1 / (e * a);
|
||||
return [
|
||||
h * s,
|
||||
r * s,
|
||||
n * s
|
||||
];
|
||||
}, S = (t, e, a, o, r) => {
|
||||
if (o < 1 || o > 9 || r < 1 || r > 9) throw new d("BlurHash must have between 1 and 9 components");
|
||||
if (e * a * 4 !== t.length) throw new d("Width and height must match the pixels array");
|
||||
let n = [];
|
||||
for (let h = 0; h < r; h++) for (let r = 0; r < o; r++) {
|
||||
let o = 0 == r && 0 == h ? 1 : 2, i = D(t, e, a, (t, n) => o * Math.cos(Math.PI * r * t / e) * Math.cos(Math.PI * h * n / a));
|
||||
n.push(i);
|
||||
}
|
||||
let i, s = n[0], l = n.slice(1), g = "";
|
||||
if (g += p(o - 1 + 9 * (r - 1), 1), l.length > 0) {
|
||||
let t = Math.max(...l.map((t) => Math.max(...t))), e = Math.floor(Math.max(0, Math.min(82, Math.floor(166 * t - .5))));
|
||||
i = (e + 1) / 166, g += p(e, 1);
|
||||
} else i = 1, g += p(0, 1);
|
||||
return g += p(((t) => (h(t[0]) << 16) + (h(t[1]) << 8) + h(t[2]))(s), 4), l.forEach((t) => {
|
||||
g += p(((t, e) => 19 * Math.floor(Math.max(0, Math.min(18, Math.floor(9 * M(t[0] / e, .5) + 9.5)))) * 19 + 19 * Math.floor(Math.max(0, Math.min(18, Math.floor(9 * M(t[1] / e, .5) + 9.5)))) + Math.floor(Math.max(0, Math.min(18, Math.floor(9 * M(t[2] / e, .5) + 9.5)))))(t, i), 2);
|
||||
}), g;
|
||||
};
|
||||
self.onmessage = async (t) => {
|
||||
if ("PROCESS_IMAGE" !== t.data?.type) return;
|
||||
const { id: e, file: a, targetWidth: o, targetHeight: h, quality: r, computeBlurhash: n } = t.data;
|
||||
try {
|
||||
let t, l;
|
||||
try {
|
||||
const e = await createImageBitmap(a, {
|
||||
resizeWidth: o,
|
||||
resizeHeight: h,
|
||||
resizeQuality: "high"
|
||||
});
|
||||
if (t = new OffscreenCanvas(o, h), l = t.getContext("2d"), !l) throw new Error("Failed to get 2d context from OffscreenCanvas");
|
||||
l.drawImage(e, 0, 0, o, h), e.close();
|
||||
} catch (i) {
|
||||
console.warn("Hardware resize failed, falling back to stepped software scaling:", i);
|
||||
const e = await a.arrayBuffer(), r = new Blob([e], { type: a.type }), n = await createImageBitmap(r);
|
||||
let s = n.width, g = n.height, f = new OffscreenCanvas(s, g), c = f.getContext("2d");
|
||||
for (c.imageSmoothingEnabled = !0, c.imageSmoothingQuality = "high", c.drawImage(n, 0, 0); .5 * f.width > o && .5 * f.height > h;) {
|
||||
const t = new OffscreenCanvas(Math.floor(.5 * f.width), Math.floor(.5 * f.height)), e = t.getContext("2d");
|
||||
e.imageSmoothingEnabled = !0, e.imageSmoothingQuality = "high", e.drawImage(f, 0, 0, t.width, t.height), f = t;
|
||||
}
|
||||
t = new OffscreenCanvas(o, h), l = t.getContext("2d"), l.imageSmoothingEnabled = !0, l.imageSmoothingQuality = "high", l.drawImage(f, 0, 0, o, h), n.close();
|
||||
}
|
||||
let g = null;
|
||||
if (n) try {
|
||||
const t = l.getImageData(0, 0, o, h);
|
||||
g = S(t.data, o, h, 4, 3);
|
||||
} catch (s) {
|
||||
console.warn("Could not generate blurhash (possible canvas fingerprinting protection):", s);
|
||||
}
|
||||
const f = await t.convertToBlob({
|
||||
type: "image/jpeg",
|
||||
quality: r
|
||||
}), c = `${o}x${h}`;
|
||||
self.postMessage({
|
||||
id: e,
|
||||
success: !0,
|
||||
blob: f,
|
||||
dim: c,
|
||||
blurhash: g
|
||||
});
|
||||
} catch (l) {
|
||||
self.postMessage({
|
||||
id: e,
|
||||
success: !1,
|
||||
error: l.message
|
||||
});
|
||||
}
|
||||
};
|
||||
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
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
+5
-2
@@ -6,6 +6,9 @@
|
||||
<meta name="description" content="Unhosted maps app that respects your privacy and choices.">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<!-- Fediverse account -->
|
||||
<link rel="me" href="https://kosmos.social/@marco">
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="Marco">
|
||||
<meta property="og:description" content="Unhosted maps app that respects your privacy and choices.">
|
||||
@@ -39,8 +42,8 @@
|
||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||
|
||||
<script type="module" crossorigin src="/assets/main-BVNM87jL.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-BGF-Udec.css">
|
||||
<script type="module" crossorigin src="/assets/main-Znuwpp9Y.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-CEhcTO-R.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="modal-portal"></div>
|
||||
|
||||
+9
-6
@@ -4,17 +4,20 @@
|
||||
"ember-best-practices": {
|
||||
"source": "nullvoxpopuli/agent-skills",
|
||||
"sourceType": "github",
|
||||
"computedHash": "7909c3def6c4ddefb358d1973cf724269ede9f6cdba1dd2888e4e6072a897f3e"
|
||||
},
|
||||
"nak": {
|
||||
"source": "soapbox-pub/nostr-skills",
|
||||
"sourceType": "github",
|
||||
"computedHash": "710d3f3945ff421ed2b7f40ecd32c5e263bc029d43fe8f4fd1491a8013c7389a"
|
||||
"skillPath": "skills/ember-best-practices/SKILL.md",
|
||||
"computedHash": "34e82668e2fa1e6b025ac8cf1015634484bf5d8fbc80e8aa642489d2df79498c"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { visit, currentURL, click, waitFor } from '@ember/test-helpers';
|
||||
import { setupApplicationTest } from 'marco/tests/helpers';
|
||||
import Service from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
class MockActivityService extends Service {
|
||||
@tracked items = [
|
||||
{
|
||||
type: 'zap',
|
||||
photoEventId: 'photo-1',
|
||||
photo: {
|
||||
url: 'https://x.com/photo.jpg',
|
||||
thumbUrl: 'https://x.com/thumb.jpg',
|
||||
},
|
||||
placeIdentifier: 'osm:node:123',
|
||||
senderPubkey: 'b'.repeat(64),
|
||||
amountSats: 21,
|
||||
message: 'Great photo!',
|
||||
createdAt: Math.floor(Date.now() / 1000) - 3600,
|
||||
senderName: 'Alice',
|
||||
senderAvatar: 'https://x.com/avatar.jpg',
|
||||
senderProfileLoading: false,
|
||||
},
|
||||
];
|
||||
|
||||
async load() {}
|
||||
|
||||
stop() {
|
||||
this.items = [];
|
||||
}
|
||||
}
|
||||
|
||||
class MockNostrAuthService extends Service {
|
||||
@tracked pubkey = 'test-pubkey';
|
||||
|
||||
get isConnected() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class MockStorageService extends Service {
|
||||
initialSyncDone = true;
|
||||
savedPlaces = [];
|
||||
findPlaceById() {
|
||||
return null;
|
||||
}
|
||||
isPlaceSaved() {
|
||||
return false;
|
||||
}
|
||||
loadPlacesInBounds() {
|
||||
return [];
|
||||
}
|
||||
get placesInView() {
|
||||
return [];
|
||||
}
|
||||
rs = {
|
||||
on: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
class MockOsmService extends Service {
|
||||
async fetchOsmObject() {
|
||||
return {
|
||||
osmId: '123',
|
||||
osmType: 'node',
|
||||
lat: 1,
|
||||
lon: 1,
|
||||
osmTags: { name: 'Test Place', amenity: 'cafe' },
|
||||
title: 'Test Place',
|
||||
};
|
||||
}
|
||||
|
||||
getCachedOsmObject() {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
}
|
||||
|
||||
module('Acceptance | activity', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.owner.register('service:activity', MockActivityService);
|
||||
this.owner.register('service:nostrAuth', MockNostrAuthService);
|
||||
this.owner.register('service:storage', MockStorageService);
|
||||
this.owner.register('service:osm', MockOsmService);
|
||||
});
|
||||
|
||||
test('visiting /activity renders the sidebar with activity items', async function (assert) {
|
||||
await visit('/activity');
|
||||
assert.strictEqual(currentURL(), '/activity');
|
||||
|
||||
assert.dom('.sidebar').exists('Sidebar is rendered');
|
||||
assert.dom('.sidebar-header-text-centered').includesText('Activity');
|
||||
});
|
||||
|
||||
test('activity items are rendered as zap rows', async function (assert) {
|
||||
await visit('/activity');
|
||||
|
||||
await waitFor('.activity-zap-item');
|
||||
assert.dom('.activity-zap-item').exists({ count: 1 });
|
||||
assert.dom('.zap-sender-name').hasText('Alice');
|
||||
assert.dom('.zap-action').includesText('zapped your photo');
|
||||
assert.dom('.zap-amount').includesText('21 ⚡');
|
||||
assert.dom('.zap-message').includesText('Great photo!');
|
||||
});
|
||||
|
||||
test('closing the sidebar returns to index', async function (assert) {
|
||||
await visit('/activity');
|
||||
assert.strictEqual(currentURL(), '/activity');
|
||||
|
||||
await click('.sidebar-header .close-btn');
|
||||
assert.strictEqual(currentURL(), '/', 'Returns to index');
|
||||
});
|
||||
|
||||
test('clicking back button returns to menu', async function (assert) {
|
||||
await visit('/activity');
|
||||
|
||||
await click('.sidebar-header .back-btn');
|
||||
assert.strictEqual(currentURL(), '/menu', 'Returns to menu');
|
||||
});
|
||||
|
||||
test('clicking a zap item navigates to the place', async function (assert) {
|
||||
const mapUi = this.owner.lookup('service:map-ui');
|
||||
|
||||
await visit('/activity');
|
||||
await waitFor('.activity-zap-item');
|
||||
|
||||
await click('.activity-zap-item');
|
||||
|
||||
assert.ok(
|
||||
currentURL().includes('/place/osm:node:123'),
|
||||
'Transitions to place details'
|
||||
);
|
||||
assert.deepEqual(
|
||||
mapUi.returnToRoute,
|
||||
{ name: 'activity' },
|
||||
'returnToRoute is set to activity'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -135,4 +135,85 @@ module('Acceptance | collections navigation', function (hooks) {
|
||||
'Returns gracefully back to lists/to-go list view'
|
||||
);
|
||||
});
|
||||
|
||||
test('places inside a collection are sorted by createdAt descending', async function (assert) {
|
||||
class SortedMockStorageService extends Service {
|
||||
initialSyncDone = true;
|
||||
savedPlaces = [
|
||||
{
|
||||
id: 'place-oldest',
|
||||
title: 'Oldest Place',
|
||||
geohash: 'u33dc0',
|
||||
createdAt: '2023-01-01T12:00:00.000Z',
|
||||
osmTags: { name: 'Oldest Place' },
|
||||
},
|
||||
{
|
||||
id: 'place-newest',
|
||||
title: 'Newest Place',
|
||||
geohash: 'u33dc0',
|
||||
createdAt: '2023-01-03T12:00:00.000Z',
|
||||
osmTags: { name: 'Newest Place' },
|
||||
},
|
||||
{
|
||||
id: 'place-middle',
|
||||
title: 'Middle Place',
|
||||
geohash: 'u33dc0',
|
||||
createdAt: '2023-01-02T12:00:00.000Z',
|
||||
updatedAt: '2023-01-04T12:00:00.000Z',
|
||||
osmTags: { name: 'Middle Place' },
|
||||
},
|
||||
];
|
||||
lists = [
|
||||
{
|
||||
id: 'to-go',
|
||||
title: 'Want to go',
|
||||
color: '#2e9e4f',
|
||||
placeRefs: [
|
||||
{ id: 'place-oldest', geohash: 'u33dc0' },
|
||||
{ id: 'place-newest', geohash: 'u33dc0' },
|
||||
{ id: 'place-middle', geohash: 'u33dc0' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
findPlaceById(id) {
|
||||
return this.savedPlaces.find((p) => p.id === id) || null;
|
||||
}
|
||||
|
||||
isPlaceSaved() {
|
||||
return true;
|
||||
}
|
||||
|
||||
loadPlacesInBounds() {
|
||||
return [];
|
||||
}
|
||||
|
||||
getPlacesInList(listId) {
|
||||
if (listId === 'to-go') {
|
||||
return Promise.resolve(this.savedPlaces);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
rs = {
|
||||
on: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
this.owner.unregister('service:storage');
|
||||
this.owner.register('service:storage', SortedMockStorageService);
|
||||
|
||||
await visit('/lists/to-go');
|
||||
await waitFor('.places-list');
|
||||
|
||||
const placeNames = Array.from(
|
||||
document.querySelectorAll('.places-list .place-name')
|
||||
).map((el) => el.textContent.trim());
|
||||
|
||||
assert.deepEqual(
|
||||
placeNames,
|
||||
['Newest Place', 'Middle Place', 'Oldest Place'],
|
||||
'Places are ordered by createdAt in descending order'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { visit, currentURL, click, waitFor } from '@ember/test-helpers';
|
||||
import { setupApplicationTest } from 'marco/tests/helpers';
|
||||
import Service from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
class MockOsmService extends Service {
|
||||
fetchOsmObjectCalls = [];
|
||||
|
||||
async fetchOsmObject(id, type) {
|
||||
this.fetchOsmObjectCalls.push({ id, type });
|
||||
return {
|
||||
osmId: String(id),
|
||||
osmType: type,
|
||||
lat: 1,
|
||||
lon: 1,
|
||||
osmTags: { name: 'Test Place', amenity: 'cafe' },
|
||||
title: 'Test Place',
|
||||
};
|
||||
}
|
||||
|
||||
getCachedOsmObject() {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
}
|
||||
|
||||
class MockStorageService extends Service {
|
||||
initialSyncDone = true;
|
||||
savedPlaces = [];
|
||||
findPlaceById() {
|
||||
return null;
|
||||
}
|
||||
isPlaceSaved() {
|
||||
return false;
|
||||
}
|
||||
loadPlacesInBounds() {
|
||||
return [];
|
||||
}
|
||||
get placesInView() {
|
||||
return [];
|
||||
}
|
||||
rs = {
|
||||
on: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
class MockContributionsService extends Service {
|
||||
@tracked items = [
|
||||
{
|
||||
type: 'photo',
|
||||
placeIdentifier: 'osm:node:123',
|
||||
osmType: 'node',
|
||||
osmId: '123',
|
||||
placeName: 'Test Place',
|
||||
placeNameLoading: false,
|
||||
createdAt: 2000,
|
||||
photos: [
|
||||
{
|
||||
url: 'https://x.com/photo.jpg',
|
||||
thumbUrl: 'https://x.com/thumb.jpg',
|
||||
tags: ['food'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
async load() {}
|
||||
stop() {
|
||||
this.items = [];
|
||||
}
|
||||
}
|
||||
|
||||
class MockNostrAuthService extends Service {
|
||||
@tracked pubkey = 'test-pubkey';
|
||||
|
||||
get isConnected() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
module('Acceptance | contributions navigation', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.owner.register('service:osm', MockOsmService);
|
||||
this.owner.register('service:storage', MockStorageService);
|
||||
this.owner.register('service:contributions', MockContributionsService);
|
||||
this.owner.register('service:nostrAuth', MockNostrAuthService);
|
||||
});
|
||||
|
||||
test('navigating from menu to contributions and back to menu', async function (assert) {
|
||||
await visit('/');
|
||||
assert.strictEqual(currentURL(), '/');
|
||||
|
||||
// Open the app menu
|
||||
await click('.menu-btn-integrated');
|
||||
assert.dom('.sidebar.app-menu-pane').exists('App menu sidebar is open');
|
||||
|
||||
// Click "My Contributions"
|
||||
const buttons = document.querySelectorAll('.app-menu button');
|
||||
const contributionsBtn = Array.from(buttons).find((b) =>
|
||||
b.textContent.includes('My Contributions')
|
||||
);
|
||||
await click(contributionsBtn);
|
||||
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
'/contributions',
|
||||
'Transitions to /contributions'
|
||||
);
|
||||
assert
|
||||
.dom('.sidebar-header-text-centered')
|
||||
.includesText('My Contributions', 'Header shows the title');
|
||||
|
||||
// Contribution item should be visible
|
||||
await waitFor('.contribution-item');
|
||||
assert.dom('.contribution-place').includesText('Test Place');
|
||||
|
||||
// Click back button -> returns to menu
|
||||
await click('.sidebar-header .back-btn');
|
||||
assert.strictEqual(currentURL(), '/menu', 'Goes back to /menu');
|
||||
});
|
||||
|
||||
test('clicking a contribution opens place details and back returns to contributions', async function (assert) {
|
||||
const mapUi = this.owner.lookup('service:map-ui');
|
||||
|
||||
await visit('/contributions');
|
||||
await waitFor('.contribution-item');
|
||||
|
||||
// Click the contribution item
|
||||
await click('.contribution-item');
|
||||
assert.ok(
|
||||
currentURL().includes('/place/osm:node:123'),
|
||||
'Transitions to place details'
|
||||
);
|
||||
assert.deepEqual(
|
||||
mapUi.returnToRoute,
|
||||
{ name: 'contributions' },
|
||||
'returnToRoute is set to contributions'
|
||||
);
|
||||
|
||||
// Click back from place details
|
||||
await click('.back-btn');
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
'/contributions',
|
||||
'Returns to contributions list'
|
||||
);
|
||||
});
|
||||
|
||||
test('closing the contributions sidebar returns to index', async function (assert) {
|
||||
await visit('/contributions');
|
||||
await waitFor('.sidebar');
|
||||
|
||||
await click('.sidebar-header .close-btn');
|
||||
assert.strictEqual(currentURL(), '/', 'Returns to index');
|
||||
});
|
||||
|
||||
test('contributions route saves scroll position when selecting a place', async function (assert) {
|
||||
const mapUi = this.owner.lookup('service:map-ui');
|
||||
|
||||
await visit('/contributions');
|
||||
await waitFor('.contribution-item');
|
||||
|
||||
// The scroll position key should start at 0
|
||||
assert.strictEqual(
|
||||
mapUi.getScrollPosition('contributions'),
|
||||
0,
|
||||
'Initial scroll position is 0'
|
||||
);
|
||||
|
||||
await click('.contribution-item');
|
||||
|
||||
// After navigating to a place, the scroll position should have been saved
|
||||
// (0 in this case since the sidebar wasn't scrolled, but the key should exist)
|
||||
assert.ok(
|
||||
mapUi.returnToRoute,
|
||||
'returnToRoute is set when selecting a contribution'
|
||||
);
|
||||
});
|
||||
|
||||
test('selecting a contribution runs the model hook so the place has lat/lon', async function (assert) {
|
||||
const mapUi = this.owner.lookup('service:map-ui');
|
||||
const osm = this.owner.lookup('service:osm');
|
||||
|
||||
await visit('/contributions');
|
||||
await waitFor('.contribution-item');
|
||||
|
||||
await click('.contribution-item');
|
||||
|
||||
assert.ok(
|
||||
currentURL().includes('/place/osm:node:123'),
|
||||
'Transitions to place details via URL'
|
||||
);
|
||||
|
||||
// The model hook should have called fetchOsmObject (not skipped it)
|
||||
assert.ok(
|
||||
osm.fetchOsmObjectCalls.length > 0,
|
||||
'OSM fetchOsmObject was called (model hook ran)'
|
||||
);
|
||||
|
||||
// The selected place should have lat/lon so the map can pan
|
||||
assert.ok(
|
||||
mapUi.selectedPlace?.lat && mapUi.selectedPlace?.lon,
|
||||
'Selected place has lat/lon coordinates'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
setupTest as upstreamSetupTest,
|
||||
} from 'ember-qunit';
|
||||
import { setupNostrMocks } from './mock-nostr';
|
||||
import { setupLocalForageMock } from './mock-local-forage';
|
||||
import sinon from 'sinon';
|
||||
|
||||
function setupMapStyleMocks(hooks) {
|
||||
@@ -98,6 +99,7 @@ function setupMapStyleMocks(hooks) {
|
||||
function setupApplicationTest(hooks, options) {
|
||||
upstreamSetupApplicationTest(hooks, options);
|
||||
setupNostrMocks(hooks);
|
||||
setupLocalForageMock(hooks);
|
||||
setupMapStyleMocks(hooks);
|
||||
|
||||
// Additional setup for application tests can be done here.
|
||||
@@ -119,6 +121,7 @@ function setupApplicationTest(hooks, options) {
|
||||
function setupRenderingTest(hooks, options) {
|
||||
upstreamSetupRenderingTest(hooks, options);
|
||||
setupNostrMocks(hooks);
|
||||
setupLocalForageMock(hooks);
|
||||
|
||||
// Additional setup for rendering tests can be done here.
|
||||
}
|
||||
@@ -126,6 +129,7 @@ function setupRenderingTest(hooks, options) {
|
||||
function setupTest(hooks, options) {
|
||||
upstreamSetupTest(hooks, options);
|
||||
setupNostrMocks(hooks);
|
||||
setupLocalForageMock(hooks);
|
||||
|
||||
// Additional setup for unit tests can be done here.
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import Service from '@ember/service';
|
||||
|
||||
/**
|
||||
* In-memory mock of the `localForage` service for tests. Uses per-store
|
||||
* `Map`s so tests stay isolated from real IndexedDB state and stay
|
||||
* deterministic.
|
||||
*
|
||||
* Mirrors the real service's interface: `get/set/remove/keys/clear/iterate`.
|
||||
*/
|
||||
export class MockLocalForageService extends Service {
|
||||
_stores = new Map();
|
||||
|
||||
_store(name) {
|
||||
let store = this._stores.get(name);
|
||||
if (!store) {
|
||||
store = new Map();
|
||||
this._stores.set(name, store);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
async get(storeName, key) {
|
||||
const store = this._store(storeName);
|
||||
return store.has(key) ? store.get(key) : null;
|
||||
}
|
||||
|
||||
async set(storeName, key, value) {
|
||||
this._store(storeName).set(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
async remove(storeName, key) {
|
||||
this._store(storeName).delete(key);
|
||||
}
|
||||
|
||||
async keys(storeName) {
|
||||
return [...this._store(storeName).keys()];
|
||||
}
|
||||
|
||||
async clear(storeName) {
|
||||
this._store(storeName).clear();
|
||||
}
|
||||
|
||||
async iterate(storeName, fn) {
|
||||
let result;
|
||||
let idx = 0;
|
||||
for (const [key, value] of this._store(storeName)) {
|
||||
result = fn(value, key, idx);
|
||||
idx++;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export function setupLocalForageMock(hooks) {
|
||||
hooks.beforeEach(function () {
|
||||
this.owner.register('service:localForage', MockLocalForageService);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||
import { render, click } from '@ember/test-helpers';
|
||||
import ActivityTimeline from 'marco/components/activity-timeline';
|
||||
import { setupNostrMocks } from 'marco/tests/helpers/mock-nostr';
|
||||
|
||||
function noop() {}
|
||||
|
||||
module('Integration | Component | activity-timeline', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
setupNostrMocks(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.noop = noop;
|
||||
this.emptyItems = [];
|
||||
});
|
||||
|
||||
test('it renders a loading state', async function (assert) {
|
||||
await render(
|
||||
<template>
|
||||
<ActivityTimeline
|
||||
@items={{this.emptyItems}}
|
||||
@isLoading={{true}}
|
||||
@isConnected={{true}}
|
||||
@onSelect={{this.noop}}
|
||||
@onBack={{this.noop}}
|
||||
@onClose={{this.noop}}
|
||||
@onNostrConnected={{this.noop}}
|
||||
/>
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.sidebar-loading').exists();
|
||||
assert.dom('.activity-list').doesNotExist();
|
||||
});
|
||||
|
||||
test('it renders a not-connected state', async function (assert) {
|
||||
await render(
|
||||
<template>
|
||||
<ActivityTimeline
|
||||
@items={{this.emptyItems}}
|
||||
@isLoading={{false}}
|
||||
@isConnected={{false}}
|
||||
@onSelect={{this.noop}}
|
||||
@onBack={{this.noop}}
|
||||
@onClose={{this.noop}}
|
||||
@onNostrConnected={{this.noop}}
|
||||
/>
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.empty-state').includesText('Connect your Nostr account');
|
||||
});
|
||||
|
||||
test('clicking "Connect your Nostr account" opens the Nostr connect modal', async function (assert) {
|
||||
await render(
|
||||
<template>
|
||||
<div id="modal-portal"></div>
|
||||
<ActivityTimeline
|
||||
@items={{this.emptyItems}}
|
||||
@isLoading={{false}}
|
||||
@isConnected={{false}}
|
||||
@onSelect={{this.noop}}
|
||||
@onBack={{this.noop}}
|
||||
@onClose={{this.noop}}
|
||||
@onNostrConnected={{this.noop}}
|
||||
/>
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.nostr-connect-modal').doesNotExist();
|
||||
|
||||
await click('.empty-state a');
|
||||
|
||||
assert.dom('.nostr-connect-modal').exists();
|
||||
});
|
||||
|
||||
test('it renders an empty state when connected but no activity', async function (assert) {
|
||||
await render(
|
||||
<template>
|
||||
<ActivityTimeline
|
||||
@items={{this.emptyItems}}
|
||||
@isLoading={{false}}
|
||||
@isConnected={{true}}
|
||||
@onSelect={{this.noop}}
|
||||
@onBack={{this.noop}}
|
||||
@onClose={{this.noop}}
|
||||
@onNostrConnected={{this.noop}}
|
||||
/>
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.empty-state').includesText('No activity yet');
|
||||
});
|
||||
|
||||
test('it renders activity zap items', async function (assert) {
|
||||
this.items = [
|
||||
{
|
||||
type: 'zap',
|
||||
photoEventId: 'photo-1',
|
||||
photo: {
|
||||
url: 'https://x.com/photo.jpg',
|
||||
thumbUrl: 'https://x.com/thumb.jpg',
|
||||
},
|
||||
placeIdentifier: 'osm:node:111',
|
||||
senderPubkey: 'b'.repeat(64),
|
||||
amountSats: 21,
|
||||
message: 'Nice!',
|
||||
createdAt: 2000,
|
||||
senderName: 'Alice',
|
||||
senderAvatar: 'https://x.com/avatar.jpg',
|
||||
senderProfileLoading: false,
|
||||
},
|
||||
{
|
||||
type: 'zap',
|
||||
photoEventId: 'photo-2',
|
||||
photo: {
|
||||
url: 'https://x.com/photo2.jpg',
|
||||
thumbUrl: 'https://x.com/thumb2.jpg',
|
||||
},
|
||||
placeIdentifier: 'osm:node:222',
|
||||
senderPubkey: 'c'.repeat(64),
|
||||
amountSats: 100,
|
||||
message: null,
|
||||
createdAt: 1000,
|
||||
senderName: 'Bob',
|
||||
senderAvatar: null,
|
||||
senderProfileLoading: false,
|
||||
},
|
||||
];
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<ActivityTimeline
|
||||
@items={{this.items}}
|
||||
@isLoading={{false}}
|
||||
@isConnected={{true}}
|
||||
@onSelect={{this.noop}}
|
||||
@onBack={{this.noop}}
|
||||
@onClose={{this.noop}}
|
||||
@onNostrConnected={{this.noop}}
|
||||
/>
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.activity-zap-item').exists({ count: 2 });
|
||||
assert.dom(this.element).includesText('Alice');
|
||||
assert.dom(this.element).includesText('21 ⚡');
|
||||
assert.dom(this.element).includesText('Bob');
|
||||
assert.dom(this.element).includesText('100 ⚡');
|
||||
});
|
||||
|
||||
test('clicking the back button fires @onBack', async function (assert) {
|
||||
let backClicked = false;
|
||||
this.handleBack = () => {
|
||||
backClicked = true;
|
||||
};
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<ActivityTimeline
|
||||
@items={{this.emptyItems}}
|
||||
@isLoading={{false}}
|
||||
@isConnected={{true}}
|
||||
@onSelect={{this.noop}}
|
||||
@onBack={{this.handleBack}}
|
||||
@onClose={{this.noop}}
|
||||
@onNostrConnected={{this.noop}}
|
||||
/>
|
||||
</template>
|
||||
);
|
||||
|
||||
await click('.sidebar-header .back-btn');
|
||||
assert.true(backClicked);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||
import { render, click } from '@ember/test-helpers';
|
||||
import ActivityZapItem from 'marco/components/activity-zap-item';
|
||||
import { ActivityEntry } from 'marco/utils/activity';
|
||||
|
||||
function noop() {}
|
||||
|
||||
module('Integration | Component | activity-zap-item', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.noop = noop;
|
||||
});
|
||||
|
||||
test('it renders sender name, action, amount, and message', async function (assert) {
|
||||
this.item = new ActivityEntry({
|
||||
photoEventId: 'photo-1',
|
||||
photo: {
|
||||
url: 'https://x.com/photo.jpg',
|
||||
thumbUrl: 'https://x.com/thumb.jpg',
|
||||
},
|
||||
placeIdentifier: 'osm:node:12345',
|
||||
senderPubkey: 'b'.repeat(64),
|
||||
amountSats: 21,
|
||||
message: 'Great shot!',
|
||||
createdAt: Math.floor(Date.now() / 1000) - 3600,
|
||||
});
|
||||
this.item.senderName = 'Alice';
|
||||
this.item.senderAvatar = 'https://x.com/avatar.jpg';
|
||||
this.item.senderProfileLoading = false;
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<ActivityZapItem @item={{this.item}} @onSelect={{this.noop}} />
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.zap-sender-name').hasText('Alice');
|
||||
assert.dom('.zap-action').includesText('zapped your photo');
|
||||
assert.dom('.zap-amount').hasText('21 ⚡');
|
||||
assert.dom('.zap-message').includesText('Great shot!');
|
||||
assert
|
||||
.dom('.zap-sender-avatar')
|
||||
.hasAttribute('src', 'https://x.com/avatar.jpg');
|
||||
assert
|
||||
.dom('.zap-context-thumb img')
|
||||
.hasAttribute('src', 'https://x.com/thumb.jpg');
|
||||
});
|
||||
|
||||
test('it shows avatar placeholder when no avatar', async function (assert) {
|
||||
this.item = new ActivityEntry({
|
||||
photoEventId: 'photo-1',
|
||||
photo: null,
|
||||
placeIdentifier: 'osm:node:12345',
|
||||
senderPubkey: 'b'.repeat(64),
|
||||
amountSats: 100,
|
||||
message: null,
|
||||
createdAt: 1000,
|
||||
});
|
||||
this.item.senderName = 'Bob';
|
||||
this.item.senderAvatar = null;
|
||||
this.item.senderProfileLoading = false;
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<ActivityZapItem @item={{this.item}} @onSelect={{this.noop}} />
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.zap-sender-avatar-placeholder').exists();
|
||||
assert.dom('.zap-sender-avatar').doesNotExist();
|
||||
});
|
||||
|
||||
test('it omits the message line when there is no message', async function (assert) {
|
||||
this.item = new ActivityEntry({
|
||||
photoEventId: 'photo-1',
|
||||
photo: null,
|
||||
placeIdentifier: 'osm:node:12345',
|
||||
senderPubkey: 'b'.repeat(64),
|
||||
amountSats: 50,
|
||||
message: null,
|
||||
createdAt: 1000,
|
||||
});
|
||||
this.item.senderName = 'Alice';
|
||||
this.item.senderProfileLoading = false;
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<ActivityZapItem @item={{this.item}} @onSelect={{this.noop}} />
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.zap-message').doesNotExist();
|
||||
});
|
||||
|
||||
test('it omits the context thumbnail when there is no photo', async function (assert) {
|
||||
this.item = new ActivityEntry({
|
||||
photoEventId: 'photo-1',
|
||||
photo: null,
|
||||
placeIdentifier: 'osm:node:12345',
|
||||
senderPubkey: 'b'.repeat(64),
|
||||
amountSats: 50,
|
||||
message: 'Hi',
|
||||
createdAt: 1000,
|
||||
});
|
||||
this.item.senderName = 'Alice';
|
||||
this.item.senderProfileLoading = false;
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<ActivityZapItem @item={{this.item}} @onSelect={{this.noop}} />
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.zap-context-thumb').doesNotExist();
|
||||
assert.dom('.zap-context-text').exists();
|
||||
});
|
||||
|
||||
test('clicking the item fires @onSelect with the item', async function (assert) {
|
||||
this.item = new ActivityEntry({
|
||||
photoEventId: 'photo-1',
|
||||
photo: null,
|
||||
placeIdentifier: 'osm:node:12345',
|
||||
senderPubkey: 'b'.repeat(64),
|
||||
amountSats: 21,
|
||||
message: 'Test',
|
||||
createdAt: 1000,
|
||||
});
|
||||
this.item.senderName = 'Alice';
|
||||
this.item.senderProfileLoading = false;
|
||||
|
||||
let selected = null;
|
||||
this.handleSelect = (item) => {
|
||||
selected = item;
|
||||
};
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<ActivityZapItem @item={{this.item}} @onSelect={{this.handleSelect}} />
|
||||
</template>
|
||||
);
|
||||
|
||||
await click('.activity-zap-item');
|
||||
|
||||
assert.strictEqual(selected, this.item);
|
||||
});
|
||||
|
||||
test('it shows resolved place name when placeName is set', async function (assert) {
|
||||
this.item = new ActivityEntry({
|
||||
photoEventId: 'photo-1',
|
||||
photo: null,
|
||||
placeIdentifier: 'osm:node:12345',
|
||||
senderPubkey: 'b'.repeat(64),
|
||||
amountSats: 21,
|
||||
message: null,
|
||||
createdAt: 1000,
|
||||
});
|
||||
this.item.senderName = 'Alice';
|
||||
this.item.senderProfileLoading = false;
|
||||
this.item.placeName = 'Café Central';
|
||||
this.item.placeNameLoading = false;
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<ActivityZapItem @item={{this.item}} @onSelect={{this.noop}} />
|
||||
</template>
|
||||
);
|
||||
|
||||
assert
|
||||
.dom('.zap-context-text')
|
||||
.includesText('Café Central', 'Resolved place name is displayed');
|
||||
assert
|
||||
.dom('.contribution-name-loading')
|
||||
.doesNotExist('No loading/fallback text shown');
|
||||
});
|
||||
|
||||
test('it shows "Loading…" when placeNameLoading is true', async function (assert) {
|
||||
this.item = new ActivityEntry({
|
||||
photoEventId: 'photo-1',
|
||||
photo: null,
|
||||
placeIdentifier: 'osm:node:12345',
|
||||
senderPubkey: 'b'.repeat(64),
|
||||
amountSats: 21,
|
||||
message: null,
|
||||
createdAt: 1000,
|
||||
});
|
||||
this.item.senderName = 'Alice';
|
||||
this.item.senderProfileLoading = false;
|
||||
this.item.placeName = null;
|
||||
this.item.placeNameLoading = true;
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<ActivityZapItem @item={{this.item}} @onSelect={{this.noop}} />
|
||||
</template>
|
||||
);
|
||||
|
||||
assert
|
||||
.dom('.zap-context-text .contribution-name-loading')
|
||||
.hasText('Loading…', 'Loading state is displayed');
|
||||
});
|
||||
|
||||
test('it shows "Unnamed place" when not loading and no placeName', async function (assert) {
|
||||
this.item = new ActivityEntry({
|
||||
photoEventId: 'photo-1',
|
||||
photo: null,
|
||||
placeIdentifier: 'osm:node:12345',
|
||||
senderPubkey: 'b'.repeat(64),
|
||||
amountSats: 21,
|
||||
message: null,
|
||||
createdAt: 1000,
|
||||
});
|
||||
this.item.senderName = 'Alice';
|
||||
this.item.senderProfileLoading = false;
|
||||
this.item.placeName = null;
|
||||
this.item.placeNameLoading = false;
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<ActivityZapItem @item={{this.item}} @onSelect={{this.noop}} />
|
||||
</template>
|
||||
);
|
||||
|
||||
assert
|
||||
.dom('.zap-context-text .contribution-name-loading')
|
||||
.hasText('Unnamed place', 'Fallback name is displayed');
|
||||
});
|
||||
|
||||
test('it displays place name in zap-place-name and date in zap-date', async function (assert) {
|
||||
this.item = new ActivityEntry({
|
||||
photoEventId: 'photo-1',
|
||||
photo: null,
|
||||
placeIdentifier: 'osm:node:12345',
|
||||
senderPubkey: 'b'.repeat(64),
|
||||
amountSats: 21,
|
||||
message: null,
|
||||
createdAt: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago
|
||||
});
|
||||
this.item.senderName = 'Alice';
|
||||
this.item.senderProfileLoading = false;
|
||||
this.item.placeName = 'Café Central';
|
||||
this.item.placeNameLoading = false;
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<ActivityZapItem @item={{this.item}} @onSelect={{this.noop}} />
|
||||
</template>
|
||||
);
|
||||
|
||||
const placeNameEl = this.element.querySelector('.zap-place-name');
|
||||
const dateEl = this.element.querySelector('.zap-date');
|
||||
|
||||
assert.ok(placeNameEl, 'zap-place-name element exists');
|
||||
assert.ok(dateEl, 'zap-date element exists');
|
||||
assert.ok(
|
||||
placeNameEl.textContent.includes('Café Central'),
|
||||
'Place name is in zap-place-name'
|
||||
);
|
||||
assert.ok(dateEl.textContent.includes('hr ago'), 'Date contains hr ago');
|
||||
assert.notOk(
|
||||
placeNameEl.textContent.includes('hr ago'),
|
||||
'Place name does not contain date'
|
||||
);
|
||||
assert.notOk(
|
||||
dateEl.textContent.includes('Café Central'),
|
||||
'Date does not contain place name'
|
||||
);
|
||||
});
|
||||
|
||||
test('it truncates long place names with ellipsis while keeping date visible', async function (assert) {
|
||||
this.item = new ActivityEntry({
|
||||
photoEventId: 'photo-1',
|
||||
photo: null,
|
||||
placeIdentifier: 'osm:node:12345',
|
||||
senderPubkey: 'b'.repeat(64),
|
||||
amountSats: 21,
|
||||
message: null,
|
||||
createdAt: Math.floor(Date.now() / 1000) - 3600,
|
||||
});
|
||||
this.item.senderName = 'Alice';
|
||||
this.item.senderProfileLoading = false;
|
||||
this.item.placeName =
|
||||
'A Very Long Place Name That Should Be Truncated With Ellipsis';
|
||||
this.item.placeNameLoading = false;
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<ActivityZapItem @item={{this.item}} @onSelect={{this.noop}} />
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.zap-place-name').exists('zap-place-name element exists');
|
||||
assert.dom('.zap-date').hasText('1 hr ago', 'Date is still visible');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||
import { click, fillIn, render } from '@ember/test-helpers';
|
||||
import { click, fillIn, render, settled } from '@ember/test-helpers';
|
||||
import Service, { service } from '@ember/service';
|
||||
import AppMenuSettingsNostr from 'marco/components/app-menu/settings/nostr';
|
||||
import {
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
mergeRequiredRelays,
|
||||
uniqNormalizedRelays,
|
||||
} from 'marco/utils/nostr';
|
||||
import { DEFAULT_BLOSSOM_SERVER } from 'marco/services/blossom';
|
||||
|
||||
class MockNostrDataService extends Service {
|
||||
@service settings;
|
||||
@@ -18,6 +19,8 @@ class MockNostrDataService extends Service {
|
||||
mailboxReadRelays = ['wss://mailbox.example.com'];
|
||||
mailboxWriteRelays = ['wss://mailbox-write.example.com'];
|
||||
|
||||
blossomServers = [];
|
||||
|
||||
get configuredReadRelays() {
|
||||
const configured = uniqNormalizedRelays([
|
||||
...this.mailboxReadRelays,
|
||||
@@ -56,6 +59,11 @@ class MockNostrDataService extends Service {
|
||||
);
|
||||
}
|
||||
|
||||
get trustedRelays() {
|
||||
const configured = this.settings.nostrTrustedRelays || [];
|
||||
return uniqNormalizedRelays([...this.requiredReadRelays, ...configured]);
|
||||
}
|
||||
|
||||
async clearCache() {}
|
||||
}
|
||||
|
||||
@@ -105,8 +113,17 @@ module('Integration | Component | app-menu/settings/nostr', function (hooks) {
|
||||
const requiredRow = rowByText(rows, 'nostr.kosmos.org');
|
||||
const mailboxRow = rowByText(rows, 'mailbox.example.com');
|
||||
|
||||
assert.dom(requiredRow.querySelector('.btn-remove-relay')).doesNotExist();
|
||||
assert.dom(mailboxRow.querySelector('.btn-remove-relay')).exists();
|
||||
const requiredRemoveBtn = requiredRow.querySelector('.btn-remove-relay');
|
||||
assert.dom(requiredRemoveBtn).exists();
|
||||
assert.true(requiredRemoveBtn.disabled, 'required relay cannot be removed');
|
||||
assert.strictEqual(
|
||||
requiredRemoveBtn.getAttribute('aria-description'),
|
||||
'Required relays cannot be removed'
|
||||
);
|
||||
|
||||
const mailboxRemoveBtn = mailboxRow.querySelector('.btn-remove-relay');
|
||||
assert.dom(mailboxRemoveBtn).exists();
|
||||
assert.false(mailboxRemoveBtn.disabled);
|
||||
});
|
||||
|
||||
test('removing mailbox read relay stores exclusion override', async function (assert) {
|
||||
@@ -185,4 +202,105 @@ module('Integration | Component | app-menu/settings/nostr', function (hooks) {
|
||||
'wss://mailbox-write.example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
test('media server dropdown shows only the default server when no blossom servers discovered', async function (assert) {
|
||||
const element = await renderAndOpenDetails(this);
|
||||
const select = element.querySelector('#nostr-media-server');
|
||||
|
||||
assert.dom(select).exists();
|
||||
assert.strictEqual(select.options.length, 1);
|
||||
assert.dom(select.options[0]).hasValue(DEFAULT_BLOSSOM_SERVER);
|
||||
assert.dom(select.options[0]).hasText(DEFAULT_BLOSSOM_SERVER);
|
||||
assert.strictEqual(select.value, DEFAULT_BLOSSOM_SERVER);
|
||||
assert
|
||||
.dom('#nostr-photo-fallback-uploads')
|
||||
.doesNotExist('fallback toggle hidden with single media server');
|
||||
});
|
||||
|
||||
test('media server dropdown lists discovered blossom servers', async function (assert) {
|
||||
const servers = ['https://cdn.example.com', 'https://cdn.satellite.earth'];
|
||||
this.owner.lookup('service:nostrData').blossomServers = servers;
|
||||
|
||||
const element = await renderAndOpenDetails(this);
|
||||
const select = element.querySelector('#nostr-media-server');
|
||||
|
||||
assert.strictEqual(select.options.length, servers.length);
|
||||
assert.dom(select.options[0]).hasValue(servers[0]);
|
||||
assert.dom(select.options[1]).hasValue(servers[1]);
|
||||
assert
|
||||
.dom('#nostr-photo-fallback-uploads')
|
||||
.exists('fallback toggle visible with multiple media servers');
|
||||
});
|
||||
|
||||
test('required read relay is trusted and its trust toggle is disabled', async function (assert) {
|
||||
const element = await renderAndOpenDetails(this);
|
||||
const requiredRow = rowByText(readRows(element), 'nostr.kosmos.org');
|
||||
|
||||
const trustBtn = requiredRow.querySelector('.btn-trust-relay');
|
||||
assert.dom(trustBtn).exists('trust toggle renders for required relay');
|
||||
assert.true(trustBtn.classList.contains('is-trusted'));
|
||||
assert.true(trustBtn.disabled, 'required relay toggle is disabled');
|
||||
});
|
||||
|
||||
test('toggling trust on a custom read relay marks it trusted', async function (assert) {
|
||||
this.settings.update('nostrReadRelays', ['wss://custom.example.com']);
|
||||
|
||||
const element = await renderAndOpenDetails(this);
|
||||
const customRow = rowByText(readRows(element), 'custom.example.com');
|
||||
|
||||
const trustBtn = customRow.querySelector('.btn-trust-relay');
|
||||
assert.false(trustBtn.classList.contains('is-trusted'), 'starts untrusted');
|
||||
assert.strictEqual(this.settings.nostrTrustedRelays, null);
|
||||
|
||||
await click(trustBtn);
|
||||
|
||||
assert.deepEqual(this.settings.nostrTrustedRelays, [
|
||||
'wss://custom.example.com',
|
||||
]);
|
||||
const updatedBtn = rowByText(
|
||||
readRows(element),
|
||||
'custom.example.com'
|
||||
).querySelector('.btn-trust-relay');
|
||||
assert.true(updatedBtn.classList.contains('is-trusted'), 'now trusted');
|
||||
});
|
||||
|
||||
test('toggling trust off removes relay from trusted list', async function (assert) {
|
||||
this.settings.update('nostrReadRelays', ['wss://custom.example.com']);
|
||||
this.settings.update('nostrTrustedRelays', ['wss://custom.example.com']);
|
||||
|
||||
const element = await renderAndOpenDetails(this);
|
||||
const customRow = rowByText(readRows(element), 'custom.example.com');
|
||||
const trustBtn = customRow.querySelector('.btn-trust-relay');
|
||||
|
||||
assert.true(trustBtn.classList.contains('is-trusted'));
|
||||
|
||||
await click(trustBtn);
|
||||
|
||||
assert.strictEqual(
|
||||
this.settings.nostrTrustedRelays,
|
||||
null,
|
||||
'empty list collapses to null'
|
||||
);
|
||||
});
|
||||
|
||||
test('tooltip appears on hover and disappears on mouseleave', async function (assert) {
|
||||
const element = await renderAndOpenDetails(this);
|
||||
const mailboxRow = rowByText(readRows(element), 'mailbox.example.com');
|
||||
const removeBtn = mailboxRow.querySelector('.btn-remove-relay');
|
||||
|
||||
assert.dom('.tooltip', document.body).doesNotExist('no tooltip initially');
|
||||
|
||||
removeBtn.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
await settled();
|
||||
|
||||
assert.dom('.tooltip', document.body).exists('tooltip appears on hover');
|
||||
assert.dom('.tooltip', document.body).hasText('Remove relay');
|
||||
|
||||
removeBtn.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
|
||||
await settled();
|
||||
|
||||
assert
|
||||
.dom('.tooltip', document.body)
|
||||
.doesNotExist('tooltip removed on mouseleave');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||
import { render, click, settled } from '@ember/test-helpers';
|
||||
import ContributionPhoto from 'marco/components/contribution-photo';
|
||||
import { ContributionEntry } from 'marco/utils/contributions';
|
||||
|
||||
function noop() {}
|
||||
|
||||
module('Integration | Component | contribution-photo', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.noop = noop;
|
||||
});
|
||||
|
||||
test('it renders the place name and thumbnail', async function (assert) {
|
||||
this.item = {
|
||||
type: 'photo',
|
||||
placeIdentifier: 'osm:node:12345',
|
||||
osmType: 'node',
|
||||
osmId: '12345',
|
||||
placeName: 'Café Example',
|
||||
placeNameLoading: false,
|
||||
createdAt: Math.floor(Date.now() / 1000) - 60 * 60,
|
||||
photos: [
|
||||
{
|
||||
url: 'https://x.com/photo.jpg',
|
||||
thumbUrl: 'https://x.com/thumb.jpg',
|
||||
alt: 'A nice café',
|
||||
tags: ['food'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<ContributionPhoto @item={{this.item}} @onSelect={{this.noop}} />
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.contribution-place').hasText('Café Example');
|
||||
assert
|
||||
.dom('.contribution-thumb img')
|
||||
.hasAttribute('src', 'https://x.com/thumb.jpg');
|
||||
assert.dom('.contribution-tag').hasText('food');
|
||||
assert.dom('.contribution-thumb-badge').doesNotExist();
|
||||
});
|
||||
|
||||
test('it shows a +N badge when there are multiple photos', async function (assert) {
|
||||
this.item = {
|
||||
type: 'photo',
|
||||
placeIdentifier: 'osm:node:12345',
|
||||
osmType: 'node',
|
||||
osmId: '12345',
|
||||
placeName: 'Park',
|
||||
placeNameLoading: false,
|
||||
createdAt: 1000,
|
||||
photos: [
|
||||
{
|
||||
url: 'https://x.com/1.jpg',
|
||||
thumbUrl: 'https://x.com/t1.jpg',
|
||||
tags: [],
|
||||
},
|
||||
{
|
||||
url: 'https://x.com/2.jpg',
|
||||
thumbUrl: 'https://x.com/t2.jpg',
|
||||
tags: [],
|
||||
},
|
||||
{
|
||||
url: 'https://x.com/3.jpg',
|
||||
thumbUrl: 'https://x.com/t3.jpg',
|
||||
tags: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<ContributionPhoto @item={{this.item}} @onSelect={{this.noop}} />
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.contribution-thumb-badge').hasText('+2');
|
||||
});
|
||||
|
||||
test('it shows a loading label when placeNameLoading is true', async function (assert) {
|
||||
this.item = {
|
||||
type: 'photo',
|
||||
placeIdentifier: 'osm:node:12345',
|
||||
osmType: 'node',
|
||||
osmId: '12345',
|
||||
placeName: null,
|
||||
placeNameLoading: true,
|
||||
createdAt: 1000,
|
||||
photos: [
|
||||
{
|
||||
url: 'https://x.com/1.jpg',
|
||||
thumbUrl: 'https://x.com/t1.jpg',
|
||||
tags: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<ContributionPhoto @item={{this.item}} @onSelect={{this.noop}} />
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.contribution-name-loading').hasText('Loading…');
|
||||
});
|
||||
|
||||
test('clicking the item fires @onSelect with the item', async function (assert) {
|
||||
this.item = {
|
||||
type: 'photo',
|
||||
placeIdentifier: 'osm:node:12345',
|
||||
osmType: 'node',
|
||||
osmId: '12345',
|
||||
placeName: 'Test',
|
||||
placeNameLoading: false,
|
||||
createdAt: 1000,
|
||||
photos: [
|
||||
{
|
||||
url: 'https://x.com/1.jpg',
|
||||
thumbUrl: 'https://x.com/t1.jpg',
|
||||
tags: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let selected = null;
|
||||
this.handleSelect = (item) => {
|
||||
selected = item;
|
||||
};
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<ContributionPhoto
|
||||
@item={{this.item}}
|
||||
@onSelect={{this.handleSelect}}
|
||||
/>
|
||||
</template>
|
||||
);
|
||||
|
||||
await click('.contribution-item');
|
||||
|
||||
assert.strictEqual(selected, this.item);
|
||||
});
|
||||
|
||||
test('it updates the place name when a tracked entry resolves after render', async function (assert) {
|
||||
// Regression: when a place name is resolved by the background batch fetch
|
||||
// after the entry has already been rendered as "Loading…", the component
|
||||
// must re-render with the resolved name. This requires the entry's
|
||||
// placeName/placeNameLoading to be tracked.
|
||||
this.item = new ContributionEntry({
|
||||
placeIdentifier: 'osm:node:12345',
|
||||
osmType: 'node',
|
||||
osmId: '12345',
|
||||
createdAt: 1000,
|
||||
photos: [
|
||||
{
|
||||
url: 'https://x.com/1.jpg',
|
||||
thumbUrl: 'https://x.com/t1.jpg',
|
||||
tags: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<ContributionPhoto @item={{this.item}} @onSelect={{this.noop}} />
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.contribution-name-loading').hasText('Loading…');
|
||||
|
||||
// Simulate the contributions service resolving the name after the batch fetch
|
||||
this.item.placeName = 'Resolved Café';
|
||||
this.item.placeNameLoading = false;
|
||||
await settled();
|
||||
|
||||
assert.dom('.contribution-place').hasText('Resolved Café');
|
||||
assert.dom('.contribution-name-loading').doesNotExist();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||
import { render, click } from '@ember/test-helpers';
|
||||
import Service from '@ember/service';
|
||||
import ContributionsTimeline from 'marco/components/contributions-timeline';
|
||||
import { setupNostrMocks } from 'marco/tests/helpers/mock-nostr';
|
||||
|
||||
function noop() {}
|
||||
|
||||
class MockToastService extends Service {
|
||||
show() {}
|
||||
}
|
||||
|
||||
module('Integration | Component | contributions-timeline', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
setupNostrMocks(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.owner.register('service:toast', MockToastService);
|
||||
|
||||
this.noop = noop;
|
||||
this.emptyItems = [];
|
||||
});
|
||||
|
||||
test('it renders a loading state', async function (assert) {
|
||||
await render(
|
||||
<template>
|
||||
<ContributionsTimeline
|
||||
@items={{this.emptyItems}}
|
||||
@isLoading={{true}}
|
||||
@isConnected={{true}}
|
||||
@onBack={{this.noop}}
|
||||
@onClose={{this.noop}}
|
||||
@onSelect={{this.noop}}
|
||||
/>
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.sidebar-loading').exists();
|
||||
assert.dom('.contributions-list').doesNotExist();
|
||||
});
|
||||
|
||||
test('it renders a not-connected state', async function (assert) {
|
||||
await render(
|
||||
<template>
|
||||
<ContributionsTimeline
|
||||
@items={{this.emptyItems}}
|
||||
@isLoading={{false}}
|
||||
@isConnected={{false}}
|
||||
@onBack={{this.noop}}
|
||||
@onClose={{this.noop}}
|
||||
@onSelect={{this.noop}}
|
||||
/>
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.empty-state').includesText('Connect your Nostr account');
|
||||
});
|
||||
|
||||
test('clicking "Connect your Nostr account" opens the Nostr connect modal', async function (assert) {
|
||||
await render(
|
||||
<template>
|
||||
<div id="modal-portal"></div>
|
||||
<ContributionsTimeline
|
||||
@items={{this.emptyItems}}
|
||||
@isLoading={{false}}
|
||||
@isConnected={{false}}
|
||||
@onBack={{this.noop}}
|
||||
@onClose={{this.noop}}
|
||||
@onSelect={{this.noop}}
|
||||
/>
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.nostr-connect-modal').doesNotExist();
|
||||
|
||||
await click('.empty-state a');
|
||||
|
||||
assert.dom('.nostr-connect-modal').exists();
|
||||
});
|
||||
|
||||
test('it renders an empty state when connected but no contributions', async function (assert) {
|
||||
await render(
|
||||
<template>
|
||||
<ContributionsTimeline
|
||||
@items={{this.emptyItems}}
|
||||
@isLoading={{false}}
|
||||
@isConnected={{true}}
|
||||
@onBack={{this.noop}}
|
||||
@onClose={{this.noop}}
|
||||
@onSelect={{this.noop}}
|
||||
/>
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.empty-state').includesText('No contributions yet');
|
||||
});
|
||||
|
||||
test('it renders contribution items', async function (assert) {
|
||||
this.items = [
|
||||
{
|
||||
type: 'photo',
|
||||
placeIdentifier: 'osm:node:111',
|
||||
osmType: 'node',
|
||||
osmId: '111',
|
||||
placeName: 'Café One',
|
||||
placeNameLoading: false,
|
||||
createdAt: 2000,
|
||||
photos: [
|
||||
{
|
||||
url: 'https://x.com/1.jpg',
|
||||
thumbUrl: 'https://x.com/t1.jpg',
|
||||
tags: ['food'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'photo',
|
||||
placeIdentifier: 'osm:node:222',
|
||||
osmType: 'node',
|
||||
osmId: '222',
|
||||
placeName: 'Park Two',
|
||||
placeNameLoading: false,
|
||||
createdAt: 1000,
|
||||
photos: [
|
||||
{
|
||||
url: 'https://x.com/2.jpg',
|
||||
thumbUrl: 'https://x.com/t2.jpg',
|
||||
tags: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<ContributionsTimeline
|
||||
@items={{this.items}}
|
||||
@isLoading={{false}}
|
||||
@isConnected={{true}}
|
||||
@onBack={{this.noop}}
|
||||
@onClose={{this.noop}}
|
||||
@onSelect={{this.noop}}
|
||||
/>
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.contribution-item').exists({ count: 2 });
|
||||
assert.dom(this.element).includesText('Café One');
|
||||
assert.dom(this.element).includesText('Park Two');
|
||||
});
|
||||
|
||||
test('clicking the back button fires @onBack', async function (assert) {
|
||||
let backClicked = false;
|
||||
this.handleBack = () => {
|
||||
backClicked = true;
|
||||
};
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<ContributionsTimeline
|
||||
@items={{this.emptyItems}}
|
||||
@isLoading={{false}}
|
||||
@isConnected={{true}}
|
||||
@onBack={{this.handleBack}}
|
||||
@onClose={{this.noop}}
|
||||
@onSelect={{this.noop}}
|
||||
/>
|
||||
</template>
|
||||
);
|
||||
|
||||
await click('.sidebar-header .back-btn');
|
||||
assert.true(backClicked);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,9 @@ import PhotoGallery from 'marco/components/photo-gallery';
|
||||
import { setupNostrMocks } from 'marco/tests/helpers/mock-nostr';
|
||||
import sinon from 'sinon';
|
||||
|
||||
const USER_A = 'a'.repeat(64);
|
||||
const USER_B = 'b'.repeat(64);
|
||||
|
||||
class MockBlossomService extends Service {
|
||||
async delete() {
|
||||
return true;
|
||||
@@ -34,7 +37,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
this.photos = [
|
||||
{
|
||||
eventId: 'event1',
|
||||
pubkey: 'userA',
|
||||
pubkey: USER_A,
|
||||
placeIdentifier: 'osm:node:12345',
|
||||
url: 'https://example.com/a3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1.jpg',
|
||||
thumbUrl:
|
||||
@@ -42,7 +45,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
},
|
||||
{
|
||||
eventId: 'event2',
|
||||
pubkey: 'userB',
|
||||
pubkey: USER_B,
|
||||
placeIdentifier: 'osm:node:12345',
|
||||
url: 'photo2.jpg',
|
||||
},
|
||||
@@ -55,7 +58,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
});
|
||||
|
||||
test('it does not show delete button if user is not creator', async function (assert) {
|
||||
this.nostrAuth.pubkey = 'userB'; // Different from photo1's pubkey
|
||||
this.nostrAuth.pubkey = USER_B; // Different from photo1's pubkey
|
||||
this.selectedPhoto = this.photos[0];
|
||||
|
||||
await render(
|
||||
@@ -80,7 +83,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
});
|
||||
|
||||
test('it shows delete button if user is creator and setting is enabled', async function (assert) {
|
||||
this.nostrAuth.pubkey = 'userA'; // Matches photo1's pubkey
|
||||
this.nostrAuth.pubkey = USER_A; // Matches photo1's pubkey
|
||||
this.settings.update('experimentalEnablePhotoDeletion', true); // Enable the setting
|
||||
this.selectedPhoto = this.photos[0];
|
||||
|
||||
@@ -106,7 +109,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
});
|
||||
|
||||
test('it handles cancellation of deletion', async function (assert) {
|
||||
this.nostrAuth.pubkey = 'userA';
|
||||
this.nostrAuth.pubkey = USER_A;
|
||||
this.settings.update('experimentalEnablePhotoDeletion', true);
|
||||
this.selectedPhoto = this.photos[0];
|
||||
|
||||
@@ -133,7 +136,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
});
|
||||
|
||||
test('it performs full deletion flow when confirmed', async function (assert) {
|
||||
this.nostrAuth.pubkey = 'userA';
|
||||
this.nostrAuth.pubkey = USER_A;
|
||||
this.settings.update('experimentalEnablePhotoDeletion', true);
|
||||
// Override the mock's getter just for this test
|
||||
Object.defineProperty(this.nostrAuth, 'signer', {
|
||||
@@ -141,11 +144,11 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
get: () => ({
|
||||
signEvent: async (e) => ({
|
||||
...e,
|
||||
id: 'signed-id',
|
||||
sig: 'sig',
|
||||
pubkey: 'userA',
|
||||
id: 'a3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1',
|
||||
sig: 'b3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1',
|
||||
pubkey: USER_A,
|
||||
}),
|
||||
getPublicKey: async () => 'userA',
|
||||
getPublicKey: async () => USER_A,
|
||||
}),
|
||||
});
|
||||
this.selectedPhoto = this.photos[0];
|
||||
@@ -227,7 +230,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
});
|
||||
|
||||
test('it copies event id to clipboard', async function (assert) {
|
||||
this.nostrAuth.pubkey = 'userA';
|
||||
this.nostrAuth.pubkey = USER_A;
|
||||
this.selectedPhoto = this.photos[0];
|
||||
|
||||
const clipboardStub = sinon
|
||||
@@ -336,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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -320,14 +320,14 @@ module('Integration | Component | place-details', function (hooks) {
|
||||
const links = whatsappBlock.querySelectorAll('a[href^="https://wa.me/"]');
|
||||
assert.strictEqual(links.length, 2, 'Rendered exactly 2 WhatsApp links');
|
||||
|
||||
// Verify it stripped the dashes and spaces for the wa.me URL
|
||||
// Verify it stripped the dashes, spaces and leading plus for the wa.me URL
|
||||
assert.strictEqual(
|
||||
links[0].getAttribute('href'),
|
||||
'https://wa.me/+44987654321'
|
||||
'https://wa.me/44987654321'
|
||||
);
|
||||
assert.strictEqual(
|
||||
links[1].getAttribute('href'),
|
||||
'https://wa.me/+12345678900'
|
||||
'https://wa.me/12345678900'
|
||||
);
|
||||
|
||||
// Verify it kept the dashes and spaces for the visible text
|
||||
@@ -335,6 +335,33 @@ module('Integration | Component | place-details', function (hooks) {
|
||||
assert.dom(links[1]).hasText('+1 234-567 8900');
|
||||
});
|
||||
|
||||
test('it strips parentheses, dots and the leading plus from whatsapp hrefs', async function (assert) {
|
||||
const place = {
|
||||
title: 'Chat Shop',
|
||||
osmTags: {
|
||||
whatsapp: '+504-9850-3802;(504) 9850.3802;+1.234.567.8900',
|
||||
},
|
||||
};
|
||||
|
||||
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||
|
||||
const links = this.element.querySelectorAll('a[href^="https://wa.me/"]');
|
||||
assert.strictEqual(links.length, 3, 'Rendered exactly 3 WhatsApp links');
|
||||
|
||||
assert.strictEqual(
|
||||
links[0].getAttribute('href'),
|
||||
'https://wa.me/50498503802'
|
||||
);
|
||||
assert.strictEqual(
|
||||
links[1].getAttribute('href'),
|
||||
'https://wa.me/50498503802'
|
||||
);
|
||||
assert.strictEqual(
|
||||
links[2].getAttribute('href'),
|
||||
'https://wa.me/12345678900'
|
||||
);
|
||||
});
|
||||
|
||||
test('it renders correct OpenStreetMap link for an OSM place', async function (assert) {
|
||||
const place = {
|
||||
title: 'OSM Place',
|
||||
|
||||
@@ -49,7 +49,7 @@ module('Integration | Component | place-photo-upload', function (hooks) {
|
||||
|
||||
assert.dom('.photo-tag-suggestions').exists();
|
||||
assert.dom('.photo-tag-chip').exists();
|
||||
assert.dom('.photo-tag-chip').includesText('Food');
|
||||
assert.dom('.photo-tag-chip').includesText('Front');
|
||||
});
|
||||
|
||||
test('it only allows one selected tag at a time', async function (assert) {
|
||||
@@ -97,4 +97,27 @@ module('Integration | Component | place-photo-upload', function (hooks) {
|
||||
|
||||
assert.dom('.photo-tag-suggestions').doesNotExist();
|
||||
});
|
||||
|
||||
test('it renders an optional alt text input after upload selection', async function (assert) {
|
||||
this.place = {
|
||||
title: 'Cafe Alpha',
|
||||
osmId: '123',
|
||||
osmType: 'node',
|
||||
osmTags: { amenity: 'cafe' },
|
||||
};
|
||||
|
||||
await render(
|
||||
<template><PlacePhotoUpload @place={{this.place}} /></template>
|
||||
);
|
||||
|
||||
assert.dom('#photo-alt-input').doesNotExist();
|
||||
|
||||
const file = new File(['test'], 'photo.jpg', { type: 'image/jpeg' });
|
||||
await selectFile(this.element, file);
|
||||
|
||||
assert.dom('#photo-alt-input').exists();
|
||||
assert
|
||||
.dom('#photo-alt-input')
|
||||
.hasAttribute('placeholder', 'Describe this photo');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user