From 5ad702e6e68618cbbf1fe91fa71fdb3b1d6fc918 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Fri, 17 Apr 2026 14:19:13 +0400
Subject: [PATCH 01/56] Add planning docs for Nostr place reviews
---
doc/nostr/nip-place-reviews.md | 343 +++++++++++++++++++++++++++++++++
doc/nostr/notes.md | 6 +
doc/nostr/ranking.md | 251 ++++++++++++++++++++++++
doc/nostr/ratings.md | 101 ++++++++++
doc/nostr/review-schema.json | 92 +++++++++
5 files changed, 793 insertions(+)
create mode 100644 doc/nostr/nip-place-reviews.md
create mode 100644 doc/nostr/notes.md
create mode 100644 doc/nostr/ranking.md
create mode 100644 doc/nostr/ratings.md
create mode 100644 doc/nostr/review-schema.json
diff --git a/doc/nostr/nip-place-reviews.md b/doc/nostr/nip-place-reviews.md
new file mode 100644
index 0000000..a0221d2
--- /dev/null
+++ b/doc/nostr/nip-place-reviews.md
@@ -0,0 +1,343 @@
+# NIP-XX: Place Reviews
+
+## Abstract
+
+This NIP defines a standardized event format for decentralized place reviews using Nostr. Reviews are tied to real-world locations (e.g. OpenStreetMap POIs) via tags, and include structured, multi-aspect ratings, a binary recommendation signal, and optional contextual metadata.
+
+The design prioritizes:
+
+* Small event size
+* Interoperability across clients
+* Flexibility for different place types
+* Efficient geospatial querying using geohashes
+
+---
+
+## Event Kind
+
+`kind: 30315` (suggested; subject to coordination)
+
+---
+
+## Tags
+
+Additional tags MAY be included by clients but are not defined by this specification.
+
+This NIP reuses and builds upon existing Nostr tag conventions:
+
+* `i` tag: see NIP-73 (External Content Identifiers)
+* `g` tag: geohash-based geotagging (community conventions)
+
+Where conflicts arise, this NIP specifies the behavior for review events.
+
+### Required
+
+#### `i` — Entity Identifier
+
+Identifies the reviewed place using an external identifier. OpenStreetMap data is the default:
+
+```
+["i", "osm::"]
+```
+
+Requirements:
+
+* For OSM POIs, `` MUST be one of: `node`, `way`, `relation`
+
+Examples:
+
+```
+["i", "osm:node:123456"]
+["i", "osm:way:987654"]
+```
+
+---
+
+### Geospatial Tags
+
+#### `g` — Geohash
+
+Geohash tags are used for spatial indexing and discovery.
+
+##### Requirements
+
+* Clients MUST include at least one high-precision geohash (length ≥ 9)
+
+##### Recommendations
+
+Clients SHOULD include geohashes at the following resolutions:
+
+* length 4 — coarse (city-scale discovery)
+* length 6 — medium (default query level, ~1 km)
+* length 7 — fine (neighborhood, ~150 m)
+
+Example:
+
+```
+["g", "thrr"]
+["g", "thrrn5"]
+["g", "thrrn5k"]
+["g", "thrrn5kxyz"]
+```
+
+##### Querying
+
+Geospatial queries are performed using the `g` tag.
+
+* Clients SHOULD query using a single geohash precision level per request
+* Clients MAY include multiple geohash values in a filter to cover a bounding box
+* Clients SHOULD limit the number of geohash values per query (e.g. ≤ 30)
+* Clients MAY reduce precision or split queries when necessary
+
+Note: Other queries (e.g. fetching reviews for a specific place) are performed using the `i` tag and are outside the scope of geospatial querying.
+
+---
+
+## Content (JSON)
+
+The event `content` MUST be valid JSON matching the following schema.
+
+### Schema
+
+```json
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "required": ["version", "ratings"],
+ "additionalProperties": false,
+ "properties": {
+ "version": {
+ "type": "integer",
+ "const": 1
+ },
+ "ratings": {
+ "type": "object",
+ "required": ["quality"],
+ "additionalProperties": false,
+ "properties": {
+ "quality": { "$ref": "#/$defs/score" },
+ "value": { "$ref": "#/$defs/score" },
+ "experience": { "$ref": "#/$defs/score" },
+ "accessibility": { "$ref": "#/$defs/score" },
+ "aspects": {
+ "type": "object",
+ "minProperties": 1,
+ "maxProperties": 20,
+ "additionalProperties": { "$ref": "#/$defs/score" },
+ "propertyNames": {
+ "pattern": "^[a-z][a-z0-9_]{1,31}$"
+ }
+ }
+ }
+ },
+ "recommend": {
+ "type": "boolean"
+ },
+ "familiarity": {
+ "type": "string",
+ "enum": ["low", "medium", "high"],
+ "description": "User familiarity: low = first visit; medium = occasional; high = frequent"
+ },
+ "context": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "visited_at": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "duration_minutes": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 1440
+ },
+ "party_size": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100
+ }
+ }
+ },
+ "review": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "text": {
+ "type": "string",
+ "maxLength": 1000
+ },
+ "language": {
+ "type": "string",
+ "pattern": "^[a-z]{2}(-[A-Z]{2})?$"
+ }
+ }
+ }
+ },
+ "$defs": {
+ "score": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 10
+ }
+ }
+}
+```
+
+---
+
+## Example
+
+### Restaurant Review Event
+
+#### Tags
+
+```
+[
+ ["i", "osm:node:123456"],
+ ["g", "thrr"],
+ ["g", "thrrn5"],
+ ["g", "thrrn5k"],
+ ["g", "thrrn5kxyz"]
+]
+```
+
+#### Content
+
+```json
+{
+ "version": 1,
+ "ratings": {
+ "quality": 9,
+ "value": 8,
+ "experience": 9,
+ "accessibility": 7,
+ "aspects": {
+ "food": 9,
+ "service": 6,
+ "ambience": 8,
+ "wait_time": 5
+ }
+ },
+ "recommend": true,
+ "familiarity": "medium",
+ "context": {
+ "visited_at": 1713200000,
+ "duration_minutes": 90,
+ "party_size": 2
+ },
+ "review": {
+ "text": "Excellent food with bold flavors. Service was a bit slow, but the atmosphere made up for it.",
+ "language": "en"
+ }
+}
+```
+
+---
+
+## Semantics
+
+### Ratings
+
+* Scores are integers from 1 to 10
+* `quality` is required and represents the core evaluation of the place
+* Other fields are optional and context-dependent
+
+### Aspects
+
+* Free-form keys allow domain-specific ratings
+* Clients MAY define and interpret aspect keys
+* Clients SHOULD reuse commonly established aspect keys where possible
+
+---
+
+## Recommendation Signal
+
+The `recommend` field represents a binary verdict:
+
+* `true` → user recommends the place
+* `false` → user does not recommend the place
+
+Clients SHOULD strongly encourage users to provide this value.
+
+---
+
+## Familiarity
+
+Represents user familiarity with the place:
+
+* `low` → first visit or limited exposure
+* `medium` → occasional visits
+* `high` → frequent or expert-level familiarity
+
+Clients MAY use this signal for weighting during aggregation.
+
+---
+
+## Context
+
+Optional metadata about the visit.
+
+* `visited_at` is a Unix timestamp
+* `duration_minutes` represents time spent
+* `party_size` indicates group size
+
+---
+
+## Interoperability
+
+This specification defines a content payload only.
+
+* In Nostr: place identity is conveyed via tags
+* In other protocols (e.g. ActivityPub, AT Protocol): identity MUST be mapped to the equivalent field (e.g. `object`)
+
+Content payloads SHOULD NOT include place identifiers.
+
+---
+
+## Rationale
+
+### No Place Field in Content
+
+Avoids duplication and inconsistency with tags.
+
+### Multi-Aspect Ratings
+
+Separates concerns (e.g. quality vs service), improving signal quality.
+
+### Recommendation vs Score
+
+Binary recommendation avoids averaging pitfalls and improves ranking.
+
+### Familiarity
+
+Provides a human-friendly proxy for confidence without requiring numeric input.
+
+### Geohash Strategy
+
+Multiple resolutions balance:
+
+* efficient querying
+* small event size
+* early-stage discoverability
+
+---
+
+## Future Work
+
+* Standardized aspect vocabularies
+* Reputation and weighting models
+* Indexing/aggregation services
+* Cross-protocol mappings
+
+---
+
+## Security Considerations
+
+* Clients SHOULD validate all input
+* Malicious or spam reviews may require external moderation or reputation systems
+
+---
+
+## Copyright
+
+This NIP is public domain.
diff --git a/doc/nostr/notes.md b/doc/nostr/notes.md
new file mode 100644
index 0000000..8aec90a
--- /dev/null
+++ b/doc/nostr/notes.md
@@ -0,0 +1,6 @@
+# Notes
+
+- NIP-73 for external IDs ("osm:node:123456"): https://github.com/nostr-protocol/nips/blob/744bce8fcae0aca07b936b6662db635c8b4253dd/73.md
+- Places NIP-XX draft PR: https://github.com/nostr-protocol/nips/pull/927
+- NPM package for generating multi-resolution geotags: https://sandwichfarm.github.io/nostr-geotags/#md:nostr-geotags
+- AppleSauce docs for AI agents: https://applesauce.build/introduction/mcp-server.html
diff --git a/doc/nostr/ranking.md b/doc/nostr/ranking.md
new file mode 100644
index 0000000..7f5489e
--- /dev/null
+++ b/doc/nostr/ranking.md
@@ -0,0 +1,251 @@
+# Ranking Algorithm
+
+Your inputs:
+
+* many users
+* partial ratings
+* different priorities
+
+Your output:
+
+> “Best place *for this user right now*”
+
+---
+
+## Step 1: Normalize scores
+
+Convert 1–10 → 0–1:
+
+```text
+normalized_score = (score - 1) / 9
+```
+
+Why:
+
+* easier math
+* comparable across aspects
+
+---
+
+## Step 2: Per-aspect aggregation (avoid averages trap)
+
+Instead of mean, compute:
+
+### A. Positive ratio
+
+```text
+positive = score >= 7
+negative = score <= 4
+```
+
+Then:
+
+```text
+positive_ratio = positive_votes / total_votes
+```
+
+---
+
+### B. Confidence-weighted score
+
+Use something like a **Wilson score interval** (this is key):
+
+* prevents small-sample abuse
+* avoids “1 review = #1 place”
+
+---
+
+## Step 3: Build aspect scores
+
+For each aspect:
+
+```text
+aspect_score = f(
+ positive_ratio,
+ confidence,
+ number_of_reviews
+)
+```
+
+You can approximate with:
+
+```text
+aspect_score = positive_ratio * log(1 + review_count)
+```
+
+(Simple, works surprisingly well)
+
+---
+
+## Step 4: User preference weighting
+
+User defines:
+
+```json
+{
+ "quality": 0.5,
+ "value": 0.2,
+ "service": 0.2,
+ "speed": 0.1
+}
+```
+
+Then:
+
+```text
+final_score = Σ (aspect_score × weight)
+```
+
+---
+
+## Step 5: Context filtering (this is your unfair advantage)
+
+Filter reviews before scoring:
+
+* time-based:
+
+ * “last 6 months”
+* context-based:
+
+ * lunch vs dinner
+ * solo vs group
+
+This is something centralized platforms barely do.
+
+---
+
+## Step 6: Reviewer weighting (later, but powerful)
+
+Weight reviews by:
+
+* consistency
+* similarity to user preferences
+* past agreement
+
+This gives you:
+
+> “people like you liked this”
+
+---
+
+# 3. Example end-to-end
+
+### Raw reviews:
+
+| User | Food | Service |
+| ---- | ---- | ------- |
+| A | 9 | 4 |
+| B | 8 | 5 |
+| C | 10 | 3 |
+
+---
+
+### Derived:
+
+* food → high positive ratio (~100%)
+* service → low (~33%)
+
+---
+
+### User preferences:
+
+```json
+{
+ "food": 0.8,
+ "service": 0.2
+}
+```
+
+→ ranks high
+
+Another user:
+
+```json
+{
+ "food": 0.3,
+ "service": 0.7
+}
+```
+
+→ ranks low
+
+👉 Same data, different truth
+That’s your killer feature.
+
+---
+
+# 4. Critical design choices (don’t skip these)
+
+## A. No global score in protocol
+
+Let clients compute it.
+
+---
+
+## B. Embrace incomplete data
+
+Most reviews will have:
+
+* 1–3 aspects only
+
+That’s fine.
+
+---
+
+## C. Time decay (important)
+
+Recent reviews should matter more:
+
+```text
+weight = e^(-λ × age)
+```
+
+---
+
+## D. Anti-gaming baseline
+
+Even in nostr:
+
+* spam will happen
+
+Mitigation later:
+
+* require minimum interactions
+* reputation layers
+
+---
+
+# 5. What you’ve built (zooming out)
+
+This is not a review system.
+
+It’s:
+
+> A decentralized, multi-dimensional reputation graph for real-world places
+
+That’s much bigger.
+
+---
+
+# 6. Next step (if you want to go deeper)
+
+We can design:
+
+### A. Query layer
+
+* how clients fetch & merge nostr reviews efficiently
+
+### B. Anti-spam / trust model
+
+* web-of-trust
+* staking / reputation
+
+### C. OSM integration details
+
+* handling duplicates
+* POI identity conflicts
+
+---
+
+If I had to pick one next:
+👉 **trust/reputation system** — because without it, everything you built *will* get gamed.
diff --git a/doc/nostr/ratings.md b/doc/nostr/ratings.md
new file mode 100644
index 0000000..9e69d02
--- /dev/null
+++ b/doc/nostr/ratings.md
@@ -0,0 +1,101 @@
+# Canonical Aspect Vocabulary (v0.1)
+
+## A. Core universal aspects
+
+These should work for *any* place:
+
+```json
+[
+ "quality", // core offering (food, repair, exhibits, etc.)
+ "value", // value for money/time
+ "experience", // comfort, usability, vibe
+ "accessibility" // ease of access, inclusivity
+]
+```
+
+### Why these work
+
+* **quality** → your “product” abstraction (critical)
+* **value** → universally meaningful signal
+* **experience** → captures everything “soft”
+* **accessibility** → often ignored but high utility
+
+👉 Resist adding more. Every extra “universal” weakens the concept.
+
+---
+
+## B. Common cross-domain aspects (recommended pool)
+
+Not universal, but widely reusable:
+
+```json
+[
+ "service", // human interaction
+ "speed", // waiting time / turnaround
+ "cleanliness",
+ "safety",
+ "reliability",
+ "atmosphere"
+]
+```
+
+These apply to:
+
+* restaurants, garages, clinics, parks, etc.
+
+---
+
+## C. Domain-specific examples (NOT standardized)
+
+Let clients define freely:
+
+```json
+{
+ "restaurant": ["food", "drinks"],
+ "bar": ["drinks", "music"],
+ "garage": ["work_quality", "honesty"],
+ "park": ["greenery", "amenities"],
+ "museum": ["exhibits", "crowding"]
+}
+```
+
+---
+
+## D. Key rule (this prevents chaos)
+
+👉 **Aspect keys MUST be lowercase snake_case**
+
+👉 **Meaning is defined socially, not technically**
+
+To reduce fragmentation:
+
+* publish a **public registry (GitHub repo)**
+* clients can:
+
+ * suggest additions
+ * map synonyms
+
+---
+
+## E. Optional normalization hint (important later)
+
+Allow this:
+
+```json
+"aspect_aliases": {
+ "food": "quality",
+ "work_quality": "quality"
+}
+```
+
+Not required, but useful for aggregation engines.
+
+---
+
+## Notes
+
+Map familiarity in UI to:
+
+* high: “I know this place well”
+* medium: “Been a few times”
+* low: “First visit”
diff --git a/doc/nostr/review-schema.json b/doc/nostr/review-schema.json
new file mode 100644
index 0000000..b2a2cb0
--- /dev/null
+++ b/doc/nostr/review-schema.json
@@ -0,0 +1,92 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://example.org/schemas/place-review.json",
+ "title": "Decentralized Place Review (Nostr/Event Content)",
+ "type": "object",
+ "required": ["version", "place", "ratings"],
+ "additionalProperties": false,
+
+ "properties": {
+ "version": {
+ "type": "integer",
+ "const": 1
+ },
+
+ "ratings": {
+ "type": "object",
+ "required": ["quality"],
+ "additionalProperties": false,
+ "properties": {
+ "quality": { "$ref": "#/$defs/score" },
+ "value": { "$ref": "#/$defs/score" },
+ "experience": { "$ref": "#/$defs/score" },
+ "accessibility": { "$ref": "#/$defs/score" },
+
+ "aspects": {
+ "type": "object",
+ "minProperties": 1,
+ "maxProperties": 20,
+ "additionalProperties": { "$ref": "#/$defs/score" },
+ "propertyNames": {
+ "pattern": "^[a-z][a-z0-9_]{1,31}$"
+ }
+ }
+ }
+ },
+
+ "recommend": {
+ "type": "boolean",
+ "description": "Whether the user recommends this place to others"
+ },
+
+ "familiarity": {
+ "type": "string",
+ "enum": ["low", "medium", "high"],
+ "description": "User familiarity with the place. Suggested interpretation: 'low' = first visit or very limited experience; 'medium' = visited a few times or moderate familiarity; 'high' = frequent visitor or strong familiarity."
+ },
+
+ "context": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "visited_at": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "duration_minutes": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 1440
+ },
+ "party_size": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100
+ }
+ }
+ },
+
+ "review": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "text": {
+ "type": "string",
+ "maxLength": 1000
+ },
+ "language": {
+ "type": "string",
+ "pattern": "^[a-z]{2}(-[A-Z]{2})?$"
+ }
+ }
+ }
+ },
+
+ "$defs": {
+ "score": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 10
+ }
+ }
+}
From 90750892216af539f27e384e6ea82a5f22ea924d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Sat, 18 Apr 2026 12:47:33 +0400
Subject: [PATCH 02/56] Update notes
---
doc/nostr/notes.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/doc/nostr/notes.md b/doc/nostr/notes.md
index 8aec90a..315771e 100644
--- a/doc/nostr/notes.md
+++ b/doc/nostr/notes.md
@@ -1,6 +1,7 @@
# Notes
- NIP-73 for external IDs ("osm:node:123456"): https://github.com/nostr-protocol/nips/blob/744bce8fcae0aca07b936b6662db635c8b4253dd/73.md
+- NIP 68/92/94 for place photos and image metadata (add "i" tag for "osm:node:123456" to NIP-68)
- Places NIP-XX draft PR: https://github.com/nostr-protocol/nips/pull/927
- NPM package for generating multi-resolution geotags: https://sandwichfarm.github.io/nostr-geotags/#md:nostr-geotags
- AppleSauce docs for AI agents: https://applesauce.build/introduction/mcp-server.html
From f01b5f8faaa18fa8ebc7f597ed0fb3d6b84f1fbf Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Sat, 18 Apr 2026 17:26:17 +0400
Subject: [PATCH 03/56] Add place photos NIP, update reviews NIP
---
doc/nostr/nip-place-photos.md | 105 +++++++++++++++++++++++++++++++++
doc/nostr/nip-place-reviews.md | 36 +----------
2 files changed, 108 insertions(+), 33 deletions(-)
create mode 100644 doc/nostr/nip-place-photos.md
diff --git a/doc/nostr/nip-place-photos.md b/doc/nostr/nip-place-photos.md
new file mode 100644
index 0000000..4994cf0
--- /dev/null
+++ b/doc/nostr/nip-place-photos.md
@@ -0,0 +1,105 @@
+# NIP-XX: Place Photos and Media
+
+`draft` `optional`
+
+## Abstract
+
+This NIP defines a standardized event format for sharing photos, videos, and other visual media tied to specific real-world locations (e.g., OpenStreetMap POIs).
+
+While NIP-68 (Picture-first feeds) caters to general visual feeds, this NIP specifically targets map-based applications, travel logs, and location directories by mandating strict entity identifiers (`i` tags) and spatial indexing (`g` tags).
+
+## Event Kind
+
+`kind: 360`
+
+## Content
+
+The `.content` of the event SHOULD generally be empty. If a user wishes to provide a detailed description, summary, or caption for a place, clients SHOULD encourage them to create a Place Review event (`kind: 30360`) instead.
+
+## Tags
+
+This NIP relies on existing Nostr tag conventions to link media to places and provide inline metadata.
+
+### Required Tags
+
+#### 1. `i` — Entity Identifier
+
+Identifies the exact place the media depicts using an external identifier (as defined in NIP-73). OpenStreetMap data is the default:
+
+```json
+["i", "osm:node:123456"]
+```
+* For OSM POIs, `` MUST be one of: `node`, `way`, `relation`.
+
+#### 2. `g` — Geohash
+
+Used for spatial indexing and discovery. Events MUST include at least one high-precision geohash. To optimize for map-based discovery across different zoom levels, clients SHOULD include geohashes at multiple resolutions:
+
+```json
+["g", "thrr"] // coarse (~city)
+["g", "thrrn5"] // medium (~1km)
+["g", "thrrn5k"] // fine (~150m)
+["g", "thrrn5kxyz"] // exact
+```
+
+#### 3. `imeta` — Inline Media Metadata
+
+Media files MUST be attached using the `imeta` tag as defined in NIP-92. Each `imeta` tag represents one media item. The primary `url` SHOULD also be appended to the event's `.content` for backwards compatibility with clients that do not parse `imeta` tags.
+
+Clients SHOULD include `alt` (accessibility descriptions), `dim` (dimensions), `m` (MIME type), and `blurhash` where possible.
+
+```json
+[
+ "imeta",
+ "url https://example.com/photo.jpg",
+ "m image/jpeg",
+ "dim 3024x4032",
+ "alt A steaming bowl of ramen on a wooden table at the restaurant.",
+ "blurhash eVF$^OI:${M{o#*0-nNFxakD-?xVM}WEWB%iNKxvR-oetmo#R-aen$"
+]
+```
+
+### Optional Tags
+
+* `t`: Hashtags for categorization (e.g., `["t", "food"]`, `["t", "architecture"]`).
+* `content-warning`: If the media contains NSFW or sensitive imagery.
+* `published_at`: Unix timestamp of when the photo was originally taken or published.
+
+## Example Event
+
+```json
+{
+ "id": "<32-bytes hex>",
+ "pubkey": "<32-bytes hex>",
+ "created_at": 1713205000,
+ "kind": 360,
+ "content": "",
+ "tags": [
+ ["i", "osm:node:987654321"],
+ ["g", "xn0m"],
+ ["g", "xn0m7h"],
+ ["g", "xn0m7hwq"],
+
+ ["imeta",
+ "url https://example.com/ramen.jpg",
+ "m image/jpeg",
+ "dim 1080x1080",
+ "alt A close-up of spicy miso ramen with chashu pork, soft boiled egg, and scallions.",
+ "blurhash UHI=0o~q4T-o~q%MozM{x]t7RjRPt7oKkCWB"
+ ],
+
+ ["t", "ramen"],
+ ["t", "food"]
+ ]
+}
+```
+
+## Rationale
+
+### Why not use NIP-68 (Picture-first feeds)?
+
+NIP-68 is designed for general-purpose social feeds (like Instagram). Place photos require strict guarantees about what entity is being depicted to be useful for map clients, directories, and review aggregators. By mandating the `i` tag for POI linking and the `g` tag for spatial querying, this kind ensures interoperability for geo-spatial applications without cluttering general picture feeds with mundane POI images (like photos of storefronts or menus).
+
+### Separation from Place Reviews
+
+Reviews (kind 30360) and media have different lifecycles and data models. A user might upload 10 photos of a park without writing a review, or write a detailed review without attaching photos. Keeping them as separate events allows clients to query `imeta` attachments for a specific `i` tag to quickly build a photo gallery for a place, regardless of whether a review was attached.
diff --git a/doc/nostr/nip-place-reviews.md b/doc/nostr/nip-place-reviews.md
index a0221d2..efe8861 100644
--- a/doc/nostr/nip-place-reviews.md
+++ b/doc/nostr/nip-place-reviews.md
@@ -1,5 +1,7 @@
# NIP-XX: Place Reviews
+`draft` `optional`
+
## Abstract
This NIP defines a standardized event format for decentralized place reviews using Nostr. Reviews are tied to real-world locations (e.g. OpenStreetMap POIs) via tags, and include structured, multi-aspect ratings, a binary recommendation signal, and optional contextual metadata.
@@ -11,13 +13,9 @@ The design prioritizes:
* Flexibility for different place types
* Efficient geospatial querying using geohashes
----
-
## Event Kind
-`kind: 30315` (suggested; subject to coordination)
-
----
+`kind: 30360`
## Tags
@@ -51,8 +49,6 @@ Examples:
["i", "osm:way:987654"]
```
----
-
### Geospatial Tags
#### `g` — Geohash
@@ -91,8 +87,6 @@ Geospatial queries are performed using the `g` tag.
Note: Other queries (e.g. fetching reviews for a specific place) are performed using the `i` tag and are outside the scope of geospatial querying.
----
-
## Content (JSON)
The event `content` MUST be valid JSON matching the following schema.
@@ -183,8 +177,6 @@ The event `content` MUST be valid JSON matching the following schema.
}
```
----
-
## Example
### Restaurant Review Event
@@ -232,8 +224,6 @@ The event `content` MUST be valid JSON matching the following schema.
}
```
----
-
## Semantics
### Ratings
@@ -248,8 +238,6 @@ The event `content` MUST be valid JSON matching the following schema.
* Clients MAY define and interpret aspect keys
* Clients SHOULD reuse commonly established aspect keys where possible
----
-
## Recommendation Signal
The `recommend` field represents a binary verdict:
@@ -259,8 +247,6 @@ The `recommend` field represents a binary verdict:
Clients SHOULD strongly encourage users to provide this value.
----
-
## Familiarity
Represents user familiarity with the place:
@@ -271,8 +257,6 @@ Represents user familiarity with the place:
Clients MAY use this signal for weighting during aggregation.
----
-
## Context
Optional metadata about the visit.
@@ -281,8 +265,6 @@ Optional metadata about the visit.
* `duration_minutes` represents time spent
* `party_size` indicates group size
----
-
## Interoperability
This specification defines a content payload only.
@@ -292,8 +274,6 @@ This specification defines a content payload only.
Content payloads SHOULD NOT include place identifiers.
----
-
## Rationale
### No Place Field in Content
@@ -320,8 +300,6 @@ Multiple resolutions balance:
* small event size
* early-stage discoverability
----
-
## Future Work
* Standardized aspect vocabularies
@@ -329,15 +307,7 @@ Multiple resolutions balance:
* Indexing/aggregation services
* Cross-protocol mappings
----
-
## Security Considerations
* Clients SHOULD validate all input
* Malicious or spam reviews may require external moderation or reputation systems
-
----
-
-## Copyright
-
-This NIP is public domain.
From 2268a607d5c800f72be648f938c1826e898a36b3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Sat, 18 Apr 2026 18:35:45 +0400
Subject: [PATCH 04/56] Format docs
---
doc/nostr/nip-place-photos.md | 12 ++---
doc/nostr/nip-place-reviews.md | 80 +++++++++++++++++-----------------
doc/nostr/ranking.md | 57 ++++++++++++------------
doc/nostr/ratings.md | 37 ++++++++--------
4 files changed, 93 insertions(+), 93 deletions(-)
diff --git a/doc/nostr/nip-place-photos.md b/doc/nostr/nip-place-photos.md
index 4994cf0..2c22c58 100644
--- a/doc/nostr/nip-place-photos.md
+++ b/doc/nostr/nip-place-photos.md
@@ -29,7 +29,8 @@ Identifies the exact place the media depicts using an external identifier (as de
```json
["i", "osm:node:123456"]
```
-* For OSM POIs, `` MUST be one of: `node`, `way`, `relation`.
+
+- For OSM POIs, `` MUST be one of: `node`, `way`, `relation`.
#### 2. `g` — Geohash
@@ -61,9 +62,9 @@ Clients SHOULD include `alt` (accessibility descriptions), `dim` (dimensions), `
### Optional Tags
-* `t`: Hashtags for categorization (e.g., `["t", "food"]`, `["t", "architecture"]`).
-* `content-warning`: If the media contains NSFW or sensitive imagery.
-* `published_at`: Unix timestamp of when the photo was originally taken or published.
+- `t`: Hashtags for categorization (e.g., `["t", "food"]`, `["t", "architecture"]`).
+- `content-warning`: If the media contains NSFW or sensitive imagery.
+- `published_at`: Unix timestamp of when the photo was originally taken or published.
## Example Event
@@ -80,7 +81,8 @@ Clients SHOULD include `alt` (accessibility descriptions), `dim` (dimensions), `
["g", "xn0m7h"],
["g", "xn0m7hwq"],
- ["imeta",
+ [
+ "imeta",
"url https://example.com/ramen.jpg",
"m image/jpeg",
"dim 1080x1080",
diff --git a/doc/nostr/nip-place-reviews.md b/doc/nostr/nip-place-reviews.md
index efe8861..177d6d9 100644
--- a/doc/nostr/nip-place-reviews.md
+++ b/doc/nostr/nip-place-reviews.md
@@ -8,10 +8,10 @@ This NIP defines a standardized event format for decentralized place reviews usi
The design prioritizes:
-* Small event size
-* Interoperability across clients
-* Flexibility for different place types
-* Efficient geospatial querying using geohashes
+- Small event size
+- Interoperability across clients
+- Flexibility for different place types
+- Efficient geospatial querying using geohashes
## Event Kind
@@ -23,8 +23,8 @@ Additional tags MAY be included by clients but are not defined by this specifica
This NIP reuses and builds upon existing Nostr tag conventions:
-* `i` tag: see NIP-73 (External Content Identifiers)
-* `g` tag: geohash-based geotagging (community conventions)
+- `i` tag: see NIP-73 (External Content Identifiers)
+- `g` tag: geohash-based geotagging (community conventions)
Where conflicts arise, this NIP specifies the behavior for review events.
@@ -40,7 +40,7 @@ Identifies the reviewed place using an external identifier. OpenStreetMap data i
Requirements:
-* For OSM POIs, `` MUST be one of: `node`, `way`, `relation`
+- For OSM POIs, `` MUST be one of: `node`, `way`, `relation`
Examples:
@@ -57,15 +57,15 @@ Geohash tags are used for spatial indexing and discovery.
##### Requirements
-* Clients MUST include at least one high-precision geohash (length ≥ 9)
+- Clients MUST include at least one high-precision geohash (length ≥ 9)
##### Recommendations
Clients SHOULD include geohashes at the following resolutions:
-* length 4 — coarse (city-scale discovery)
-* length 6 — medium (default query level, ~1 km)
-* length 7 — fine (neighborhood, ~150 m)
+- length 4 — coarse (city-scale discovery)
+- length 6 — medium (default query level, ~1 km)
+- length 7 — fine (neighborhood, ~150 m)
Example:
@@ -80,10 +80,10 @@ Example:
Geospatial queries are performed using the `g` tag.
-* Clients SHOULD query using a single geohash precision level per request
-* Clients MAY include multiple geohash values in a filter to cover a bounding box
-* Clients SHOULD limit the number of geohash values per query (e.g. ≤ 30)
-* Clients MAY reduce precision or split queries when necessary
+- Clients SHOULD query using a single geohash precision level per request
+- Clients MAY include multiple geohash values in a filter to cover a bounding box
+- Clients SHOULD limit the number of geohash values per query (e.g. ≤ 30)
+- Clients MAY reduce precision or split queries when necessary
Note: Other queries (e.g. fetching reviews for a specific place) are performed using the `i` tag and are outside the scope of geospatial querying.
@@ -228,22 +228,22 @@ The event `content` MUST be valid JSON matching the following schema.
### Ratings
-* Scores are integers from 1 to 10
-* `quality` is required and represents the core evaluation of the place
-* Other fields are optional and context-dependent
+- Scores are integers from 1 to 10
+- `quality` is required and represents the core evaluation of the place
+- Other fields are optional and context-dependent
### Aspects
-* Free-form keys allow domain-specific ratings
-* Clients MAY define and interpret aspect keys
-* Clients SHOULD reuse commonly established aspect keys where possible
+- Free-form keys allow domain-specific ratings
+- Clients MAY define and interpret aspect keys
+- Clients SHOULD reuse commonly established aspect keys where possible
## Recommendation Signal
The `recommend` field represents a binary verdict:
-* `true` → user recommends the place
-* `false` → user does not recommend the place
+- `true` → user recommends the place
+- `false` → user does not recommend the place
Clients SHOULD strongly encourage users to provide this value.
@@ -251,9 +251,9 @@ Clients SHOULD strongly encourage users to provide this value.
Represents user familiarity with the place:
-* `low` → first visit or limited exposure
-* `medium` → occasional visits
-* `high` → frequent or expert-level familiarity
+- `low` → first visit or limited exposure
+- `medium` → occasional visits
+- `high` → frequent or expert-level familiarity
Clients MAY use this signal for weighting during aggregation.
@@ -261,16 +261,16 @@ Clients MAY use this signal for weighting during aggregation.
Optional metadata about the visit.
-* `visited_at` is a Unix timestamp
-* `duration_minutes` represents time spent
-* `party_size` indicates group size
+- `visited_at` is a Unix timestamp
+- `duration_minutes` represents time spent
+- `party_size` indicates group size
## Interoperability
This specification defines a content payload only.
-* In Nostr: place identity is conveyed via tags
-* In other protocols (e.g. ActivityPub, AT Protocol): identity MUST be mapped to the equivalent field (e.g. `object`)
+- In Nostr: place identity is conveyed via tags
+- In other protocols (e.g. ActivityPub, AT Protocol): identity MUST be mapped to the equivalent field (e.g. `object`)
Content payloads SHOULD NOT include place identifiers.
@@ -296,18 +296,18 @@ Provides a human-friendly proxy for confidence without requiring numeric input.
Multiple resolutions balance:
-* efficient querying
-* small event size
-* early-stage discoverability
+- efficient querying
+- small event size
+- early-stage discoverability
## Future Work
-* Standardized aspect vocabularies
-* Reputation and weighting models
-* Indexing/aggregation services
-* Cross-protocol mappings
+- Standardized aspect vocabularies
+- Reputation and weighting models
+- Indexing/aggregation services
+- Cross-protocol mappings
## Security Considerations
-* Clients SHOULD validate all input
-* Malicious or spam reviews may require external moderation or reputation systems
+- Clients SHOULD validate all input
+- Malicious or spam reviews may require external moderation or reputation systems
diff --git a/doc/nostr/ranking.md b/doc/nostr/ranking.md
index 7f5489e..f39bf33 100644
--- a/doc/nostr/ranking.md
+++ b/doc/nostr/ranking.md
@@ -2,13 +2,13 @@
Your inputs:
-* many users
-* partial ratings
-* different priorities
+- many users
+- partial ratings
+- different priorities
Your output:
-> “Best place *for this user right now*”
+> “Best place _for this user right now_”
---
@@ -22,8 +22,8 @@ normalized_score = (score - 1) / 9
Why:
-* easier math
-* comparable across aspects
+- easier math
+- comparable across aspects
---
@@ -50,8 +50,8 @@ positive_ratio = positive_votes / total_votes
Use something like a **Wilson score interval** (this is key):
-* prevents small-sample abuse
-* avoids “1 review = #1 place”
+- prevents small-sample abuse
+- avoids “1 review = #1 place”
---
@@ -102,13 +102,12 @@ final_score = Σ (aspect_score × weight)
Filter reviews before scoring:
-* time-based:
+- time-based:
+ - “last 6 months”
- * “last 6 months”
-* context-based:
-
- * lunch vs dinner
- * solo vs group
+- context-based:
+ - lunch vs dinner
+ - solo vs group
This is something centralized platforms barely do.
@@ -118,9 +117,9 @@ This is something centralized platforms barely do.
Weight reviews by:
-* consistency
-* similarity to user preferences
-* past agreement
+- consistency
+- similarity to user preferences
+- past agreement
This gives you:
@@ -142,8 +141,8 @@ This gives you:
### Derived:
-* food → high positive ratio (~100%)
-* service → low (~33%)
+- food → high positive ratio (~100%)
+- service → low (~33%)
---
@@ -186,7 +185,7 @@ Let clients compute it.
Most reviews will have:
-* 1–3 aspects only
+- 1–3 aspects only
That’s fine.
@@ -206,12 +205,12 @@ weight = e^(-λ × age)
Even in nostr:
-* spam will happen
+- spam will happen
Mitigation later:
-* require minimum interactions
-* reputation layers
+- require minimum interactions
+- reputation layers
---
@@ -233,19 +232,19 @@ We can design:
### A. Query layer
-* how clients fetch & merge nostr reviews efficiently
+- how clients fetch & merge nostr reviews efficiently
### B. Anti-spam / trust model
-* web-of-trust
-* staking / reputation
+- web-of-trust
+- staking / reputation
### C. OSM integration details
-* handling duplicates
-* POI identity conflicts
+- handling duplicates
+- POI identity conflicts
---
If I had to pick one next:
-👉 **trust/reputation system** — because without it, everything you built *will* get gamed.
+👉 **trust/reputation system** — because without it, everything you built _will_ get gamed.
diff --git a/doc/nostr/ratings.md b/doc/nostr/ratings.md
index 9e69d02..593091e 100644
--- a/doc/nostr/ratings.md
+++ b/doc/nostr/ratings.md
@@ -2,23 +2,23 @@
## A. Core universal aspects
-These should work for *any* place:
+These should work for _any_ place:
```json
[
- "quality", // core offering (food, repair, exhibits, etc.)
- "value", // value for money/time
- "experience", // comfort, usability, vibe
+ "quality", // core offering (food, repair, exhibits, etc.)
+ "value", // value for money/time
+ "experience", // comfort, usability, vibe
"accessibility" // ease of access, inclusivity
]
```
### Why these work
-* **quality** → your “product” abstraction (critical)
-* **value** → universally meaningful signal
-* **experience** → captures everything “soft”
-* **accessibility** → often ignored but high utility
+- **quality** → your “product” abstraction (critical)
+- **value** → universally meaningful signal
+- **experience** → captures everything “soft”
+- **accessibility** → often ignored but high utility
👉 Resist adding more. Every extra “universal” weakens the concept.
@@ -30,8 +30,8 @@ Not universal, but widely reusable:
```json
[
- "service", // human interaction
- "speed", // waiting time / turnaround
+ "service", // human interaction
+ "speed", // waiting time / turnaround
"cleanliness",
"safety",
"reliability",
@@ -41,7 +41,7 @@ Not universal, but widely reusable:
These apply to:
-* restaurants, garages, clinics, parks, etc.
+- restaurants, garages, clinics, parks, etc.
---
@@ -69,11 +69,10 @@ Let clients define freely:
To reduce fragmentation:
-* publish a **public registry (GitHub repo)**
-* clients can:
-
- * suggest additions
- * map synonyms
+- publish a **public registry (GitHub repo)**
+- clients can:
+ - suggest additions
+ - map synonyms
---
@@ -96,6 +95,6 @@ Not required, but useful for aggregation engines.
Map familiarity in UI to:
-* high: “I know this place well”
-* medium: “Been a few times”
-* low: “First visit”
+- high: “I know this place well”
+- medium: “Been a few times”
+- low: “First visit”
From f875fc18776484eb0dce057c5f9474ff555a5569 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Sat, 18 Apr 2026 18:36:09 +0400
Subject: [PATCH 05/56] WIP Add Nostr auth
---
app/components/place-photo-upload.gjs | 179 ++++++
app/components/user-menu.gjs | 40 +-
app/services/nostr-auth.js | 60 ++
app/services/nostr-relay.js | 25 +
package.json | 7 +-
pnpm-lock.yaml | 802 ++++++++++++++++++++++++++
6 files changed, 1110 insertions(+), 3 deletions(-)
create mode 100644 app/components/place-photo-upload.gjs
create mode 100644 app/services/nostr-auth.js
create mode 100644 app/services/nostr-relay.js
diff --git a/app/components/place-photo-upload.gjs b/app/components/place-photo-upload.gjs
new file mode 100644
index 0000000..86d0343
--- /dev/null
+++ b/app/components/place-photo-upload.gjs
@@ -0,0 +1,179 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
+import { inject as service } from '@ember/service';
+import { on } from '@ember/modifier';
+import { EventFactory } from 'applesauce-core';
+
+export default class PlacePhotoUpload extends Component {
+ @service nostrAuth;
+ @service nostrRelay;
+
+ @tracked photoUrl = '';
+ @tracked osmId = '';
+ @tracked geohash = '';
+ @tracked status = '';
+ @tracked error = '';
+
+ @action
+ async login() {
+ try {
+ this.error = '';
+ await this.nostrAuth.login();
+ } catch (e) {
+ this.error = e.message;
+ }
+ }
+
+ @action
+ async uploadPhoto(event) {
+ event.preventDefault();
+ this.error = '';
+ this.status = 'Uploading...';
+
+ try {
+ // Mock upload
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ this.photoUrl =
+ 'https://dummyimage.com/600x400/000/fff.jpg&text=Mock+Place+Photo';
+ this.status = 'Photo uploaded! Ready to publish.';
+ } catch (e) {
+ this.error = 'Upload failed: ' + e.message;
+ this.status = '';
+ }
+ }
+
+ @action
+ async publish() {
+ if (!this.nostrAuth.isConnected) {
+ this.error = 'You must connect Nostr first.';
+ return;
+ }
+
+ if (!this.photoUrl || !this.osmId || !this.geohash) {
+ this.error = 'Please provide an OSM ID, Geohash, and upload a photo.';
+ return;
+ }
+
+ this.status = 'Publishing event...';
+ this.error = '';
+
+ try {
+ const factory = new EventFactory({ signer: this.nostrAuth.signer });
+
+ // NIP-XX draft Place Photo event
+ const template = {
+ kind: 360,
+ content: '',
+ tags: [
+ ['i', `osm:node:${this.osmId}`],
+ ['g', this.geohash],
+ [
+ 'imeta',
+ `url ${this.photoUrl}`,
+ 'm image/jpeg',
+ 'dim 600x400',
+ 'alt A photo of a place',
+ ],
+ ],
+ };
+
+ // Ensure created_at is present before signing
+ if (!template.created_at) {
+ template.created_at = Math.floor(Date.now() / 1000);
+ }
+
+ const event = await factory.sign(template);
+ await this.nostrRelay.publish(event);
+
+ this.status = 'Published successfully!';
+ // Reset form
+ this.photoUrl = '';
+ this.osmId = '';
+ this.geohash = '';
+ } catch (e) {
+ this.error = 'Failed to publish: ' + e.message;
+ this.status = '';
+ }
+ }
+
+ @action updateOsmId(e) {
+ this.osmId = e.target.value;
+ }
+ @action updateGeohash(e) {
+ this.geohash = e.target.value;
+ }
+
+
+
+
Add Place Photo
+
+ {{#if this.error}}
+
+ {{this.error}}
+
+ {{/if}}
+
+ {{#if this.status}}
+
+ {{this.status}}
+
+ {{/if}}
+
+ {{#if this.nostrAuth.isConnected}}
+
+ Connected:
+ {{this.nostrAuth.pubkey}}
+
+
+
+ {{else}}
+
+ Connect Nostr Extension
+
+ {{/if}}
+
+
+}
diff --git a/app/components/user-menu.gjs b/app/components/user-menu.gjs
index cc76bbf..c1bac0f 100644
--- a/app/components/user-menu.gjs
+++ b/app/components/user-menu.gjs
@@ -8,6 +8,8 @@ export default class UserMenuComponent extends Component {
@service storage;
@service osmAuth;
+ @service nostrAuth;
+
@action
connectRS() {
this.args.onClose();
@@ -30,6 +32,21 @@ export default class UserMenuComponent extends Component {
this.osmAuth.logout();
}
+ @action
+ async connectNostr() {
+ try {
+ await this.nostrAuth.login();
+ } catch (e) {
+ console.error(e);
+ alert(e.message);
+ }
+ }
+
+ @action
+ disconnectNostr() {
+ this.nostrAuth.logout();
+ }
+
-
+
- Coming soon
+ {{#if this.nostrAuth.isConnected}}
+
+ {{this.nostrAuth.pubkey}}
+
+ {{else}}
+ Not connected
+ {{/if}}
diff --git a/app/services/nostr-auth.js b/app/services/nostr-auth.js
new file mode 100644
index 0000000..8963aa0
--- /dev/null
+++ b/app/services/nostr-auth.js
@@ -0,0 +1,60 @@
+import Service from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+import { ExtensionSigner } from 'applesauce-signers';
+
+const STORAGE_KEY = 'marco:nostr_pubkey';
+
+export default class NostrAuthService extends Service {
+ @tracked pubkey = null;
+ signer = null;
+
+ constructor() {
+ super(...arguments);
+ const saved = localStorage.getItem(STORAGE_KEY);
+ if (saved) {
+ this.pubkey = saved;
+ }
+ }
+
+ get isConnected() {
+ return !!this.pubkey;
+ }
+
+ async login() {
+ if (typeof window.nostr === 'undefined') {
+ throw new Error('No NIP-07 Nostr extension found (e.g., Alby, nos2x).');
+ }
+
+ if (!this.signer) {
+ this.signer = new ExtensionSigner();
+ }
+
+ try {
+ this.pubkey = await this.signer.getPublicKey();
+ localStorage.setItem(STORAGE_KEY, this.pubkey);
+ return this.pubkey;
+ } catch (error) {
+ console.error('Failed to get public key from extension:', error);
+ throw error;
+ }
+ }
+
+ async signEvent(event) {
+ if (!this.signer) {
+ if (this.pubkey && typeof window.nostr !== 'undefined') {
+ this.signer = new ExtensionSigner();
+ } else {
+ throw new Error(
+ 'Not connected or extension missing. Please connect Nostr again.'
+ );
+ }
+ }
+ return await this.signer.signEvent(event);
+ }
+
+ logout() {
+ this.pubkey = null;
+ this.signer = null;
+ localStorage.removeItem(STORAGE_KEY);
+ }
+}
diff --git a/app/services/nostr-relay.js b/app/services/nostr-relay.js
new file mode 100644
index 0000000..98e7e9f
--- /dev/null
+++ b/app/services/nostr-relay.js
@@ -0,0 +1,25 @@
+import Service from '@ember/service';
+import { RelayPool } from 'applesauce-relay';
+
+export default class NostrRelayService extends Service {
+ pool = new RelayPool();
+
+ // For Phase 1, we hardcode the local relay
+ relays = ['ws://127.0.0.1:7777'];
+
+ async publish(event) {
+ // The publish method is a wrapper around the event method that returns a Promise
+ // and automatically handles reconnecting and retrying.
+ const responses = await this.pool.publish(this.relays, event);
+
+ // Check if at least one relay accepted the event
+ const success = responses.some((res) => res.ok);
+ if (!success) {
+ throw new Error(
+ `Failed to publish event. Responses: ${JSON.stringify(responses)}`
+ );
+ }
+
+ return responses;
+ }
+}
diff --git a/package.json b/package.json
index a7d304b..ca50c6d 100644
--- a/package.json
+++ b/package.json
@@ -103,8 +103,13 @@
},
"dependencies": {
"@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",
"ember-concurrency": "^5.2.0",
"ember-lifeline": "^7.0.0",
- "oauth2-pkce": "^2.1.3"
+ "oauth2-pkce": "^2.1.3",
+ "rxjs": "^7.8.2"
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c1102d9..8dd2a87 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11,6 +11,18 @@ importers:
'@waysidemapping/pinhead':
specifier: ^15.20.0
version: 15.20.0
+ applesauce-core:
+ specifier: ^5.2.0
+ version: 5.2.0(typescript@5.9.3)
+ applesauce-factory:
+ specifier: ^4.0.0
+ version: 4.0.0(typescript@5.9.3)
+ applesauce-relay:
+ specifier: ^5.2.0
+ version: 5.2.0(typescript@5.9.3)
+ applesauce-signers:
+ specifier: ^5.2.0
+ version: 5.2.0(@capacitor/core@7.6.2)(typescript@5.9.3)
ember-concurrency:
specifier: ^5.2.0
version: 5.2.0(@babel/core@7.28.6)
@@ -20,6 +32,9 @@ importers:
oauth2-pkce:
specifier: ^2.1.3
version: 2.1.3
+ rxjs:
+ specifier: ^7.8.2
+ version: 7.8.2
devDependencies:
'@babel/core':
specifier: ^7.28.5
@@ -764,6 +779,13 @@ packages:
'@cacheable/utils@2.3.3':
resolution: {integrity: sha512-JsXDL70gQ+1Vc2W/KUFfkAJzgb4puKwwKehNLuB+HrNKWf91O736kGfxn4KujXCCSuh6mRRL4XEB0PkAFjWS0A==}
+ '@capacitor/core@7.6.2':
+ resolution: {integrity: sha512-8HRKEUlYpCOeRec8bCHZwEA4o/E2q5dhHSd0v/Cr6+ume08fZY/gniF+ZCKF+6DO0T/nRaBeNRQn6Up+45J1mg==}
+
+ '@cashu/cashu-ts@2.9.0':
+ resolution: {integrity: sha512-UesYcBkkJAGPbob2I/SX0aht4MQlfxUVPzI+NQqLKSg2l3m0EnmMznvnXZQnDZnhGy0Hd6rkgwAJTVOYcr0v/Q==}
+ engines: {node: '>=22.4.0'}
+
'@cnakazawa/watch@1.0.4':
resolution: {integrity: sha512-v9kIhKwjeZThiWrLmj0y17CWoyddASLj9O2yvbZkbvw/N3rWOYy9zkV66ursAoVr0mV15bL8g0c4QZUE6cdDoQ==}
engines: {node: '>=0.1.95'}
@@ -1355,6 +1377,54 @@ packages:
'@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1':
resolution: {integrity: sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==}
+ '@noble/ciphers@0.5.3':
+ resolution: {integrity: sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==}
+
+ '@noble/ciphers@2.1.1':
+ resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==}
+ engines: {node: '>= 20.19.0'}
+
+ '@noble/curves@1.1.0':
+ resolution: {integrity: sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==}
+
+ '@noble/curves@1.2.0':
+ resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==}
+
+ '@noble/curves@1.9.7':
+ resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==}
+ engines: {node: ^14.21.3 || >=16}
+
+ '@noble/curves@2.0.1':
+ resolution: {integrity: sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==}
+ engines: {node: '>= 20.19.0'}
+
+ '@noble/curves@2.2.0':
+ resolution: {integrity: sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==}
+ engines: {node: '>= 20.19.0'}
+
+ '@noble/hashes@1.3.1':
+ resolution: {integrity: sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==}
+ engines: {node: '>= 16'}
+
+ '@noble/hashes@1.3.2':
+ resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==}
+ engines: {node: '>= 16'}
+
+ '@noble/hashes@1.8.0':
+ resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
+ engines: {node: ^14.21.3 || >=16}
+
+ '@noble/hashes@2.0.1':
+ resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==}
+ engines: {node: '>= 20.19.0'}
+
+ '@noble/hashes@2.2.0':
+ resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==}
+ engines: {node: '>= 20.19.0'}
+
+ '@noble/secp256k1@1.7.2':
+ resolution: {integrity: sha512-/qzwYl5eFLH8OWIecQWM31qld2g1NfjgylK+TNhqtaUKP37Nm+Y+z30Fjhw0Ct8p9yCQEm2N3W/AckdIb3SMcQ==}
+
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -1549,6 +1619,30 @@ packages:
cpu: [x64]
os: [win32]
+ '@scure/base@1.1.1':
+ resolution: {integrity: sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==}
+
+ '@scure/base@1.2.6':
+ resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==}
+
+ '@scure/base@2.0.0':
+ resolution: {integrity: sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==}
+
+ '@scure/bip32@1.3.1':
+ resolution: {integrity: sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==}
+
+ '@scure/bip32@1.7.0':
+ resolution: {integrity: sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==}
+
+ '@scure/bip32@2.0.1':
+ resolution: {integrity: sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==}
+
+ '@scure/bip39@1.2.1':
+ resolution: {integrity: sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==}
+
+ '@scure/bip39@2.0.1':
+ resolution: {integrity: sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==}
+
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
@@ -1577,6 +1671,9 @@ packages:
'@types/cors@2.8.19':
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
+ '@types/debug@4.1.13':
+ resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
+
'@types/eslint@8.56.12':
resolution: {integrity: sha512-03ruubjWyOHlmljCVoxSuNDdmfZDzsrrz0P2LeJsOXr+ZwFQ+0yQIwNCwt/GYhV7Z31fgtXJTAEs+FYlEL851g==}
@@ -1593,15 +1690,24 @@ packages:
resolution: {integrity: sha512-00UxlRaIUvYm4R4W9WYkN8/J+kV8fmOQ7okeH6YFtGWFMt3odD45tpG5yA5wnL7HE6lLgjaTW5n14ju2hl2NNA==}
deprecated: This is a stub types definition. glob provides its own type definitions, so you do not need this installed.
+ '@types/hast@3.0.4':
+ resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
+
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
+ '@types/mdast@4.0.4':
+ resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
+
'@types/minimatch@3.0.5':
resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==}
'@types/minimatch@5.1.2':
resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==}
+ '@types/ms@2.1.0':
+ resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
+
'@types/node@20.14.0':
resolution: {integrity: sha512-5cHBxFGJx6L4s56Bubp4fglrEpmyJypsqI6RgzMfBHWUJQGWAAi8cWcgetEbZXHYXo9C2Fa4EEds/uSyS4cxmA==}
@@ -1620,6 +1726,9 @@ packages:
'@types/tv4@1.2.33':
resolution: {integrity: sha512-7phCVTXC6Bj50IV1iKOwqGkR4JONJyMbRZnKTSuujv1S/tO9rG5OdCt7BMSjytO+zJmYdn1/I4fd3SH0gtO99g==}
+ '@types/unist@3.0.3':
+ resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
+
'@typescript-eslint/tsconfig-utils@8.53.0':
resolution: {integrity: sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1752,6 +1861,24 @@ packages:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
+ applesauce-content@4.0.0:
+ resolution: {integrity: sha512-2ZrhM/UCQkcZcAldXJX+KfWAPAtkoTXH5BwPhYpaMw0UgHjWX8mYiy/801PtLBr2gWkKd/Dw1obdNDcPUO3idw==}
+
+ applesauce-core@4.4.2:
+ resolution: {integrity: sha512-zuZB74Pp28UGM4e8DWbN1atR95xL7ODENvjkaGGnvAjIKvfdgMznU7m9gLxr/Hu+IHOmVbbd4YxwNmKBzCWhHQ==}
+
+ applesauce-core@5.2.0:
+ resolution: {integrity: sha512-aSuM6q6/Gs2FGUqytlHDjKZpSst2xKaT0vMXUQFWUctECNIxvwy6/hTDDInukMuI9mrQdjnO781ZJJgghI7RNw==}
+
+ applesauce-factory@4.0.0:
+ resolution: {integrity: sha512-Sqsg+bC7CkRXMxXLkO6YGoKxy/Aqtia9YenasS5qjPOQFmyFMwKRxaHCu6vX6KdpNSABusw0b9Tnn4gTh6CxLw==}
+
+ applesauce-relay@5.2.0:
+ resolution: {integrity: sha512-ty8PzHenocGdTr3x3It8Ql0rMD9rxB6VGCzGRfL5QF6epdstv2YHKuTyr8QdPBvf7yxfc7oZcMi6djSwNxXqkQ==}
+
+ applesauce-signers@5.2.0:
+ resolution: {integrity: sha512-7tN7lNK2XERdrRchG5z4rdpMqOacFdv7rRhiS+DLTdlbqeSf0wD6Kj8M3vSqq5f2pVS2cl5Z4E/m5RpWC4PSxg==}
+
aproba@2.1.0:
resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==}
@@ -1879,6 +2006,9 @@ packages:
backburner.js@2.8.0:
resolution: {integrity: sha512-zYXY0KvpD7/CWeOLF576mV8S+bQsaIoj/GNLXXB+Eb8SJcQy5lqSjkRrZ0MZhdKUs9QoqmGNIEIe3NQfGiiscQ==}
+ bail@2.0.2:
+ resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
+
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -2112,6 +2242,9 @@ packages:
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
+ character-entities@2.0.2:
+ resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
+
chardet@0.7.0:
resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==}
@@ -2544,6 +2677,9 @@ packages:
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
+ decode-named-character-reference@1.3.0:
+ resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
+
decorator-transforms@1.2.1:
resolution: {integrity: sha512-UUtmyfdlHvYoX3VSG1w5rbvBQ2r5TX1JsE4hmKU9snleFymadA3VACjl6SRfi9YgBCSjBbfQvR1bs9PRW9yBKw==}
@@ -2575,6 +2711,10 @@ packages:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
+ dequal@2.0.3:
+ resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
+ engines: {node: '>=6'}
+
destroy@1.2.0:
resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
@@ -2591,6 +2731,9 @@ packages:
resolution: {integrity: sha512-qE3Veg1YXzGHQhlA6jzebZN2qVf6NX+A7m7qlhCGG30dJixrAQhYOsJjsnBjJkCSmuOPpCk30145fr8FV0bzog==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+ devlop@1.1.0:
+ resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
+
diff@7.0.0:
resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==}
engines: {node: '>=0.3.1'}
@@ -2859,6 +3002,10 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
+ escape-string-regexp@5.0.0:
+ resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
+ engines: {node: '>=12'}
+
eslint-compat-utils@0.5.1:
resolution: {integrity: sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==}
engines: {node: '>=12'}
@@ -3022,6 +3169,9 @@ packages:
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
engines: {node: '>= 18'}
+ extend@3.0.2:
+ resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
+
external-editor@3.1.0:
resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==}
engines: {node: '>=4'}
@@ -3439,6 +3589,9 @@ packages:
hash-for-dep@1.5.1:
resolution: {integrity: sha512-/dQ/A2cl7FBPI2pO0CANkvuuVi/IFS5oTyJ0PsOb6jW6WbVW1js5qJXMJTNbWHXBIPdFTWFbabjB+mE0d+gelw==}
+ hash-sum@2.0.0:
+ resolution: {integrity: sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg==}
+
hashery@1.4.0:
resolution: {integrity: sha512-Wn2i1In6XFxl8Az55kkgnFRiAlIAushzh26PTjL2AKtQcEfXrcLa7Hn5QOWGZEf3LU057P9TwwZjFyxfS1VuvQ==}
engines: {node: '>=20'}
@@ -3808,6 +3961,9 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
+ light-bolt11-decoder@3.2.0:
+ resolution: {integrity: sha512-3QEofgiBOP4Ehs9BI+RkZdXZNtSys0nsJ6fyGeSiAGCBsMwHGUDS/JQlY/sTnWs91A2Nh0S9XXfA8Sy9g6QpuQ==}
+
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
@@ -3883,6 +4039,9 @@ packages:
resolution: {integrity: sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==}
engines: {node: '>=4'}
+ longest-streak@3.1.0:
+ resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
+
lower-case@2.0.2:
resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==}
@@ -3929,6 +4088,21 @@ packages:
mathml-tag-names@2.1.3:
resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==}
+ mdast-util-find-and-replace@3.0.2:
+ resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
+
+ mdast-util-from-markdown@2.0.3:
+ resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==}
+
+ mdast-util-phrasing@4.1.0:
+ resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
+
+ mdast-util-to-markdown@2.1.2:
+ resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==}
+
+ mdast-util-to-string@4.0.0:
+ resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
+
mdn-data@2.12.2:
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
@@ -3975,6 +4149,69 @@ packages:
resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==}
engines: {node: '>= 0.6'}
+ micromark-core-commonmark@2.0.3:
+ resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
+
+ micromark-factory-destination@2.0.1:
+ resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==}
+
+ micromark-factory-label@2.0.1:
+ resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==}
+
+ micromark-factory-space@2.0.1:
+ resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==}
+
+ micromark-factory-title@2.0.1:
+ resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==}
+
+ micromark-factory-whitespace@2.0.1:
+ resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==}
+
+ micromark-util-character@2.1.1:
+ resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==}
+
+ micromark-util-chunked@2.0.1:
+ resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==}
+
+ micromark-util-classify-character@2.0.1:
+ resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==}
+
+ micromark-util-combine-extensions@2.0.1:
+ resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==}
+
+ micromark-util-decode-numeric-character-reference@2.0.2:
+ resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==}
+
+ micromark-util-decode-string@2.0.1:
+ resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==}
+
+ micromark-util-encode@2.0.1:
+ resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==}
+
+ micromark-util-html-tag-name@2.0.1:
+ resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==}
+
+ micromark-util-normalize-identifier@2.0.1:
+ resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==}
+
+ micromark-util-resolve-all@2.0.1:
+ resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==}
+
+ micromark-util-sanitize-uri@2.0.1:
+ resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==}
+
+ micromark-util-subtokenize@2.1.0:
+ resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==}
+
+ micromark-util-symbol@2.0.1:
+ resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==}
+
+ micromark-util-types@2.0.2:
+ resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==}
+
+ micromark@4.0.2:
+ resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==}
+
micromatch@4.0.8:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
@@ -4084,6 +4321,11 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
+ nanoid@5.1.9:
+ resolution: {integrity: sha512-ZUvP7KeBLe3OZ1ypw6dI/TzYJuvHP77IM4Ry73waSQTLn8/g8rpdjfyVAh7t1/+FjBtG4lCP42MEbDxOsRpBMw==}
+ engines: {node: ^18 || >=20}
+ hasBin: true
+
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
@@ -4129,6 +4371,38 @@ packages:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
+ nostr-signer-capacitor-plugin@0.0.5:
+ resolution: {integrity: sha512-/EvqWz71HZ5cWmzvfXWTm48AWZtbeZDbOg3vLwXyXPjnIp1DR7Wurww/Mo41ORNu1DNPlqH20l7kIXKO6vR5og==}
+ peerDependencies:
+ '@capacitor/core': ^7.0.0
+
+ nostr-tools@2.17.4:
+ resolution: {integrity: sha512-LGqpKufnmR93tOjFi4JZv1BTTVIAVfZAaAa+1gMqVfI0wNz2DnCB6UDXmjVTRrjQHMw2ykbk0EZLPzV5UeCIJw==}
+ peerDependencies:
+ typescript: '>=5.0.0'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ nostr-tools@2.19.4:
+ resolution: {integrity: sha512-qVLfoTpZegNYRJo5j+Oi6RPu0AwLP6jcvzcB3ySMnIT5DrAGNXfs5HNBspB/2HiGfH3GY+v6yXkTtcKSBQZwSg==}
+ peerDependencies:
+ typescript: '>=5.0.0'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ nostr-tools@2.23.3:
+ resolution: {integrity: sha512-AALyt9k8xPdF4UV2mlLJ2mgCn4kpTB0DZ8t2r6wjdUh6anfx2cTVBsHUlo9U0EY/cKC5wcNyiMAmRJV5OVEalA==}
+ peerDependencies:
+ typescript: '>=5.0.0'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ nostr-wasm@0.1.0:
+ resolution: {integrity: sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==}
+
npm-package-arg@13.0.2:
resolution: {integrity: sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA==}
engines: {node: ^20.17.0 || >=22.9.0}
@@ -4564,6 +4838,15 @@ packages:
resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==}
hasBin: true
+ remark-parse@11.0.0:
+ resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
+
+ remark-stringify@11.0.0:
+ resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
+
+ remark@15.0.1:
+ resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==}
+
remotestorage-widget@1.8.1:
resolution: {integrity: sha512-HxNu2VvIRW3wzkf5fLEzs56ySQ7+YQbRqyp3CKvmw/G+zKhRsmj06HtFoAcm3B14/nJh2SOAv3LyfKuXfUsKPw==}
@@ -5164,6 +5447,9 @@ packages:
resolution: {integrity: sha512-OLWW+Nd99NOM53aZ8ilT/YpEiOo6mXD3F4/wLbARqybSZ3Jb8IxHK5UGVbZaae0wtXAyQshVV+SeqVBik+Fbmw==}
engines: {node: '>=8'}
+ trough@2.2.0:
+ resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==}
+
ts-declaration-location@1.0.7:
resolution: {integrity: sha512-EDyGAwH1gO0Ausm9gV6T2nUvBgXT5kGoCMJPllOaooZ+4VvJiKBdZE7wK18N1deEowhcUptS+5GXZK8U/fvpwA==}
peerDependencies:
@@ -5259,6 +5545,21 @@ packages:
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
engines: {node: '>=18'}
+ unified@11.0.5:
+ resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
+
+ unist-util-is@6.0.1:
+ resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
+
+ unist-util-stringify-position@4.0.0:
+ resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
+
+ unist-util-visit-parents@6.0.2:
+ resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==}
+
+ unist-util-visit@5.1.0:
+ resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==}
+
universalify@0.1.2:
resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==}
engines: {node: '>= 4.0.0'}
@@ -5306,6 +5607,12 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
+ vfile-message@4.0.3:
+ resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
+
+ vfile@6.0.3:
+ resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
+
vite@7.3.1:
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -5535,6 +5842,9 @@ packages:
zstddec@0.1.0:
resolution: {integrity: sha512-w2NTI8+3l3eeltKAdK8QpiLo/flRAr2p8AGeakfMZOXBxOg9HIu4LVDxBi81sYgVhFhdJjv1OrB5ssI8uFPoLg==}
+ zwitch@2.0.4:
+ resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
+
snapshots:
'@asamuzakjp/css-color@3.2.0':
@@ -6301,6 +6611,17 @@ snapshots:
hashery: 1.4.0
keyv: 5.5.5
+ '@capacitor/core@7.6.2':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@cashu/cashu-ts@2.9.0':
+ dependencies:
+ '@noble/curves': 2.2.0
+ '@noble/hashes': 2.2.0
+ '@scure/bip32': 1.7.0
+
'@cnakazawa/watch@1.0.4':
dependencies:
exec-sh: 0.3.6
@@ -6986,6 +7307,42 @@ snapshots:
dependencies:
eslint-scope: 5.1.1
+ '@noble/ciphers@0.5.3': {}
+
+ '@noble/ciphers@2.1.1': {}
+
+ '@noble/curves@1.1.0':
+ dependencies:
+ '@noble/hashes': 1.3.1
+
+ '@noble/curves@1.2.0':
+ dependencies:
+ '@noble/hashes': 1.3.2
+
+ '@noble/curves@1.9.7':
+ dependencies:
+ '@noble/hashes': 1.8.0
+
+ '@noble/curves@2.0.1':
+ dependencies:
+ '@noble/hashes': 2.0.1
+
+ '@noble/curves@2.2.0':
+ dependencies:
+ '@noble/hashes': 2.2.0
+
+ '@noble/hashes@1.3.1': {}
+
+ '@noble/hashes@1.3.2': {}
+
+ '@noble/hashes@1.8.0': {}
+
+ '@noble/hashes@2.0.1': {}
+
+ '@noble/hashes@2.2.0': {}
+
+ '@noble/secp256k1@1.7.2': {}
+
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -7112,6 +7469,40 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.55.1':
optional: true
+ '@scure/base@1.1.1': {}
+
+ '@scure/base@1.2.6': {}
+
+ '@scure/base@2.0.0': {}
+
+ '@scure/bip32@1.3.1':
+ dependencies:
+ '@noble/curves': 1.1.0
+ '@noble/hashes': 1.3.1
+ '@scure/base': 1.1.1
+
+ '@scure/bip32@1.7.0':
+ dependencies:
+ '@noble/curves': 1.9.7
+ '@noble/hashes': 1.8.0
+ '@scure/base': 1.2.6
+
+ '@scure/bip32@2.0.1':
+ dependencies:
+ '@noble/curves': 2.0.1
+ '@noble/hashes': 2.0.1
+ '@scure/base': 2.0.0
+
+ '@scure/bip39@1.2.1':
+ dependencies:
+ '@noble/hashes': 1.3.1
+ '@scure/base': 1.1.1
+
+ '@scure/bip39@2.0.1':
+ dependencies:
+ '@noble/hashes': 2.0.1
+ '@scure/base': 2.0.0
+
'@sec-ant/readable-stream@0.4.1': {}
'@simple-dom/document@1.4.0':
@@ -7141,6 +7532,10 @@ snapshots:
dependencies:
'@types/node': 25.0.7
+ '@types/debug@4.1.13':
+ dependencies:
+ '@types/ms': 2.1.0
+
'@types/eslint@8.56.12':
dependencies:
'@types/estree': 1.0.8
@@ -7160,12 +7555,22 @@ snapshots:
dependencies:
glob: 13.0.0
+ '@types/hast@3.0.4':
+ dependencies:
+ '@types/unist': 3.0.3
+
'@types/json-schema@7.0.15': {}
+ '@types/mdast@4.0.4':
+ dependencies:
+ '@types/unist': 3.0.3
+
'@types/minimatch@3.0.5': {}
'@types/minimatch@5.1.2': {}
+ '@types/ms@2.1.0': {}
+
'@types/node@20.14.0':
dependencies:
undici-types: 5.26.5
@@ -7185,6 +7590,8 @@ snapshots:
'@types/tv4@1.2.33': {}
+ '@types/unist@3.0.3': {}
+
'@typescript-eslint/tsconfig-utils@8.53.0(typescript@5.9.3)':
dependencies:
typescript: 5.9.3
@@ -7327,6 +7734,85 @@ snapshots:
normalize-path: 3.0.0
picomatch: 2.3.1
+ applesauce-content@4.0.0(typescript@5.9.3):
+ dependencies:
+ '@cashu/cashu-ts': 2.9.0
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ applesauce-core: 4.4.2(typescript@5.9.3)
+ mdast-util-find-and-replace: 3.0.2
+ nostr-tools: 2.17.4(typescript@5.9.3)
+ remark: 15.0.1
+ remark-parse: 11.0.0
+ unified: 11.0.5
+ unist-util-visit-parents: 6.0.2
+ transitivePeerDependencies:
+ - supports-color
+ - typescript
+
+ applesauce-core@4.4.2(typescript@5.9.3):
+ dependencies:
+ '@noble/hashes': 1.8.0
+ '@scure/base': 1.2.6
+ debug: 4.4.3
+ fast-deep-equal: 3.1.3
+ hash-sum: 2.0.0
+ light-bolt11-decoder: 3.2.0
+ nanoid: 5.1.9
+ nostr-tools: 2.17.4(typescript@5.9.3)
+ rxjs: 7.8.2
+ transitivePeerDependencies:
+ - supports-color
+ - typescript
+
+ applesauce-core@5.2.0(typescript@5.9.3):
+ dependencies:
+ debug: 4.4.3
+ fast-deep-equal: 3.1.3
+ hash-sum: 2.0.0
+ nanoid: 5.1.9
+ nostr-tools: 2.19.4(typescript@5.9.3)
+ rxjs: 7.8.2
+ transitivePeerDependencies:
+ - supports-color
+ - typescript
+
+ applesauce-factory@4.0.0(typescript@5.9.3):
+ dependencies:
+ applesauce-content: 4.0.0(typescript@5.9.3)
+ applesauce-core: 4.4.2(typescript@5.9.3)
+ nanoid: 5.1.9
+ nostr-tools: 2.23.3(typescript@5.9.3)
+ transitivePeerDependencies:
+ - supports-color
+ - typescript
+
+ applesauce-relay@5.2.0(typescript@5.9.3):
+ dependencies:
+ '@noble/hashes': 1.8.0
+ applesauce-core: 5.2.0(typescript@5.9.3)
+ nanoid: 5.1.9
+ nostr-tools: 2.19.4(typescript@5.9.3)
+ rxjs: 7.8.2
+ transitivePeerDependencies:
+ - supports-color
+ - typescript
+
+ applesauce-signers@5.2.0(@capacitor/core@7.6.2)(typescript@5.9.3):
+ dependencies:
+ '@noble/secp256k1': 1.7.2
+ applesauce-core: 5.2.0(typescript@5.9.3)
+ debug: 4.4.3
+ nanoid: 5.1.9
+ rxjs: 7.8.2
+ optionalDependencies:
+ nostr-signer-capacitor-plugin: 0.0.5(@capacitor/core@7.6.2)
+ transitivePeerDependencies:
+ - '@capacitor/core'
+ - supports-color
+ - typescript
+
aproba@2.1.0: {}
are-we-there-yet@3.0.1:
@@ -7484,6 +7970,8 @@ snapshots:
backburner.js@2.8.0: {}
+ bail@2.0.2: {}
+
balanced-match@1.0.2: {}
balanced-match@2.0.0: {}
@@ -7910,6 +8398,8 @@ snapshots:
chalk@5.6.2: {}
+ character-entities@2.0.2: {}
+
chardet@0.7.0: {}
chardet@2.1.1: {}
@@ -8152,6 +8642,10 @@ snapshots:
decimal.js@10.6.0: {}
+ decode-named-character-reference@1.3.0:
+ dependencies:
+ character-entities: 2.0.2
+
decorator-transforms@1.2.1(@babel/core@7.28.6):
dependencies:
'@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.28.6)
@@ -8186,6 +8680,8 @@ snapshots:
depd@2.0.0: {}
+ dequal@2.0.3: {}
+
destroy@1.2.0: {}
detect-file@1.0.0: {}
@@ -8194,6 +8690,10 @@ snapshots:
detect-newline@4.0.1: {}
+ devlop@1.1.0:
+ dependencies:
+ dequal: 2.0.3
+
diff@7.0.0: {}
diff@8.0.3: {}
@@ -8762,6 +9262,8 @@ snapshots:
escape-string-regexp@4.0.0: {}
+ escape-string-regexp@5.0.0: {}
+
eslint-compat-utils@0.5.1(eslint@9.39.2):
dependencies:
eslint: 9.39.2
@@ -9040,6 +9542,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ extend@3.0.2: {}
+
external-editor@3.1.0:
dependencies:
chardet: 0.7.0
@@ -9583,6 +10087,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ hash-sum@2.0.0: {}
+
hashery@1.4.0:
dependencies:
hookified: 1.15.0
@@ -9958,6 +10464,10 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
+ light-bolt11-decoder@3.2.0:
+ dependencies:
+ '@scure/base': 1.1.1
+
lines-and-columns@1.2.4: {}
linkify-it@5.0.0:
@@ -10026,6 +10536,8 @@ snapshots:
dependencies:
chalk: 2.4.2
+ longest-streak@3.1.0: {}
+
lower-case@2.0.2:
dependencies:
tslib: 2.8.1
@@ -10078,6 +10590,51 @@ snapshots:
mathml-tag-names@2.1.3: {}
+ mdast-util-find-and-replace@3.0.2:
+ dependencies:
+ '@types/mdast': 4.0.4
+ escape-string-regexp: 5.0.0
+ unist-util-is: 6.0.1
+ unist-util-visit-parents: 6.0.2
+
+ mdast-util-from-markdown@2.0.3:
+ dependencies:
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ decode-named-character-reference: 1.3.0
+ devlop: 1.1.0
+ mdast-util-to-string: 4.0.0
+ micromark: 4.0.2
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-decode-string: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+ unist-util-stringify-position: 4.0.0
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-phrasing@4.1.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ unist-util-is: 6.0.1
+
+ mdast-util-to-markdown@2.1.2:
+ dependencies:
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ longest-streak: 3.1.0
+ mdast-util-phrasing: 4.1.0
+ mdast-util-to-string: 4.0.0
+ micromark-util-classify-character: 2.0.1
+ micromark-util-decode-string: 2.0.1
+ unist-util-visit: 5.1.0
+ zwitch: 2.0.4
+
+ mdast-util-to-string@4.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+
mdn-data@2.12.2: {}
mdurl@2.0.0: {}
@@ -10114,6 +10671,139 @@ snapshots:
methods@1.1.2: {}
+ micromark-core-commonmark@2.0.3:
+ dependencies:
+ decode-named-character-reference: 1.3.0
+ devlop: 1.1.0
+ micromark-factory-destination: 2.0.1
+ micromark-factory-label: 2.0.1
+ micromark-factory-space: 2.0.1
+ micromark-factory-title: 2.0.1
+ micromark-factory-whitespace: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-chunked: 2.0.1
+ micromark-util-classify-character: 2.0.1
+ micromark-util-html-tag-name: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-resolve-all: 2.0.1
+ micromark-util-subtokenize: 2.1.0
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-destination@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-label@2.0.1:
+ dependencies:
+ devlop: 1.1.0
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-space@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-title@2.0.1:
+ dependencies:
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-whitespace@2.0.1:
+ dependencies:
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-character@2.1.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-chunked@2.0.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-classify-character@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-combine-extensions@2.0.1:
+ dependencies:
+ micromark-util-chunked: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-decode-numeric-character-reference@2.0.2:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-decode-string@2.0.1:
+ dependencies:
+ decode-named-character-reference: 1.3.0
+ micromark-util-character: 2.1.1
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-encode@2.0.1: {}
+
+ micromark-util-html-tag-name@2.0.1: {}
+
+ micromark-util-normalize-identifier@2.0.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-resolve-all@2.0.1:
+ dependencies:
+ micromark-util-types: 2.0.2
+
+ micromark-util-sanitize-uri@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-encode: 2.0.1
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-subtokenize@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-util-chunked: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-symbol@2.0.1: {}
+
+ micromark-util-types@2.0.2: {}
+
+ micromark@4.0.2:
+ dependencies:
+ '@types/debug': 4.1.13
+ debug: 4.4.3
+ decode-named-character-reference: 1.3.0
+ devlop: 1.1.0
+ micromark-core-commonmark: 2.0.3
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-chunked: 2.0.1
+ micromark-util-combine-extensions: 2.0.1
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-encode: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-resolve-all: 2.0.1
+ micromark-util-sanitize-uri: 2.0.1
+ micromark-util-subtokenize: 2.1.0
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+ transitivePeerDependencies:
+ - supports-color
+
micromatch@4.0.8:
dependencies:
braces: 3.0.3
@@ -10200,6 +10890,8 @@ snapshots:
nanoid@3.3.11: {}
+ nanoid@5.1.9: {}
+
natural-compare@1.4.0: {}
negotiator@0.6.3: {}
@@ -10238,6 +10930,49 @@ snapshots:
normalize-path@3.0.0: {}
+ nostr-signer-capacitor-plugin@0.0.5(@capacitor/core@7.6.2):
+ dependencies:
+ '@capacitor/core': 7.6.2
+ optional: true
+
+ nostr-tools@2.17.4(typescript@5.9.3):
+ dependencies:
+ '@noble/ciphers': 0.5.3
+ '@noble/curves': 1.2.0
+ '@noble/hashes': 1.3.1
+ '@scure/base': 1.1.1
+ '@scure/bip32': 1.3.1
+ '@scure/bip39': 1.2.1
+ nostr-wasm: 0.1.0
+ optionalDependencies:
+ typescript: 5.9.3
+
+ nostr-tools@2.19.4(typescript@5.9.3):
+ dependencies:
+ '@noble/ciphers': 0.5.3
+ '@noble/curves': 1.2.0
+ '@noble/hashes': 1.3.1
+ '@scure/base': 1.1.1
+ '@scure/bip32': 1.3.1
+ '@scure/bip39': 1.2.1
+ nostr-wasm: 0.1.0
+ optionalDependencies:
+ typescript: 5.9.3
+
+ nostr-tools@2.23.3(typescript@5.9.3):
+ dependencies:
+ '@noble/ciphers': 2.1.1
+ '@noble/curves': 2.0.1
+ '@noble/hashes': 2.0.1
+ '@scure/base': 2.0.0
+ '@scure/bip32': 2.0.1
+ '@scure/bip39': 2.0.1
+ nostr-wasm: 0.1.0
+ optionalDependencies:
+ typescript: 5.9.3
+
+ nostr-wasm@0.1.0: {}
+
npm-package-arg@13.0.2:
dependencies:
hosted-git-info: 9.0.2
@@ -10648,6 +11383,30 @@ snapshots:
dependencies:
jsesc: 3.1.0
+ remark-parse@11.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-from-markdown: 2.0.3
+ micromark-util-types: 2.0.2
+ unified: 11.0.5
+ transitivePeerDependencies:
+ - supports-color
+
+ remark-stringify@11.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-to-markdown: 2.1.2
+ unified: 11.0.5
+
+ remark@15.0.1:
+ dependencies:
+ '@types/mdast': 4.0.4
+ remark-parse: 11.0.0
+ remark-stringify: 11.0.0
+ unified: 11.0.5
+ transitivePeerDependencies:
+ - supports-color
+
remotestorage-widget@1.8.1: {}
remotestoragejs@2.0.0-beta.8:
@@ -11480,6 +12239,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ trough@2.2.0: {}
+
ts-declaration-location@1.0.7(typescript@5.9.3):
dependencies:
picomatch: 4.0.3
@@ -11549,6 +12310,35 @@ snapshots:
unicorn-magic@0.3.0: {}
+ unified@11.0.5:
+ dependencies:
+ '@types/unist': 3.0.3
+ bail: 2.0.2
+ devlop: 1.1.0
+ extend: 3.0.2
+ is-plain-obj: 4.1.0
+ trough: 2.2.0
+ vfile: 6.0.3
+
+ unist-util-is@6.0.1:
+ dependencies:
+ '@types/unist': 3.0.3
+
+ unist-util-stringify-position@4.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+
+ unist-util-visit-parents@6.0.2:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-is: 6.0.1
+
+ unist-util-visit@5.1.0:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-is: 6.0.1
+ unist-util-visit-parents: 6.0.2
+
universalify@0.1.2: {}
universalify@2.0.1: {}
@@ -11579,6 +12369,16 @@ snapshots:
vary@1.1.2: {}
+ vfile-message@4.0.3:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-stringify-position: 4.0.0
+
+ vfile@6.0.3:
+ dependencies:
+ '@types/unist': 3.0.3
+ vfile-message: 4.0.3
+
vite@7.3.1(@types/node@25.0.7)(terser@5.44.1):
dependencies:
esbuild: 0.27.2
@@ -11766,3 +12566,5 @@ snapshots:
yoctocolors@2.1.2: {}
zstddec@0.1.0: {}
+
+ zwitch@2.0.4: {}
From 4bd5c4bf2aee807ad9564e2cbe271b2aac0dfa17 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Sun, 19 Apr 2026 11:08:55 +0400
Subject: [PATCH 06/56] Load signer on launch, disconnect or switch pubkey if
necessary
---
app/services/nostr-auth.js | 44 +++++++++++++++++++++++++++-----------
1 file changed, 31 insertions(+), 13 deletions(-)
diff --git a/app/services/nostr-auth.js b/app/services/nostr-auth.js
index 8963aa0..a90e971 100644
--- a/app/services/nostr-auth.js
+++ b/app/services/nostr-auth.js
@@ -6,13 +6,33 @@ const STORAGE_KEY = 'marco:nostr_pubkey';
export default class NostrAuthService extends Service {
@tracked pubkey = null;
- signer = null;
constructor() {
super(...arguments);
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
this.pubkey = saved;
+ this._verifyPubkey();
+ }
+ }
+
+ async _verifyPubkey() {
+ if (typeof window.nostr === 'undefined') {
+ this.logout();
+ return;
+ }
+
+ try {
+ const signer = new ExtensionSigner();
+ const extensionPubkey = await signer.getPublicKey();
+
+ if (extensionPubkey !== this.pubkey) {
+ this.pubkey = extensionPubkey;
+ localStorage.setItem(STORAGE_KEY, this.pubkey);
+ }
+ } catch (e) {
+ console.warn('Failed to verify nostr pubkey, logging out', e);
+ this.logout();
}
}
@@ -20,15 +40,18 @@ export default class NostrAuthService extends Service {
return !!this.pubkey;
}
+ get signer() {
+ if (typeof window.nostr !== 'undefined') {
+ return new ExtensionSigner();
+ }
+ return null;
+ }
+
async login() {
if (typeof window.nostr === 'undefined') {
throw new Error('No NIP-07 Nostr extension found (e.g., Alby, nos2x).');
}
- if (!this.signer) {
- this.signer = new ExtensionSigner();
- }
-
try {
this.pubkey = await this.signer.getPublicKey();
localStorage.setItem(STORAGE_KEY, this.pubkey);
@@ -41,20 +64,15 @@ export default class NostrAuthService extends Service {
async signEvent(event) {
if (!this.signer) {
- if (this.pubkey && typeof window.nostr !== 'undefined') {
- this.signer = new ExtensionSigner();
- } else {
- throw new Error(
- 'Not connected or extension missing. Please connect Nostr again.'
- );
- }
+ throw new Error(
+ 'Not connected or extension missing. Please connect Nostr again.'
+ );
}
return await this.signer.signEvent(event);
}
logout() {
this.pubkey = null;
- this.signer = null;
localStorage.removeItem(STORAGE_KEY);
}
}
From b9f64f30e1b80cb89c4b8b3b9d8ddca048b7495b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Sun, 19 Apr 2026 11:09:23 +0400
Subject: [PATCH 07/56] Cut off overlong account status lines with ellipsis
---
app/styles/app.css | 3 +++
1 file changed, 3 insertions(+)
diff --git a/app/styles/app.css b/app/styles/app.css
index a6ac5da..5786135 100644
--- a/app/styles/app.css
+++ b/app/styles/app.css
@@ -252,6 +252,9 @@ body {
color: #898989;
margin-top: 0.35rem;
margin-left: calc(18px + 0.75rem);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
.account-status strong {
From 03583e5a52e9b4c89c24f8618595c14438c08730 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Sun, 19 Apr 2026 11:09:41 +0400
Subject: [PATCH 08/56] Add generic modal component, refactor photo upload
modal (WIP)
---
app/components/modal.gjs | 43 +++++++++++++
app/components/place-details.gjs | 33 ++++++++++
app/components/place-photo-upload.gjs | 87 ++++++++++++---------------
app/styles/app.css | 69 +++++++++++++++++++++
4 files changed, 183 insertions(+), 49 deletions(-)
create mode 100644 app/components/modal.gjs
diff --git a/app/components/modal.gjs b/app/components/modal.gjs
new file mode 100644
index 0000000..e9751e3
--- /dev/null
+++ b/app/components/modal.gjs
@@ -0,0 +1,43 @@
+import Component from '@glimmer/component';
+import { action } from '@ember/object';
+import { on } from '@ember/modifier';
+import Icon from './icon';
+
+export default class Modal extends Component {
+ @action
+ stopProp(e) {
+ e.stopPropagation();
+ }
+
+ @action
+ close() {
+ if (this.args.onClose) {
+ this.args.onClose();
+ }
+ }
+
+
+
+
+}
diff --git a/app/components/place-details.gjs b/app/components/place-details.gjs
index 0377e2c..a665da0 100644
--- a/app/components/place-details.gjs
+++ b/app/components/place-details.gjs
@@ -9,6 +9,8 @@ import { getSocialInfo } from '../utils/social-links';
import Icon from '../components/icon';
import PlaceEditForm from './place-edit-form';
import PlaceListsManager from './place-lists-manager';
+import PlacePhotoUpload from './place-photo-upload';
+import Modal from './modal';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
@@ -17,6 +19,20 @@ export default class PlaceDetails extends Component {
@service storage;
@tracked isEditing = false;
@tracked showLists = false;
+ @tracked isPhotoUploadModalOpen = false;
+
+ @action
+ openPhotoUploadModal(e) {
+ if (e) {
+ e.preventDefault();
+ }
+ this.isPhotoUploadModalOpen = true;
+ }
+
+ @action
+ closePhotoUploadModal() {
+ this.isPhotoUploadModalOpen = false;
+ }
get isSaved() {
return this.storage.isPlaceSaved(this.place.id || this.place.osmId);
@@ -499,7 +515,24 @@ export default class PlaceDetails extends Component {
{{/if}}
+ {{#if this.osmUrl}}
+
+
+
+
+ Add a photo
+
+
+
+ {{/if}}
+
+
+ {{#if this.isPhotoUploadModalOpen}}
+
+
+
+ {{/if}}
}
diff --git a/app/components/place-photo-upload.gjs b/app/components/place-photo-upload.gjs
index 86d0343..d65337a 100644
--- a/app/components/place-photo-upload.gjs
+++ b/app/components/place-photo-upload.gjs
@@ -4,17 +4,24 @@ import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { on } from '@ember/modifier';
import { EventFactory } from 'applesauce-core';
+import Geohash from 'latlon-geohash';
export default class PlacePhotoUpload extends Component {
@service nostrAuth;
@service nostrRelay;
@tracked photoUrl = '';
- @tracked osmId = '';
- @tracked geohash = '';
@tracked status = '';
@tracked error = '';
+ get place() {
+ return this.args.place || {};
+ }
+
+ get title() {
+ return this.place.title || 'this place';
+ }
+
@action
async login() {
try {
@@ -50,8 +57,16 @@ export default class PlacePhotoUpload extends Component {
return;
}
- if (!this.photoUrl || !this.osmId || !this.geohash) {
- this.error = 'Please provide an OSM ID, Geohash, and upload a photo.';
+ if (!this.photoUrl) {
+ this.error = 'Please upload a photo.';
+ return;
+ }
+
+ const { osmId, lat, lon } = this.place;
+ const osmType = this.place.osmType || 'node';
+
+ if (!osmId) {
+ this.error = 'This place does not have a valid OSM ID.';
return;
}
@@ -61,21 +76,28 @@ export default class PlacePhotoUpload extends Component {
try {
const factory = new EventFactory({ signer: this.nostrAuth.signer });
+ const tags = [['i', `osm:${osmType}:${osmId}`]];
+
+ if (lat && lon) {
+ tags.push(['g', Geohash.encode(lat, lon, 4)]);
+ tags.push(['g', Geohash.encode(lat, lon, 6)]);
+ tags.push(['g', Geohash.encode(lat, lon, 7)]);
+ tags.push(['g', Geohash.encode(lat, lon, 9)]);
+ }
+
+ tags.push([
+ 'imeta',
+ `url ${this.photoUrl}`,
+ 'm image/jpeg',
+ 'dim 600x400',
+ 'alt A photo of a place',
+ ]);
+
// NIP-XX draft Place Photo event
const template = {
kind: 360,
content: '',
- tags: [
- ['i', `osm:node:${this.osmId}`],
- ['g', this.geohash],
- [
- 'imeta',
- `url ${this.photoUrl}`,
- 'm image/jpeg',
- 'dim 600x400',
- 'alt A photo of a place',
- ],
- ],
+ tags,
};
// Ensure created_at is present before signing
@@ -89,24 +111,15 @@ export default class PlacePhotoUpload extends Component {
this.status = 'Published successfully!';
// Reset form
this.photoUrl = '';
- this.osmId = '';
- this.geohash = '';
} catch (e) {
this.error = 'Failed to publish: ' + e.message;
this.status = '';
}
}
- @action updateOsmId(e) {
- this.osmId = e.target.value;
- }
- @action updateGeohash(e) {
- this.geohash = e.target.value;
- }
-
-
Add Place Photo
+
Add Photo for {{this.title}}
{{#if this.error}}
@@ -127,30 +140,6 @@ export default class PlacePhotoUpload extends Component {
{{#if this.isPhotoUploadModalOpen}}
diff --git a/app/components/user-menu.gjs b/app/components/user-menu.gjs
index c1bac0f..84b9f85 100644
--- a/app/components/user-menu.gjs
+++ b/app/components/user-menu.gjs
@@ -3,13 +3,17 @@ import { action } from '@ember/object';
import { service } from '@ember/service';
import Icon from '#components/icon';
import { on } from '@ember/modifier';
+import { tracked } from '@glimmer/tracking';
+import { eq } from 'ember-truth-helpers';
+import Modal from './modal';
export default class UserMenuComponent extends Component {
@service storage;
@service osmAuth;
-
@service nostrAuth;
+ @tracked isNostrConnectModalOpen = false;
+
@action
connectRS() {
this.args.onClose();
@@ -33,9 +37,31 @@ export default class UserMenuComponent extends Component {
}
@action
- async connectNostr() {
+ openNostrConnectModal() {
+ this.isNostrConnectModalOpen = true;
+ }
+
+ @action
+ closeNostrConnectModal() {
+ this.isNostrConnectModalOpen = false;
+ }
+
+ @action
+ async connectNostrExtension() {
try {
- await this.nostrAuth.login();
+ await this.nostrAuth.login('extension');
+ this.closeNostrConnectModal();
+ } catch (e) {
+ console.error(e);
+ alert(e.message);
+ }
+ }
+
+ @action
+ async connectNostrApp() {
+ try {
+ await this.nostrAuth.login('connect');
+ this.closeNostrConnectModal();
} catch (e) {
console.error(e);
alert(e.message);
@@ -47,6 +73,10 @@ export default class UserMenuComponent extends Component {
this.nostrAuth.logout();
}
+ get hasExtension() {
+ return typeof window !== 'undefined' && typeof window.nostr !== 'undefined';
+ }
+
@@ -140,5 +170,49 @@ export default class UserMenuComponent extends Component {
+
+ {{#if this.isNostrConnectModalOpen}}
+
+
+
Connect with Nostr
+
+
+ {{#if this.hasExtension}}
+
+ Browser Extension (nos2x, Alby)
+
+ {{else}}
+
+ Browser Extension (Not Found)
+
+ {{/if}}
+
+
+ Mobile Signer App (Amber, etc.)
+
+
+
+ {{#if (eq this.nostrAuth.connectStatus "waiting")}}
+
+
Waiting for you to approve the connection in your mobile signer
+ app...
+
+ {{/if}}
+
+
+ {{/if}}
}
diff --git a/app/services/nostr-auth.js b/app/services/nostr-auth.js
index a90e971..5c10564 100644
--- a/app/services/nostr-auth.js
+++ b/app/services/nostr-auth.js
@@ -1,64 +1,224 @@
-import Service from '@ember/service';
+import Service, { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
-import { ExtensionSigner } from 'applesauce-signers';
+import {
+ ExtensionSigner,
+ NostrConnectSigner,
+ PrivateKeySigner,
+} from 'applesauce-signers';
const STORAGE_KEY = 'marco:nostr_pubkey';
+const STORAGE_KEY_TYPE = 'marco:nostr_signer_type'; // 'extension' | 'connect'
+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';
export default class NostrAuthService extends Service {
+ @service nostrRelay;
+
@tracked pubkey = null;
+ @tracked signerType = null; // 'extension' or 'connect'
+
+ // Track NostrConnect state for the UI
+ @tracked connectStatus = null; // null | 'waiting' | 'connected'
+ @tracked connectUri = null; // For displaying a QR code if needed
+
+ _signerInstance = null;
constructor() {
super(...arguments);
const saved = localStorage.getItem(STORAGE_KEY);
+ const type = localStorage.getItem(STORAGE_KEY_TYPE);
if (saved) {
this.pubkey = saved;
+ this.signerType = type || 'extension';
this._verifyPubkey();
}
}
async _verifyPubkey() {
- if (typeof window.nostr === 'undefined') {
- this.logout();
- return;
- }
-
- try {
- const signer = new ExtensionSigner();
- const extensionPubkey = await signer.getPublicKey();
-
- if (extensionPubkey !== this.pubkey) {
- this.pubkey = extensionPubkey;
- localStorage.setItem(STORAGE_KEY, this.pubkey);
+ if (this.signerType === 'extension') {
+ if (typeof window.nostr === 'undefined') {
+ this.logout();
+ return;
+ }
+ try {
+ const signer = new ExtensionSigner();
+ const extensionPubkey = await signer.getPublicKey();
+ if (extensionPubkey !== this.pubkey) {
+ this.pubkey = extensionPubkey;
+ localStorage.setItem(STORAGE_KEY, this.pubkey);
+ }
+ } catch (e) {
+ console.warn('Failed to verify extension nostr pubkey, logging out', e);
+ this.logout();
+ }
+ } else if (this.signerType === 'connect') {
+ try {
+ await this._initConnectSigner();
+ } catch (e) {
+ console.warn('Failed to verify connect nostr pubkey, logging out', e);
+ this.logout();
}
- } catch (e) {
- console.warn('Failed to verify nostr pubkey, logging out', e);
- this.logout();
}
}
get isConnected() {
- return !!this.pubkey;
+ return (
+ !!this.pubkey &&
+ (this.signerType === 'extension'
+ ? typeof window.nostr !== 'undefined'
+ : true)
+ );
}
get signer() {
- if (typeof window.nostr !== 'undefined') {
+ if (this._signerInstance) return this._signerInstance;
+
+ if (
+ this.signerType === 'extension' &&
+ typeof window.nostr !== 'undefined'
+ ) {
return new ExtensionSigner();
}
+
+ if (this.signerType === 'connect') {
+ // Must be initialized async due to the connect handshakes
+ return null;
+ }
+
return null;
}
- async login() {
- if (typeof window.nostr === 'undefined') {
- throw new Error('No NIP-07 Nostr extension found (e.g., Alby, nos2x).');
+ async login(type = 'extension') {
+ if (type === 'extension') {
+ if (typeof window.nostr === 'undefined') {
+ throw new Error('No NIP-07 Nostr extension found (e.g., Alby, nos2x).');
+ }
+
+ try {
+ this._signerInstance = new ExtensionSigner();
+ this.pubkey = await this._signerInstance.getPublicKey();
+ this.signerType = 'extension';
+ localStorage.setItem(STORAGE_KEY, this.pubkey);
+ localStorage.setItem(STORAGE_KEY_TYPE, 'extension');
+ return this.pubkey;
+ } catch (error) {
+ console.error('Failed to get public key from extension:', error);
+ throw error;
+ }
+ } else if (type === 'connect') {
+ this.connectStatus = 'waiting';
+
+ try {
+ // Generate or retrieve a local ephemeral keypair
+ let localKeyHex = localStorage.getItem(STORAGE_KEY_CONNECT_LOCAL_KEY);
+ let localSigner;
+ if (localKeyHex) {
+ localSigner = PrivateKeySigner.fromKey(localKeyHex);
+ } else {
+ localSigner = new PrivateKeySigner();
+ // Store the raw Uint8Array as hex string
+ localKeyHex = Array.from(localSigner.key)
+ .map((b) => b.toString(16).padStart(2, '0'))
+ .join('');
+ localStorage.setItem(STORAGE_KEY_CONNECT_LOCAL_KEY, localKeyHex);
+ }
+
+ // We use a specific relay for the connection handshake.
+ const relay = 'wss://relay.damus.io';
+ localStorage.setItem(STORAGE_KEY_CONNECT_RELAY, relay);
+
+ this._signerInstance = new NostrConnectSigner({
+ pool: this.nostrRelay.pool,
+ relays: [relay],
+ signer: localSigner,
+ onAuth: async (url) => {
+ // NIP-46 auth callback. Normally the signer app does this natively via notification.
+ // But if it requires an explicit browser window:
+ if (
+ confirm(
+ `Your signer app requests authentication via a web page. Open it now?\n\nURL: ${url}`
+ )
+ ) {
+ window.open(url, '_blank');
+ }
+ },
+ });
+
+ // Set the uri for display (e.g., to redirect via intent)
+ this.connectUri = this._signerInstance.getNostrConnectURI({
+ name: 'Marco',
+ url: window.location.origin,
+ description: 'A privacy-respecting maps application.',
+ icons: [],
+ });
+
+ // Trigger the deep link intent immediately for the user
+ window.location.href = this.connectUri;
+
+ // Start listening to the relay
+ await this._signerInstance.open();
+
+ // Wait for the remote signer to reply with their pubkey
+ await this._signerInstance.waitForSigner();
+
+ // Once connected, get the actual user pubkey
+ this.pubkey = await this._signerInstance.getPublicKey();
+ this.signerType = 'connect';
+ this.connectStatus = 'connected';
+
+ // Save connection state
+ localStorage.setItem(STORAGE_KEY, this.pubkey);
+ localStorage.setItem(STORAGE_KEY_TYPE, 'connect');
+ localStorage.setItem(
+ STORAGE_KEY_CONNECT_REMOTE_PUBKEY,
+ this._signerInstance.remote
+ );
+
+ return this.pubkey;
+ } catch (error) {
+ this.connectStatus = null;
+ console.error('Failed to connect via Nostr Connect:', error);
+ throw error;
+ }
+ }
+ }
+
+ async _initConnectSigner() {
+ const localKeyHex = localStorage.getItem(STORAGE_KEY_CONNECT_LOCAL_KEY);
+ const remotePubkey = localStorage.getItem(
+ STORAGE_KEY_CONNECT_REMOTE_PUBKEY
+ );
+ const relay =
+ localStorage.getItem(STORAGE_KEY_CONNECT_RELAY) || 'wss://relay.damus.io';
+
+ if (!localKeyHex || !remotePubkey) {
+ throw new Error('Missing Nostr Connect local state.');
}
- try {
- this.pubkey = await this.signer.getPublicKey();
- localStorage.setItem(STORAGE_KEY, this.pubkey);
- return this.pubkey;
- } catch (error) {
- console.error('Failed to get public key from extension:', error);
- throw error;
+ const localSigner = PrivateKeySigner.fromKey(localKeyHex);
+
+ this._signerInstance = new NostrConnectSigner({
+ pool: this.nostrRelay.pool,
+ relays: [relay],
+ signer: localSigner,
+ remote: remotePubkey,
+ onAuth: async (url) => {
+ if (
+ confirm(
+ `Your signer app requests authentication via a web page. Open it now?\n\nURL: ${url}`
+ )
+ ) {
+ window.open(url, '_blank');
+ }
+ },
+ });
+
+ await this._signerInstance.open();
+ // Validate we can still get the pubkey from the remote signer
+ const pubkey = await this._signerInstance.getPublicKey();
+ if (pubkey !== this.pubkey) {
+ throw new Error('Remote signer pubkey mismatch');
}
}
@@ -71,8 +231,23 @@ export default class NostrAuthService extends Service {
return await this.signer.signEvent(event);
}
- logout() {
+ async logout() {
this.pubkey = null;
+ this.signerType = null;
+ this.connectStatus = null;
+ this.connectUri = null;
+ if (
+ this._signerInstance &&
+ typeof this._signerInstance.close === 'function'
+ ) {
+ await this._signerInstance.close();
+ }
+ this._signerInstance = null;
+
localStorage.removeItem(STORAGE_KEY);
+ localStorage.removeItem(STORAGE_KEY_TYPE);
+ localStorage.removeItem(STORAGE_KEY_CONNECT_LOCAL_KEY);
+ localStorage.removeItem(STORAGE_KEY_CONNECT_REMOTE_PUBKEY);
+ localStorage.removeItem(STORAGE_KEY_CONNECT_RELAY);
}
}
diff --git a/app/styles/app.css b/app/styles/app.css
index 8a27b66..4610463 100644
--- a/app/styles/app.css
+++ b/app/styles/app.css
@@ -1378,6 +1378,22 @@ button.create-place {
}
}
+/* Nostr Connect */
+.nostr-connect-modal h2 {
+ margin-top: 0;
+}
+
+.nostr-connect-options {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ margin-top: 1.5rem;
+}
+
+.nostr-connect-status {
+ margin-top: 1.5rem;
+}
+
/* Modal */
.modal-overlay {
position: fixed;
From 99d8ca91748d544598101db8a6b9983f303643ce Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Sun, 19 Apr 2026 15:15:55 +0400
Subject: [PATCH 11/56] Create dedicated Nostr Connect component, use nsec.app
relay
---
app/components/nostr-connect.gjs | 81 +++++++++++++++++++++++++++
app/components/place-details.gjs | 30 +++++++++-
app/components/place-photo-upload.gjs | 59 +++++++------------
app/components/user-menu.gjs | 70 +----------------------
app/services/nostr-auth.js | 4 +-
5 files changed, 132 insertions(+), 112 deletions(-)
create mode 100644 app/components/nostr-connect.gjs
diff --git a/app/components/nostr-connect.gjs b/app/components/nostr-connect.gjs
new file mode 100644
index 0000000..4e3340f
--- /dev/null
+++ b/app/components/nostr-connect.gjs
@@ -0,0 +1,81 @@
+import Component from '@glimmer/component';
+import { action } from '@ember/object';
+import { inject as service } from '@ember/service';
+import { on } from '@ember/modifier';
+import { eq } from 'ember-truth-helpers';
+
+export default class NostrConnectComponent extends Component {
+ @service nostrAuth;
+
+ get hasExtension() {
+ return typeof window !== 'undefined' && typeof window.nostr !== 'undefined';
+ }
+
+ @action
+ async connectExtension() {
+ try {
+ await this.nostrAuth.login('extension');
+ if (this.args.onConnect) {
+ this.args.onConnect();
+ }
+ } catch (e) {
+ console.error(e);
+ alert(e.message);
+ }
+ }
+
+ @action
+ async connectApp() {
+ try {
+ await this.nostrAuth.login('connect');
+ if (this.args.onConnect) {
+ this.args.onConnect();
+ }
+ } catch (e) {
+ console.error(e);
+ alert(e.message);
+ }
+ }
+
+
+
+
Connect with Nostr
+
+
+ {{#if this.hasExtension}}
+
+ Browser Extension (nos2x, Alby)
+
+ {{else}}
+
+ Browser Extension (Not Found)
+
+ {{/if}}
+
+
+ Mobile Signer App (Amber, etc.)
+
+
+
+ {{#if (eq this.nostrAuth.connectStatus "waiting")}}
+
+
Waiting for you to approve the connection in your mobile signer
+ app...
+
+ {{/if}}
+
+
+}
diff --git a/app/components/place-details.gjs b/app/components/place-details.gjs
index df4d88b..309a516 100644
--- a/app/components/place-details.gjs
+++ b/app/components/place-details.gjs
@@ -10,6 +10,7 @@ import Icon from '../components/icon';
import PlaceEditForm from './place-edit-form';
import PlaceListsManager from './place-lists-manager';
import PlacePhotoUpload from './place-photo-upload';
+import NostrConnect from './nostr-connect';
import Modal from './modal';
import { tracked } from '@glimmer/tracking';
@@ -17,16 +18,22 @@ import { action } from '@ember/object';
export default class PlaceDetails extends Component {
@service storage;
+ @service nostrAuth;
@tracked isEditing = false;
@tracked showLists = false;
@tracked isPhotoUploadModalOpen = false;
+ @tracked isNostrConnectModalOpen = false;
@action
openPhotoUploadModal(e) {
if (e) {
e.preventDefault();
}
- this.isPhotoUploadModalOpen = true;
+ if (!this.nostrAuth.isConnected) {
+ this.isNostrConnectModalOpen = true;
+ } else {
+ this.isPhotoUploadModalOpen = true;
+ }
}
@action
@@ -34,6 +41,17 @@ export default class PlaceDetails extends Component {
this.isPhotoUploadModalOpen = false;
}
+ @action
+ closeNostrConnectModal() {
+ this.isNostrConnectModalOpen = false;
+ }
+
+ @action
+ onNostrConnected() {
+ this.isNostrConnectModalOpen = false;
+ this.isPhotoUploadModalOpen = true;
+ }
+
get isSaved() {
return this.storage.isPlaceSaved(this.place.id || this.place.osmId);
}
@@ -518,7 +536,7 @@ export default class PlaceDetails extends Component {
{{#if this.osmUrl}}
-
{{/if}}
@@ -536,5 +554,11 @@ export default class PlaceDetails extends Component {
{{/if}}
+
+ {{#if this.isNostrConnectModalOpen}}
+
+
+
+ {{/if}}
}
diff --git a/app/components/place-photo-upload.gjs b/app/components/place-photo-upload.gjs
index d65337a..5617df3 100644
--- a/app/components/place-photo-upload.gjs
+++ b/app/components/place-photo-upload.gjs
@@ -22,16 +22,6 @@ export default class PlacePhotoUpload extends Component {
return this.place.title || 'this place';
}
- @action
- async login() {
- try {
- this.error = '';
- await this.nostrAuth.login();
- } catch (e) {
- this.error = e.message;
- }
- }
-
@action
async uploadPhoto(event) {
event.preventDefault();
@@ -133,36 +123,25 @@ export default class PlacePhotoUpload extends Component {
{{/if}}
- {{#if this.nostrAuth.isConnected}}
-
- Connected:
- {{this.nostrAuth.pubkey}}
-
-
-
- {{else}}
-
- Connect Nostr Extension
-
- {{/if}}
+
}
diff --git a/app/components/user-menu.gjs b/app/components/user-menu.gjs
index 84b9f85..da3fec6 100644
--- a/app/components/user-menu.gjs
+++ b/app/components/user-menu.gjs
@@ -1,11 +1,11 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
-import { service } from '@ember/service';
+import { inject as service } from '@ember/service';
import Icon from '#components/icon';
import { on } from '@ember/modifier';
import { tracked } from '@glimmer/tracking';
-import { eq } from 'ember-truth-helpers';
import Modal from './modal';
+import NostrConnect from './nostr-connect';
export default class UserMenuComponent extends Component {
@service storage;
@@ -46,37 +46,11 @@ export default class UserMenuComponent extends Component {
this.isNostrConnectModalOpen = false;
}
- @action
- async connectNostrExtension() {
- try {
- await this.nostrAuth.login('extension');
- this.closeNostrConnectModal();
- } catch (e) {
- console.error(e);
- alert(e.message);
- }
- }
-
- @action
- async connectNostrApp() {
- try {
- await this.nostrAuth.login('connect');
- this.closeNostrConnectModal();
- } catch (e) {
- console.error(e);
- alert(e.message);
- }
- }
-
@action
disconnectNostr() {
this.nostrAuth.logout();
}
- get hasExtension() {
- return typeof window !== 'undefined' && typeof window.nostr !== 'undefined';
- }
-
diff --git a/app/modifiers/qr-code.js b/app/modifiers/qr-code.js
new file mode 100644
index 0000000..ea7bc52
--- /dev/null
+++ b/app/modifiers/qr-code.js
@@ -0,0 +1,17 @@
+import { modifier } from 'ember-modifier';
+import QRCode from 'qrcode';
+
+export default modifier((element, [text]) => {
+ if (text) {
+ QRCode.toCanvas(element, text, {
+ width: 256,
+ margin: 2,
+ color: {
+ dark: '#000000',
+ light: '#ffffff',
+ },
+ }).catch((err) => {
+ console.error('Failed to generate QR code', err);
+ });
+ }
+});
diff --git a/app/services/nostr-auth.js b/app/services/nostr-auth.js
index 25d5c60..5b4e7e5 100644
--- a/app/services/nostr-auth.js
+++ b/app/services/nostr-auth.js
@@ -62,6 +62,10 @@ export default class NostrAuthService extends Service {
}
}
+ get isMobile() {
+ return /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent);
+ }
+
get isConnected() {
return (
!!this.pubkey &&
@@ -153,8 +157,10 @@ export default class NostrAuthService extends Service {
icons: [],
});
- // Trigger the deep link intent immediately for the user
- window.location.href = this.connectUri;
+ // Trigger the deep link intent immediately for the user if on mobile
+ if (this.isMobile) {
+ window.location.href = this.connectUri;
+ }
// Start listening to the relay
await this._signerInstance.open();
diff --git a/app/styles/app.css b/app/styles/app.css
index 4610463..17591ee 100644
--- a/app/styles/app.css
+++ b/app/styles/app.css
@@ -1392,6 +1392,18 @@ button.create-place {
.nostr-connect-status {
margin-top: 1.5rem;
+ text-align: center;
+}
+
+.qr-code-container {
+ display: flex;
+ justify-content: center;
+ margin-top: 1rem;
+}
+
+.qr-code-container canvas {
+ border-radius: 8px;
+ background: white; /* Ensure good contrast for scanning */
}
/* Modal */
diff --git a/package.json b/package.json
index ca50c6d..3c16d93 100644
--- a/package.json
+++ b/package.json
@@ -110,6 +110,7 @@
"ember-concurrency": "^5.2.0",
"ember-lifeline": "^7.0.0",
"oauth2-pkce": "^2.1.3",
+ "qrcode": "^1.5.4",
"rxjs": "^7.8.2"
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8dd2a87..3c7b37a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -32,6 +32,9 @@ importers:
oauth2-pkce:
specifier: ^2.1.3
version: 2.1.3
+ qrcode:
+ specifier: ^1.5.4
+ version: 1.5.4
rxjs:
specifier: ^7.8.2
version: 7.8.2
@@ -2215,6 +2218,10 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
+ camelcase@5.3.1:
+ resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
+ engines: {node: '>=6'}
+
can-symlink@1.0.0:
resolution: {integrity: sha512-RbsNrFyhwkx+6psk/0fK/Q9orOUr9VMxohGd8vTa4djf4TGLfblBgUfqZChrZuW0Q+mz2eBPFLusw9Jfukzmhg==}
hasBin: true
@@ -2290,6 +2297,9 @@ packages:
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
engines: {node: '>= 12'}
+ cliui@6.0.0:
+ resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
+
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@@ -2674,6 +2684,10 @@ packages:
supports-color:
optional: true
+ decamelize@1.2.0:
+ resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
+ engines: {node: '>=0.10.0'}
+
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
@@ -2742,6 +2756,9 @@ packages:
resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==}
engines: {node: '>=0.3.1'}
+ dijkstrajs@1.0.3:
+ resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
+
dir-glob@3.0.1:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
@@ -3285,6 +3302,10 @@ packages:
resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==}
engines: {node: '>=6'}
+ find-up@4.1.0:
+ resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
+ engines: {node: '>=8'}
+
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
@@ -3981,6 +4002,10 @@ packages:
resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==}
engines: {node: '>=6'}
+ locate-path@5.0.0:
+ resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
+ engines: {node: '>=8'}
+
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@ -4529,6 +4554,10 @@ packages:
resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==}
engines: {node: '>=6'}
+ p-locate@4.1.0:
+ resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
+ engines: {node: '>=8'}
+
p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
@@ -4659,6 +4688,10 @@ packages:
resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==}
engines: {node: '>=8'}
+ pngjs@5.0.0:
+ resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
+ engines: {node: '>=10.13.0'}
+
portfinder@1.0.38:
resolution: {integrity: sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==}
engines: {node: '>= 10.12'}
@@ -4755,6 +4788,11 @@ packages:
resolution: {integrity: sha512-kXuQdQTB6oN3KhI6V4acnBSZx8D2I4xzZvn9+wFLLFCoBNQY/sFnCW6c43OL7pOQ2HvGV4lnWIXNmgfp7cTWhQ==}
engines: {node: '>=20'}
+ qrcode@1.5.4:
+ resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
+ engines: {node: '>=10.13.0'}
+ hasBin: true
+
qs@6.14.1:
resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==}
engines: {node: '>=0.6'}
@@ -4864,6 +4902,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
+ require-main-filename@2.0.0:
+ resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
+
requireindex@1.2.0:
resolution: {integrity: sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==}
engines: {node: '>=0.10.5'}
@@ -5719,6 +5760,9 @@ packages:
when-exit@2.1.5:
resolution: {integrity: sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==}
+ which-module@2.0.1:
+ resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
+
which@1.3.1:
resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==}
hasBin: true
@@ -5747,6 +5791,10 @@ packages:
workerpool@6.5.1:
resolution: {integrity: sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==}
+ wrap-ansi@6.2.0:
+ resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
+ engines: {node: '>=8'}
+
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -5808,6 +5856,9 @@ packages:
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
+ y18n@4.0.3:
+ resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
+
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -5819,10 +5870,18 @@ packages:
resolution: {integrity: sha512-Hv9xxHtsJ9228wNhk03xnlDReUuWVvHwM4rIbjdAXYvHLs17xjuyF50N6XXFMN6N0omBaqgOok/MCK3At9fTAg==}
engines: {node: ^4.5 || 6.* || >= 7.*}
+ yargs-parser@18.1.3:
+ resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
+ engines: {node: '>=6'}
+
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
+ yargs@15.4.1:
+ resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
+ engines: {node: '>=8'}
+
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
@@ -8370,6 +8429,8 @@ snapshots:
callsites@3.1.0: {}
+ camelcase@5.3.1: {}
+
can-symlink@1.0.0:
dependencies:
tmp: 0.0.28
@@ -8432,6 +8493,12 @@ snapshots:
cli-width@4.1.0: {}
+ cliui@6.0.0:
+ dependencies:
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+ wrap-ansi: 6.2.0
+
cliui@8.0.1:
dependencies:
string-width: 4.2.3
@@ -8640,6 +8707,8 @@ snapshots:
dependencies:
ms: 2.1.3
+ decamelize@1.2.0: {}
+
decimal.js@10.6.0: {}
decode-named-character-reference@1.3.0:
@@ -8698,6 +8767,8 @@ snapshots:
diff@8.0.3: {}
+ dijkstrajs@1.0.3: {}
+
dir-glob@3.0.1:
dependencies:
path-type: 4.0.0
@@ -9687,6 +9758,11 @@ snapshots:
dependencies:
locate-path: 3.0.0
+ find-up@4.1.0:
+ dependencies:
+ locate-path: 5.0.0
+ path-exists: 4.0.0
+
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
@@ -10486,6 +10562,10 @@ snapshots:
p-locate: 3.0.0
path-exists: 3.0.0
+ locate-path@5.0.0:
+ dependencies:
+ p-locate: 4.1.0
+
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@@ -11102,6 +11182,10 @@ snapshots:
dependencies:
p-limit: 2.3.0
+ p-locate@4.1.0:
+ dependencies:
+ p-limit: 2.3.0
+
p-locate@5.0.0:
dependencies:
p-limit: 3.1.0
@@ -11199,6 +11283,8 @@ snapshots:
dependencies:
find-up: 3.0.0
+ pngjs@5.0.0: {}
+
portfinder@1.0.38:
dependencies:
async: 3.2.6
@@ -11283,6 +11369,12 @@ snapshots:
dependencies:
hookified: 1.15.0
+ qrcode@1.5.4:
+ dependencies:
+ dijkstrajs: 1.0.3
+ pngjs: 5.0.0
+ yargs: 15.4.1
+
qs@6.14.1:
dependencies:
side-channel: 1.1.0
@@ -11433,6 +11525,8 @@ snapshots:
require-from-string@2.0.2: {}
+ require-main-filename@2.0.0: {}
+
requireindex@1.2.0: {}
requires-port@1.0.0: {}
@@ -12471,6 +12565,8 @@ snapshots:
when-exit@2.1.5: {}
+ which-module@2.0.1: {}
+
which@1.3.1:
dependencies:
isexe: 2.0.0
@@ -12499,6 +12595,12 @@ snapshots:
workerpool@6.5.1: {}
+ wrap-ansi@6.2.0:
+ dependencies:
+ ansi-styles: 4.3.0
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
@@ -12538,6 +12640,8 @@ snapshots:
xmlchars@2.2.0: {}
+ y18n@4.0.3: {}
+
y18n@5.0.8: {}
yallist@3.1.1: {}
@@ -12547,8 +12651,27 @@ snapshots:
fs-extra: 4.0.3
lodash.merge: 4.6.2
+ yargs-parser@18.1.3:
+ dependencies:
+ camelcase: 5.3.1
+ decamelize: 1.2.0
+
yargs-parser@21.1.1: {}
+ yargs@15.4.1:
+ dependencies:
+ cliui: 6.0.0
+ decamelize: 1.2.0
+ find-up: 4.1.0
+ get-caller-file: 2.0.5
+ require-directory: 2.1.1
+ require-main-filename: 2.0.0
+ set-blocking: 2.0.0
+ string-width: 4.2.3
+ which-module: 2.0.1
+ y18n: 4.0.3
+ yargs-parser: 18.1.3
+
yargs@17.7.2:
dependencies:
cliui: 8.0.1
From c57a665655e3ec1c891759ee5e2a0f8fc842660c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Mon, 20 Apr 2026 12:14:45 +0400
Subject: [PATCH 13/56] Add applesauce debug logs, fix aggressive connect
timeout
---
app/services/nostr-auth.js | 26 ++++++++++++++++++++++++--
1 file changed, 24 insertions(+), 2 deletions(-)
diff --git a/app/services/nostr-auth.js b/app/services/nostr-auth.js
index 5b4e7e5..1d50f1a 100644
--- a/app/services/nostr-auth.js
+++ b/app/services/nostr-auth.js
@@ -26,6 +26,12 @@ export default class NostrAuthService extends Service {
constructor() {
super(...arguments);
+
+ // Enable debug logging for applesauce packages
+ if (typeof localStorage !== 'undefined') {
+ localStorage.debug = 'applesauce:*';
+ }
+
const saved = localStorage.getItem(STORAGE_KEY);
const type = localStorage.getItem(STORAGE_KEY_TYPE);
if (saved) {
@@ -132,6 +138,9 @@ export default class NostrAuthService extends Service {
const relay = 'wss://relay.nsec.app';
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],
@@ -153,20 +162,30 @@ export default class NostrAuthService extends Service {
this.connectUri = this._signerInstance.getNostrConnectURI({
name: 'Marco',
url: window.location.origin,
- description: 'A privacy-respecting maps application.',
+ description: 'An unhosted maps application.',
icons: [],
});
// Trigger the deep link intent immediately for the user if on mobile
if (this.isMobile) {
+ console.debug('Mobile detected, triggering deep link intent.');
window.location.href = this.connectUri;
}
// Start listening to the relay
+ console.debug('Opening signer connection to relay...');
await this._signerInstance.open();
+ console.debug('Signer connection opened successfully.');
// Wait for the remote signer to reply with their pubkey
- await this._signerInstance.waitForSigner();
+ console.debug('Waiting for remote signer to ack via relay...');
+ try {
+ await this._signerInstance.waitForSigner();
+ console.debug('Remote signer ack received!');
+ } catch (waitErr) {
+ console.error('Error while waiting for remote signer ack:', waitErr);
+ throw waitErr;
+ }
// Once connected, get the actual user pubkey
this.pubkey = await this._signerInstance.getPublicKey();
@@ -204,6 +223,9 @@ export default class NostrAuthService extends Service {
const localSigner = PrivateKeySigner.fromKey(localKeyHex);
+ // 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],
From 1dc0c4119bc89c4f4767a4701865fb7031fd572d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Mon, 20 Apr 2026 12:34:44 +0400
Subject: [PATCH 14/56] Refactor Nostr auth service
---
app/components/nostr-connect.gjs | 4 +-
app/components/user-menu.gjs | 2 +-
app/services/nostr-auth.js | 223 ++++++++++++++++---------------
3 files changed, 117 insertions(+), 112 deletions(-)
diff --git a/app/components/nostr-connect.gjs b/app/components/nostr-connect.gjs
index 818fbf6..19194ee 100644
--- a/app/components/nostr-connect.gjs
+++ b/app/components/nostr-connect.gjs
@@ -15,7 +15,7 @@ export default class NostrConnectComponent extends Component {
@action
async connectExtension() {
try {
- await this.nostrAuth.login('extension');
+ await this.nostrAuth.connectWithExtension();
if (this.args.onConnect) {
this.args.onConnect();
}
@@ -28,7 +28,7 @@ export default class NostrConnectComponent extends Component {
@action
async connectApp() {
try {
- await this.nostrAuth.login('connect');
+ await this.nostrAuth.connectWithApp();
if (this.args.onConnect) {
this.args.onConnect();
}
diff --git a/app/components/user-menu.gjs b/app/components/user-menu.gjs
index da3fec6..93be100 100644
--- a/app/components/user-menu.gjs
+++ b/app/components/user-menu.gjs
@@ -48,7 +48,7 @@ export default class UserMenuComponent extends Component {
@action
disconnectNostr() {
- this.nostrAuth.logout();
+ this.nostrAuth.disconnect();
}
diff --git a/app/services/nostr-auth.js b/app/services/nostr-auth.js
index 1d50f1a..ccbb4b4 100644
--- a/app/services/nostr-auth.js
+++ b/app/services/nostr-auth.js
@@ -12,6 +12,8 @@ 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';
+
export default class NostrAuthService extends Service {
@service nostrRelay;
@@ -44,7 +46,7 @@ export default class NostrAuthService extends Service {
async _verifyPubkey() {
if (this.signerType === 'extension') {
if (typeof window.nostr === 'undefined') {
- this.logout();
+ this.disconnect();
return;
}
try {
@@ -56,14 +58,14 @@ export default class NostrAuthService extends Service {
}
} catch (e) {
console.warn('Failed to verify extension nostr pubkey, logging out', e);
- this.logout();
+ this.disconnect();
}
} else if (this.signerType === 'connect') {
try {
await this._initConnectSigner();
} catch (e) {
console.warn('Failed to verify connect nostr pubkey, logging out', e);
- this.logout();
+ this.disconnect();
}
}
}
@@ -99,129 +101,132 @@ export default class NostrAuthService extends Service {
return null;
}
- async login(type = 'extension') {
- if (type === 'extension') {
- if (typeof window.nostr === 'undefined') {
- throw new Error('No NIP-07 Nostr extension found (e.g., Alby, nos2x).');
+ async connectWithExtension() {
+ if (typeof window.nostr === 'undefined') {
+ throw new Error('No NIP-07 Nostr extension found (e.g., Alby, nos2x).');
+ }
+
+ try {
+ this._signerInstance = new ExtensionSigner();
+ this.pubkey = await this._signerInstance.getPublicKey();
+ this.signerType = 'extension';
+ localStorage.setItem(STORAGE_KEY, this.pubkey);
+ localStorage.setItem(STORAGE_KEY_TYPE, 'extension');
+ return this.pubkey;
+ } catch (error) {
+ console.error('Failed to get public key from extension:', error);
+ throw error;
+ }
+ }
+
+ _getLocalSigner() {
+ let localKeyHex = localStorage.getItem(STORAGE_KEY_CONNECT_LOCAL_KEY);
+ let localSigner;
+ if (localKeyHex) {
+ localSigner = PrivateKeySigner.fromKey(localKeyHex);
+ } else {
+ localSigner = new PrivateKeySigner();
+ // Store the raw Uint8Array as hex string
+ localKeyHex = Array.from(localSigner.key)
+ .map((b) => b.toString(16).padStart(2, '0'))
+ .join('');
+ localStorage.setItem(STORAGE_KEY_CONNECT_LOCAL_KEY, localKeyHex);
+ }
+ return localSigner;
+ }
+
+ async connectWithApp() {
+ this.connectStatus = 'waiting';
+
+ try {
+ const localSigner = this._getLocalSigner();
+
+ // We use a specific relay for the connection handshake.
+ 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],
+ signer: localSigner,
+ onAuth: async (url) => {
+ // NIP-46 auth callback. Normally the signer app does this natively via notification.
+ // But if it requires an explicit browser window:
+ if (
+ confirm(
+ `Your signer app requests authentication via a web page. Open it now?\n\nURL: ${url}`
+ )
+ ) {
+ window.open(url, '_blank');
+ }
+ },
+ });
+
+ // Set the uri for display (e.g., to redirect via intent)
+ this.connectUri = this._signerInstance.getNostrConnectURI({
+ name: 'Marco',
+ url: window.location.origin,
+ description: 'An unhosted maps application.',
+ icons: [],
+ });
+
+ // Trigger the deep link intent immediately for the user if on mobile
+ if (this.isMobile) {
+ console.debug('Mobile detected, triggering deep link intent.');
+ window.location.href = this.connectUri;
}
+ // Start listening to the relay
+ console.debug('Opening signer connection to relay...');
+ await this._signerInstance.open();
+ console.debug('Signer connection opened successfully.');
+
+ // Wait for the remote signer to reply with their pubkey
+ console.debug('Waiting for remote signer to ack via relay...');
try {
- this._signerInstance = new ExtensionSigner();
- this.pubkey = await this._signerInstance.getPublicKey();
- this.signerType = 'extension';
- localStorage.setItem(STORAGE_KEY, this.pubkey);
- localStorage.setItem(STORAGE_KEY_TYPE, 'extension');
- return this.pubkey;
- } catch (error) {
- console.error('Failed to get public key from extension:', error);
- throw error;
+ await this._signerInstance.waitForSigner();
+ console.debug('Remote signer ack received!');
+ } catch (waitErr) {
+ console.error('Error while waiting for remote signer ack:', waitErr);
+ throw waitErr;
}
- } else if (type === 'connect') {
- this.connectStatus = 'waiting';
- try {
- // Generate or retrieve a local ephemeral keypair
- let localKeyHex = localStorage.getItem(STORAGE_KEY_CONNECT_LOCAL_KEY);
- let localSigner;
- if (localKeyHex) {
- localSigner = PrivateKeySigner.fromKey(localKeyHex);
- } else {
- localSigner = new PrivateKeySigner();
- // Store the raw Uint8Array as hex string
- localKeyHex = Array.from(localSigner.key)
- .map((b) => b.toString(16).padStart(2, '0'))
- .join('');
- localStorage.setItem(STORAGE_KEY_CONNECT_LOCAL_KEY, localKeyHex);
- }
+ // Once connected, get the actual user pubkey
+ this.pubkey = await this._signerInstance.getPublicKey();
+ this.signerType = 'connect';
+ this.connectStatus = 'connected';
- // We use a specific relay for the connection handshake.
- const relay = 'wss://relay.nsec.app';
- localStorage.setItem(STORAGE_KEY_CONNECT_RELAY, relay);
+ // Save connection state
+ localStorage.setItem(STORAGE_KEY, this.pubkey);
+ localStorage.setItem(STORAGE_KEY_TYPE, 'connect');
+ localStorage.setItem(
+ STORAGE_KEY_CONNECT_REMOTE_PUBKEY,
+ this._signerInstance.remote
+ );
- // 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],
- signer: localSigner,
- onAuth: async (url) => {
- // NIP-46 auth callback. Normally the signer app does this natively via notification.
- // But if it requires an explicit browser window:
- if (
- confirm(
- `Your signer app requests authentication via a web page. Open it now?\n\nURL: ${url}`
- )
- ) {
- window.open(url, '_blank');
- }
- },
- });
-
- // Set the uri for display (e.g., to redirect via intent)
- this.connectUri = this._signerInstance.getNostrConnectURI({
- name: 'Marco',
- url: window.location.origin,
- description: 'An unhosted maps application.',
- icons: [],
- });
-
- // Trigger the deep link intent immediately for the user if on mobile
- if (this.isMobile) {
- console.debug('Mobile detected, triggering deep link intent.');
- window.location.href = this.connectUri;
- }
-
- // Start listening to the relay
- console.debug('Opening signer connection to relay...');
- await this._signerInstance.open();
- console.debug('Signer connection opened successfully.');
-
- // Wait for the remote signer to reply with their pubkey
- console.debug('Waiting for remote signer to ack via relay...');
- try {
- await this._signerInstance.waitForSigner();
- console.debug('Remote signer ack received!');
- } catch (waitErr) {
- console.error('Error while waiting for remote signer ack:', waitErr);
- throw waitErr;
- }
-
- // Once connected, get the actual user pubkey
- this.pubkey = await this._signerInstance.getPublicKey();
- this.signerType = 'connect';
- this.connectStatus = 'connected';
-
- // Save connection state
- localStorage.setItem(STORAGE_KEY, this.pubkey);
- localStorage.setItem(STORAGE_KEY_TYPE, 'connect');
- localStorage.setItem(
- STORAGE_KEY_CONNECT_REMOTE_PUBKEY,
- this._signerInstance.remote
- );
-
- return this.pubkey;
- } catch (error) {
- this.connectStatus = null;
- console.error('Failed to connect via Nostr Connect:', error);
- throw error;
- }
+ return this.pubkey;
+ } catch (error) {
+ this.connectStatus = null;
+ console.error('Failed to connect via Nostr Connect:', error);
+ throw error;
}
}
async _initConnectSigner() {
- const localKeyHex = localStorage.getItem(STORAGE_KEY_CONNECT_LOCAL_KEY);
const remotePubkey = localStorage.getItem(
STORAGE_KEY_CONNECT_REMOTE_PUBKEY
);
const relay =
- localStorage.getItem(STORAGE_KEY_CONNECT_RELAY) || 'wss://relay.nsec.app';
+ localStorage.getItem(STORAGE_KEY_CONNECT_RELAY) || DEFAULT_CONNECT_RELAY;
- if (!localKeyHex || !remotePubkey) {
- throw new Error('Missing Nostr Connect local state.');
+ if (!remotePubkey) {
+ throw new Error('Missing Nostr Connect remote pubkey.');
}
- const localSigner = PrivateKeySigner.fromKey(localKeyHex);
+ const localSigner = this._getLocalSigner();
// Override aggressive 10s EOSE timeout to allow time for QR scanning
this.nostrRelay.pool.relay(relay).eoseTimeout = 180000; // 3 minutes
@@ -259,7 +264,7 @@ export default class NostrAuthService extends Service {
return await this.signer.signEvent(event);
}
- async logout() {
+ async disconnect() {
this.pubkey = null;
this.signerType = null;
this.connectStatus = null;
From 3a564649262de3ce4e6b80f463c809e8839491b0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Mon, 20 Apr 2026 13:09:51 +0400
Subject: [PATCH 15/56] Improve Nostr connect UI
---
app/components/nostr-connect.gjs | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/app/components/nostr-connect.gjs b/app/components/nostr-connect.gjs
index 19194ee..9e278ad 100644
--- a/app/components/nostr-connect.gjs
+++ b/app/components/nostr-connect.gjs
@@ -7,6 +7,7 @@ import qrCode from '../modifiers/qr-code';
export default class NostrConnectComponent extends Component {
@service nostrAuth;
+ @service toast;
get hasExtension() {
return typeof window !== 'undefined' && typeof window.nostr !== 'undefined';
@@ -16,6 +17,7 @@ export default class NostrConnectComponent extends Component {
async connectExtension() {
try {
await this.nostrAuth.connectWithExtension();
+ this.toast.show('Nostr connected successfully');
if (this.args.onConnect) {
this.args.onConnect();
}
@@ -29,6 +31,7 @@ export default class NostrConnectComponent extends Component {
async connectApp() {
try {
await this.nostrAuth.connectWithApp();
+ this.toast.show('Nostr connected successfully');
if (this.args.onConnect) {
this.args.onConnect();
}
@@ -53,7 +56,7 @@ export default class NostrConnectComponent extends Component {
{{else}}
{{#if (eq this.nostrAuth.connectStatus "waiting")}}
-
+
{{#if this.nostrAuth.isMobile}}
Waiting for you to approve the connection in your mobile signer
app...
From 8cc579e2719a8e6dbeba9b19e6d9c185da5f2e0a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Mon, 20 Apr 2026 13:37:05 +0400
Subject: [PATCH 16/56] Load user profile from Nostr, display name and avatar
---
app/components/app-header.gjs | 19 +++-
app/components/user-menu.gjs | 3 +-
app/services/nostr-auth.js | 7 ++
app/services/nostr-data.js | 158 ++++++++++++++++++++++++++++++++++
app/styles/app.css | 11 ++-
5 files changed, 193 insertions(+), 5 deletions(-)
create mode 100644 app/services/nostr-data.js
diff --git a/app/components/app-header.gjs b/app/components/app-header.gjs
index cbc771c..91ac29e 100644
--- a/app/components/app-header.gjs
+++ b/app/components/app-header.gjs
@@ -7,10 +7,13 @@ import Icon from '#components/icon';
import UserMenu from '#components/user-menu';
import SearchBox from '#components/search-box';
import CategoryChips from '#components/category-chips';
+import { and } from 'ember-truth-helpers';
export default class AppHeaderComponent extends Component {
@service storage;
@service settings;
+ @service nostrAuth;
+ @service nostrData;
@tracked isUserMenuOpen = false;
@tracked searchQuery = '';
@@ -64,9 +67,19 @@ export default class AppHeaderComponent extends Component {
aria-label="User Menu"
{{on "click" this.toggleUserMenu}}
>
-
-
-
+ {{#if
+ (and this.nostrAuth.isConnected this.nostrData.profile.picture)
+ }}
+
+ {{else}}
+
+
+
+ {{/if}}
{{#if this.isUserMenuOpen}}
diff --git a/app/components/user-menu.gjs b/app/components/user-menu.gjs
index 93be100..6b92d66 100644
--- a/app/components/user-menu.gjs
+++ b/app/components/user-menu.gjs
@@ -11,6 +11,7 @@ export default class UserMenuComponent extends Component {
@service storage;
@service osmAuth;
@service nostrAuth;
+ @service nostrData;
@tracked isNostrConnectModalOpen = false;
@@ -135,7 +136,7 @@ export default class UserMenuComponent extends Component {
{{#if this.nostrAuth.isConnected}}
- {{this.nostrAuth.pubkey}}
+ {{this.nostrData.userDisplayName}}
{{else}}
Not connected
diff --git a/app/services/nostr-auth.js b/app/services/nostr-auth.js
index ccbb4b4..cce17d4 100644
--- a/app/services/nostr-auth.js
+++ b/app/services/nostr-auth.js
@@ -16,6 +16,7 @@ const DEFAULT_CONNECT_RELAY = 'wss://relay.nsec.app';
export default class NostrAuthService extends Service {
@service nostrRelay;
+ @service nostrData;
@tracked pubkey = null;
@tracked signerType = null; // 'extension' or 'connect'
@@ -56,6 +57,7 @@ export default class NostrAuthService extends Service {
this.pubkey = extensionPubkey;
localStorage.setItem(STORAGE_KEY, this.pubkey);
}
+ this.nostrData.loadProfile(this.pubkey);
} catch (e) {
console.warn('Failed to verify extension nostr pubkey, logging out', e);
this.disconnect();
@@ -112,6 +114,7 @@ export default class NostrAuthService extends Service {
this.signerType = 'extension';
localStorage.setItem(STORAGE_KEY, this.pubkey);
localStorage.setItem(STORAGE_KEY_TYPE, 'extension');
+ this.nostrData.loadProfile(this.pubkey);
return this.pubkey;
} catch (error) {
console.error('Failed to get public key from extension:', error);
@@ -207,6 +210,8 @@ export default class NostrAuthService extends Service {
this._signerInstance.remote
);
+ this.nostrData.loadProfile(this.pubkey);
+
return this.pubkey;
} catch (error) {
this.connectStatus = null;
@@ -253,6 +258,7 @@ export default class NostrAuthService extends Service {
if (pubkey !== this.pubkey) {
throw new Error('Remote signer pubkey mismatch');
}
+ this.nostrData.loadProfile(this.pubkey);
}
async signEvent(event) {
@@ -266,6 +272,7 @@ export default class NostrAuthService extends Service {
async disconnect() {
this.pubkey = null;
+ this.nostrData?.loadProfile(null);
this.signerType = null;
this.connectStatus = null;
this.connectUri = null;
diff --git a/app/services/nostr-data.js b/app/services/nostr-data.js
new file mode 100644
index 0000000..adf99e9
--- /dev/null
+++ b/app/services/nostr-data.js
@@ -0,0 +1,158 @@
+import Service, { inject as service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+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';
+
+const BOOTSTRAP_RELAYS = [
+ 'wss://purplepag.es',
+ 'wss://relay.damus.io',
+ 'wss://nos.lol',
+];
+
+export default class NostrDataService extends Service {
+ @service nostrRelay;
+ @service nostrAuth;
+
+ store = new EventStore();
+
+ @tracked profile = null;
+ @tracked mailboxes = null;
+ @tracked blossomServers = [];
+
+ _profileSub = null;
+ _mailboxesSub = null;
+ _blossomSub = null;
+
+ _requestSub = null;
+
+ constructor() {
+ super(...arguments);
+
+ // 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()
+ // which returns an Observable
+ });
+ }
+
+ async loadProfile(pubkey) {
+ if (!pubkey) return;
+
+ // Reset state
+ this.profile = null;
+ this.mailboxes = null;
+ this.blossomServers = [];
+
+ this._cleanupSubscriptions();
+
+ const relays = new Set(BOOTSTRAP_RELAYS);
+
+ // Try to get extension relays
+ if (typeof window.nostr !== 'undefined' && window.nostr.getRelays) {
+ try {
+ const extRelays = await window.nostr.getRelays();
+ for (const url of Object.keys(extRelays)) {
+ relays.add(url);
+ }
+ } catch {
+ console.warn('Failed to get NIP-07 relays');
+ }
+ }
+
+ const relayList = Array.from(relays);
+
+ // Request events and dump them into the store
+ this._requestSub = this.nostrRelay.pool
+ .request(relayList, [
+ {
+ authors: [pubkey],
+ kinds: [0, 10002, 10063],
+ },
+ ])
+ .subscribe({
+ next: (event) => {
+ this.store.add(event);
+ },
+ error: (err) => {
+ console.error('Error fetching profile events:', err);
+ },
+ });
+
+ // Setup models to track state reactively
+ this._profileSub = this.store
+ .model(ProfileModel, pubkey)
+ .subscribe((profileContent) => {
+ this.profile = profileContent;
+ });
+
+ this._mailboxesSub = this.store
+ .model(MailboxesModel, pubkey)
+ .subscribe((mailboxesData) => {
+ this.mailboxes = mailboxesData;
+ });
+
+ this._blossomSub = this.store
+ .replaceable(10063, pubkey)
+ .subscribe((event) => {
+ if (event && event.tags) {
+ this.blossomServers = event.tags
+ .filter((t) => t[0] === 'server' && t[1])
+ .map((t) => t[1]);
+ } else {
+ this.blossomServers = [];
+ }
+ });
+ }
+
+ get userDisplayName() {
+ if (this.profile) {
+ if (this.profile.nip05) {
+ return this.profile.nip05;
+ }
+ if (this.profile.displayName || this.profile.display_name) {
+ return this.profile.displayName || this.profile.display_name;
+ }
+ if (this.profile.name) {
+ return this.profile.name;
+ }
+ }
+
+ // Fallback to npub
+ if (this.nostrAuth.pubkey) {
+ try {
+ const npub = npubEncode(this.nostrAuth.pubkey);
+ return `${npub.slice(0, 9)}...${npub.slice(-4)}`;
+ } catch {
+ return this.nostrAuth.pubkey;
+ }
+ }
+
+ return 'Not connected';
+ }
+
+ _cleanupSubscriptions() {
+ if (this._requestSub) {
+ this._requestSub.unsubscribe();
+ this._requestSub = null;
+ }
+ if (this._profileSub) {
+ this._profileSub.unsubscribe();
+ this._profileSub = null;
+ }
+ if (this._mailboxesSub) {
+ this._mailboxesSub.unsubscribe();
+ this._mailboxesSub = null;
+ }
+ if (this._blossomSub) {
+ this._blossomSub.unsubscribe();
+ this._blossomSub = null;
+ }
+ }
+
+ willDestroy() {
+ super.willDestroy(...arguments);
+ this._cleanupSubscriptions();
+ }
+}
diff --git a/app/styles/app.css b/app/styles/app.css
index 17591ee..d5891b3 100644
--- a/app/styles/app.css
+++ b/app/styles/app.css
@@ -190,7 +190,16 @@ body {
display: flex;
align-items: center;
justify-content: center;
- box-shadow: 0 2px 5px rgb(0 0 0 / 20%);
+ flex-shrink: 0;
+}
+
+.user-avatar {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ object-fit: cover;
+ flex-shrink: 0;
+}
}
/* User Menu Popover */
From 7607f27013da6649ce8747d536fbfc7452fb8ad0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Mon, 20 Apr 2026 13:55:13 +0400
Subject: [PATCH 17/56] Upload photos to user's Blossom server
---
app/components/place-photo-upload.gjs | 150 ++++++++++++++++++++++----
app/styles/app.css | 4 +
2 files changed, 134 insertions(+), 20 deletions(-)
diff --git a/app/components/place-photo-upload.gjs b/app/components/place-photo-upload.gjs
index 5617df3..1acc8b3 100644
--- a/app/components/place-photo-upload.gjs
+++ b/app/components/place-photo-upload.gjs
@@ -6,13 +6,23 @@ import { on } from '@ember/modifier';
import { EventFactory } from 'applesauce-core';
import Geohash from 'latlon-geohash';
+function bufferToHex(buffer) {
+ return Array.from(new Uint8Array(buffer))
+ .map((b) => b.toString(16).padStart(2, '0'))
+ .join('');
+}
+
export default class PlacePhotoUpload extends Component {
@service nostrAuth;
+ @service nostrData;
@service nostrRelay;
@tracked photoUrl = '';
+ @tracked photoType = 'image/jpeg';
+ @tracked photoDim = '';
@tracked status = '';
@tracked error = '';
+ @tracked isUploading = false;
get place() {
return this.args.place || {};
@@ -22,21 +32,105 @@ export default class PlacePhotoUpload extends Component {
return this.place.title || 'this place';
}
+ get blossomServer() {
+ return this.nostrData.blossomServers[0] || 'https://nostr.build';
+ }
+
@action
- async uploadPhoto(event) {
- event.preventDefault();
+ async handleFileSelected(event) {
+ const file = event.target.files[0];
+ if (!file) return;
+
this.error = '';
- this.status = 'Uploading...';
+ this.status = 'Preparing upload...';
+ this.isUploading = true;
+ this.photoType = file.type;
try {
- // Mock upload
- await new Promise((resolve) => setTimeout(resolve, 1000));
- this.photoUrl =
- 'https://dummyimage.com/600x400/000/fff.jpg&text=Mock+Place+Photo';
+ if (!this.nostrAuth.isConnected) {
+ throw new Error('You must connect Nostr first.');
+ }
+
+ // 1. Get image dimensions
+ const dim = await new Promise((resolve) => {
+ const url = URL.createObjectURL(file);
+ const img = new Image();
+ img.onload = () => {
+ URL.revokeObjectURL(url);
+ resolve(`${img.width}x${img.height}`);
+ };
+ img.onerror = () => resolve('');
+ img.src = url;
+ });
+ this.photoDim = dim;
+
+ // 2. Read file & compute hash
+ this.status = 'Computing hash...';
+ const buffer = await file.arrayBuffer();
+ const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
+ const payloadHash = bufferToHex(hashBuffer);
+
+ // 3. Create BUD-11 Auth Event
+ this.status = 'Signing auth event...';
+ let serverUrl = this.blossomServer;
+ if (serverUrl.endsWith('/')) {
+ serverUrl = serverUrl.slice(0, -1);
+ }
+ const uploadUrl = `${serverUrl}/upload`;
+
+ 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: 'Upload photo for place',
+ tags: [
+ ['t', 'upload'],
+ ['x', payloadHash],
+ ['expiration', String(now + 3600)],
+ ['server', serverHostname],
+ ],
+ };
+
+ const authEvent = await factory.sign(authTemplate);
+ const base64 = btoa(JSON.stringify(authEvent));
+ const base64url = base64
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=+$/, '');
+ const authHeader = `Nostr ${base64url}`;
+
+ // 4. Upload to Blossom
+ this.status = `Uploading to ${serverUrl}...`;
+ // eslint-disable-next-line warp-drive/no-external-request-patterns
+ const response = await fetch(uploadUrl, {
+ method: 'PUT',
+ headers: {
+ Authorization: authHeader,
+ 'X-SHA-256': payloadHash,
+ },
+ body: file,
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(`Upload failed (${response.status}): ${text}`);
+ }
+
+ const result = await response.json();
+
+ this.photoUrl = result.url;
this.status = 'Photo uploaded! Ready to publish.';
} catch (e) {
- this.error = 'Upload failed: ' + e.message;
+ this.error = e.message;
this.status = '';
+ } finally {
+ this.isUploading = false;
+ if (event && event.target) {
+ event.target.value = '';
+ }
}
}
@@ -75,13 +169,18 @@ export default class PlacePhotoUpload extends Component {
tags.push(['g', Geohash.encode(lat, lon, 9)]);
}
- tags.push([
+ const imeta = [
'imeta',
`url ${this.photoUrl}`,
- 'm image/jpeg',
- 'dim 600x400',
+ `m ${this.photoType}`,
'alt A photo of a place',
- ]);
+ ];
+
+ if (this.photoDim) {
+ imeta.splice(3, 0, `dim ${this.photoDim}`);
+ }
+
+ tags.push(imeta);
// NIP-XX draft Place Photo event
const template = {
@@ -90,7 +189,6 @@ export default class PlacePhotoUpload extends Component {
tags,
};
- // Ensure created_at is present before signing
if (!template.created_at) {
template.created_at = Math.floor(Date.now() / 1000);
}
@@ -99,7 +197,6 @@ export default class PlacePhotoUpload extends Component {
await this.nostrRelay.publish(event);
this.status = 'Published successfully!';
- // Reset form
this.photoUrl = '';
} catch (e) {
this.error = 'Failed to publish: ' + e.message;
@@ -123,11 +220,15 @@ export default class PlacePhotoUpload extends Component {
{{/if}}
-
}
diff --git a/app/styles/app.css b/app/styles/app.css
index d5891b3..6488124 100644
--- a/app/styles/app.css
+++ b/app/styles/app.css
@@ -200,6 +200,10 @@ body {
object-fit: cover;
flex-shrink: 0;
}
+
+.photo-preview-img {
+ max-width: 100%;
+ height: auto;
}
/* User Menu Popover */
From 10501b64bd06132777f80fbca1e675acd530b488 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Mon, 20 Apr 2026 14:06:13 +0400
Subject: [PATCH 18/56] Fix avatar placement for new avatar image
---
app/styles/app.css | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/app/styles/app.css b/app/styles/app.css
index 6488124..4416dad 100644
--- a/app/styles/app.css
+++ b/app/styles/app.css
@@ -180,6 +180,9 @@ body {
border: none;
cursor: pointer;
padding: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
}
.user-avatar-placeholder {
@@ -199,6 +202,7 @@ body {
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
+ display: block;
}
.photo-preview-img {
From f1ebafc1f02b38779eb4b5bd4f18891040fe76b0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Mon, 20 Apr 2026 14:06:41 +0400
Subject: [PATCH 19/56] Fix default blossom server, move to constant
---
app/components/place-photo-upload.gjs | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/app/components/place-photo-upload.gjs b/app/components/place-photo-upload.gjs
index 1acc8b3..2a40ee3 100644
--- a/app/components/place-photo-upload.gjs
+++ b/app/components/place-photo-upload.gjs
@@ -6,6 +6,8 @@ import { on } from '@ember/modifier';
import { EventFactory } from 'applesauce-core';
import Geohash from 'latlon-geohash';
+const DEFAULT_BLOSSOM_SERVER = 'https://blossom.nostr.build';
+
function bufferToHex(buffer) {
return Array.from(new Uint8Array(buffer))
.map((b) => b.toString(16).padStart(2, '0'))
@@ -33,7 +35,7 @@ export default class PlacePhotoUpload extends Component {
}
get blossomServer() {
- return this.nostrData.blossomServers[0] || 'https://nostr.build';
+ return this.nostrData.blossomServers[0] || DEFAULT_BLOSSOM_SERVER;
}
@action
From d9ba73559ed4c9081ef64e78afd9527c39104885 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Mon, 20 Apr 2026 14:25:15 +0400
Subject: [PATCH 20/56] WIP Upload multiple photos
---
app/components/place-photo-item.gjs | 164 ++++++++++++++++
app/components/place-photo-upload.gjs | 265 +++++++++++---------------
app/styles/app.css | 112 +++++++++++
app/utils/icons.js | 8 +
4 files changed, 400 insertions(+), 149 deletions(-)
create mode 100644 app/components/place-photo-item.gjs
diff --git a/app/components/place-photo-item.gjs b/app/components/place-photo-item.gjs
new file mode 100644
index 0000000..c9845bc
--- /dev/null
+++ b/app/components/place-photo-item.gjs
@@ -0,0 +1,164 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { inject as service } from '@ember/service';
+import { task } from 'ember-concurrency';
+import { EventFactory } from 'applesauce-core';
+import Icon from '#components/icon';
+import { on } from '@ember/modifier';
+import { fn } from '@ember/helper';
+
+const DEFAULT_BLOSSOM_SERVER = 'https://blossom.nostr.build';
+
+function bufferToHex(buffer) {
+ return Array.from(new Uint8Array(buffer))
+ .map((b) => b.toString(16).padStart(2, '0'))
+ .join('');
+}
+
+export default class PlacePhotoItem extends Component {
+ @service nostrAuth;
+ @service nostrData;
+
+ @tracked thumbnailUrl = '';
+ @tracked error = '';
+ @tracked isUploaded = false;
+
+ get blossomServer() {
+ return this.nostrData.blossomServers[0] || DEFAULT_BLOSSOM_SERVER;
+ }
+
+ constructor() {
+ super(...arguments);
+ if (this.args.file) {
+ this.thumbnailUrl = URL.createObjectURL(this.args.file);
+ this.uploadTask.perform(this.args.file);
+ }
+ }
+
+ willDestroy() {
+ super.willDestroy(...arguments);
+ if (this.thumbnailUrl) {
+ URL.revokeObjectURL(this.thumbnailUrl);
+ }
+ }
+
+ uploadTask = task(async (file) => {
+ this.error = '';
+ try {
+ if (!this.nostrAuth.isConnected) throw new Error('Not connected');
+
+ const dim = await new Promise((resolve) => {
+ const img = new Image();
+ img.onload = () => resolve(`${img.width}x${img.height}`);
+ img.onerror = () => resolve('');
+ img.src = this.thumbnailUrl;
+ });
+
+ const buffer = await file.arrayBuffer();
+ const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
+ const payloadHash = bufferToHex(hashBuffer);
+
+ let serverUrl = this.blossomServer;
+ if (serverUrl.endsWith('/')) {
+ serverUrl = serverUrl.slice(0, -1);
+ }
+ const uploadUrl = `${serverUrl}/upload`;
+
+ 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: 'Upload photo for place',
+ tags: [
+ ['t', 'upload'],
+ ['x', payloadHash],
+ ['expiration', String(now + 3600)],
+ ['server', serverHostname],
+ ],
+ };
+
+ const authEvent = await factory.sign(authTemplate);
+ const base64 = btoa(JSON.stringify(authEvent));
+ const base64url = base64
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=+$/, '');
+ const authHeader = `Nostr ${base64url}`;
+
+ // eslint-disable-next-line warp-drive/no-external-request-patterns
+ const response = await fetch(uploadUrl, {
+ method: 'PUT',
+ headers: {
+ Authorization: authHeader,
+ 'X-SHA-256': payloadHash,
+ },
+ body: file,
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(`Upload failed (${response.status}): ${text}`);
+ }
+
+ const result = await response.json();
+ this.isUploaded = true;
+
+ if (this.args.onSuccess) {
+ this.args.onSuccess({
+ file,
+ url: result.url,
+ type: file.type,
+ dim,
+ hash: payloadHash,
+ });
+ }
+ } catch (e) {
+ this.error = e.message;
+ }
+ });
+
+
+
+
+
+ {{#if this.uploadTask.isRunning}}
+
+
+
+ {{/if}}
+
+ {{#if this.error}}
+
+
+
+ {{/if}}
+
+ {{#if this.isUploaded}}
+
+
+
+ {{/if}}
+
+
+
+
+
+
+}
diff --git a/app/components/place-photo-upload.gjs b/app/components/place-photo-upload.gjs
index 2a40ee3..94ef85d 100644
--- a/app/components/place-photo-upload.gjs
+++ b/app/components/place-photo-upload.gjs
@@ -5,26 +5,20 @@ import { inject as service } from '@ember/service';
import { on } from '@ember/modifier';
import { EventFactory } from 'applesauce-core';
import Geohash from 'latlon-geohash';
-
-const DEFAULT_BLOSSOM_SERVER = 'https://blossom.nostr.build';
-
-function bufferToHex(buffer) {
- return Array.from(new Uint8Array(buffer))
- .map((b) => b.toString(16).padStart(2, '0'))
- .join('');
-}
+import PlacePhotoItem from './place-photo-item';
+import Icon from '#components/icon';
+import { or, not } from 'ember-truth-helpers';
export default class PlacePhotoUpload extends Component {
@service nostrAuth;
- @service nostrData;
@service nostrRelay;
- @tracked photoUrl = '';
- @tracked photoType = 'image/jpeg';
- @tracked photoDim = '';
+ @tracked files = [];
+ @tracked uploadedPhotos = [];
@tracked status = '';
@tracked error = '';
- @tracked isUploading = false;
+ @tracked isPublishing = false;
+ @tracked isDragging = false;
get place() {
return this.args.place || {};
@@ -34,106 +28,56 @@ export default class PlacePhotoUpload extends Component {
return this.place.title || 'this place';
}
- get blossomServer() {
- return this.nostrData.blossomServers[0] || DEFAULT_BLOSSOM_SERVER;
+ get allUploaded() {
+ return (
+ this.files.length > 0 && this.files.length === this.uploadedPhotos.length
+ );
}
@action
- async handleFileSelected(event) {
- const file = event.target.files[0];
- if (!file) return;
+ handleFileSelect(event) {
+ this.addFiles(event.target.files);
+ event.target.value = ''; // Reset input
+ }
- this.error = '';
- this.status = 'Preparing upload...';
- this.isUploading = true;
- this.photoType = file.type;
+ @action
+ handleDragOver(event) {
+ event.preventDefault();
+ this.isDragging = true;
+ }
- try {
- if (!this.nostrAuth.isConnected) {
- throw new Error('You must connect Nostr first.');
- }
+ @action
+ handleDragLeave(event) {
+ event.preventDefault();
+ this.isDragging = false;
+ }
- // 1. Get image dimensions
- const dim = await new Promise((resolve) => {
- const url = URL.createObjectURL(file);
- const img = new Image();
- img.onload = () => {
- URL.revokeObjectURL(url);
- resolve(`${img.width}x${img.height}`);
- };
- img.onerror = () => resolve('');
- img.src = url;
- });
- this.photoDim = dim;
+ @action
+ handleDrop(event) {
+ event.preventDefault();
+ this.isDragging = false;
+ this.addFiles(event.dataTransfer.files);
+ }
- // 2. Read file & compute hash
- this.status = 'Computing hash...';
- const buffer = await file.arrayBuffer();
- const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
- const payloadHash = bufferToHex(hashBuffer);
+ addFiles(fileList) {
+ if (!fileList) return;
+ const newFiles = Array.from(fileList).filter((f) =>
+ f.type.startsWith('image/')
+ );
+ this.files = [...this.files, ...newFiles];
+ }
- // 3. Create BUD-11 Auth Event
- this.status = 'Signing auth event...';
- let serverUrl = this.blossomServer;
- if (serverUrl.endsWith('/')) {
- serverUrl = serverUrl.slice(0, -1);
- }
- const uploadUrl = `${serverUrl}/upload`;
+ @action
+ handleUploadSuccess(photoData) {
+ this.uploadedPhotos = [...this.uploadedPhotos, photoData];
+ }
- 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: 'Upload photo for place',
- tags: [
- ['t', 'upload'],
- ['x', payloadHash],
- ['expiration', String(now + 3600)],
- ['server', serverHostname],
- ],
- };
-
- const authEvent = await factory.sign(authTemplate);
- const base64 = btoa(JSON.stringify(authEvent));
- const base64url = base64
- .replace(/\+/g, '-')
- .replace(/\//g, '_')
- .replace(/=+$/, '');
- const authHeader = `Nostr ${base64url}`;
-
- // 4. Upload to Blossom
- this.status = `Uploading to ${serverUrl}...`;
- // eslint-disable-next-line warp-drive/no-external-request-patterns
- const response = await fetch(uploadUrl, {
- method: 'PUT',
- headers: {
- Authorization: authHeader,
- 'X-SHA-256': payloadHash,
- },
- body: file,
- });
-
- if (!response.ok) {
- const text = await response.text();
- throw new Error(`Upload failed (${response.status}): ${text}`);
- }
-
- const result = await response.json();
-
- this.photoUrl = result.url;
- this.status = 'Photo uploaded! Ready to publish.';
- } catch (e) {
- this.error = e.message;
- this.status = '';
- } finally {
- this.isUploading = false;
- if (event && event.target) {
- event.target.value = '';
- }
- }
+ @action
+ removeFile(fileToRemove) {
+ this.files = this.files.filter((f) => f !== fileToRemove);
+ this.uploadedPhotos = this.uploadedPhotos.filter(
+ (p) => p.file !== fileToRemove
+ );
}
@action
@@ -143,8 +87,8 @@ export default class PlacePhotoUpload extends Component {
return;
}
- if (!this.photoUrl) {
- this.error = 'Please upload a photo.';
+ if (!this.allUploaded) {
+ this.error = 'Please wait for all photos to finish uploading.';
return;
}
@@ -158,6 +102,7 @@ export default class PlacePhotoUpload extends Component {
this.status = 'Publishing event...';
this.error = '';
+ this.isPublishing = true;
try {
const factory = new EventFactory({ signer: this.nostrAuth.signer });
@@ -171,19 +116,21 @@ export default class PlacePhotoUpload extends Component {
tags.push(['g', Geohash.encode(lat, lon, 9)]);
}
- const imeta = [
- 'imeta',
- `url ${this.photoUrl}`,
- `m ${this.photoType}`,
- 'alt A photo of a place',
- ];
+ for (const photo of this.uploadedPhotos) {
+ const imeta = [
+ 'imeta',
+ `url ${photo.url}`,
+ `m ${photo.type}`,
+ 'alt A photo of a place',
+ ];
- if (this.photoDim) {
- imeta.splice(3, 0, `dim ${this.photoDim}`);
+ if (photo.dim) {
+ imeta.splice(3, 0, `dim ${photo.dim}`);
+ }
+
+ tags.push(imeta);
}
- tags.push(imeta);
-
// NIP-XX draft Place Photo event
const template = {
kind: 360,
@@ -199,16 +146,21 @@ export default class PlacePhotoUpload extends Component {
await this.nostrRelay.publish(event);
this.status = 'Published successfully!';
- this.photoUrl = '';
+
+ // Clear out the files so user can upload more or be done
+ this.files = [];
+ this.uploadedPhotos = [];
} catch (e) {
this.error = 'Failed to publish: ' + e.message;
this.status = '';
+ } finally {
+ this.isPublishing = false;
}
}
-
Add Photo for {{this.title}}
+
Add Photos for {{this.title}}
{{#if this.error}}
@@ -222,38 +174,53 @@ export default class PlacePhotoUpload extends Component {
{{/if}}
-
- {{#if this.photoUrl}}
-
-
Photo Preview:
-
-
-
- Publish Event (kind: 360)
-
- {{else}}
-
Select Photo
-
- {{#if this.isUploading}}
-
Uploading...
- {{/if}}
- {{/if}}
+
+
+
+ Drag and drop photos here, or click to browse
+
+
+
+ {{#if this.files.length}}
+
+ {{#each this.files as |file|}}
+
+ {{/each}}
+
+
+
+ {{#if this.isPublishing}}
+ Publishing...
+ {{else}}
+ Publish
+ {{this.files.length}}
+ Photo(s)
+ {{/if}}
+
+ {{/if}}
}
diff --git a/app/styles/app.css b/app/styles/app.css
index 4416dad..9389054 100644
--- a/app/styles/app.css
+++ b/app/styles/app.css
@@ -210,6 +210,118 @@ body {
height: auto;
}
+.dropzone {
+ border: 2px dashed #3a4b5c;
+ border-radius: 8px;
+ padding: 30px 20px;
+ text-align: center;
+ transition: all 0.2s ease;
+ margin-bottom: 20px;
+ background-color: rgb(255 255 255 / 2%);
+ cursor: pointer;
+}
+
+.dropzone.is-dragging {
+ border-color: #61afef;
+ background-color: rgb(97 175 239 / 5%);
+}
+
+.dropzone-label {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+ cursor: pointer;
+ color: #a0aec0;
+}
+
+.dropzone-label p {
+ margin: 0;
+ font-size: 1.1rem;
+}
+
+.file-input-hidden {
+ display: none;
+}
+
+.photo-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
+ gap: 12px;
+ margin-bottom: 20px;
+}
+
+.photo-item {
+ position: relative;
+ aspect-ratio: 1 / 1;
+ border-radius: 6px;
+ overflow: hidden;
+ background: #1e262e;
+}
+
+.photo-item-img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+
+.photo-item-overlay {
+ position: absolute;
+ inset: 0;
+ background: rgb(0 0 0 / 60%);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.error-overlay {
+ background: rgb(224 108 117 / 80%);
+}
+
+.success-overlay {
+ background: rgb(152 195 121 / 60%);
+}
+
+.btn-remove-photo {
+ position: absolute;
+ top: 4px;
+ right: 4px;
+ background: rgb(0 0 0 / 70%);
+ border: none;
+ border-radius: 50%;
+ width: 24px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ color: white;
+ padding: 0;
+}
+
+.btn-remove-photo:hover {
+ background: rgb(224 108 117 / 90%);
+}
+
+.spin-animation {
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.btn-publish {
+ width: 100%;
+}
+
/* User Menu Popover */
.user-menu-container {
position: relative;
diff --git a/app/utils/icons.js b/app/utils/icons.js
index 717b9e8..f2b3029 100644
--- a/app/utils/icons.js
+++ b/app/utils/icons.js
@@ -26,8 +26,12 @@ 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 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 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';
import zap from 'feather-icons/dist/icons/zap.svg?raw';
import angelfish from '@waysidemapping/pinhead/dist/icons/angelfish.svg?raw';
@@ -130,6 +134,8 @@ const ICONS = {
'check-square': checkSquare,
'cigarette-with-smoke-curl': cigaretteWithSmokeCurl,
climbing_wall: climbingWall,
+ check,
+ 'alert-circle': alertCircle,
'classical-building': classicalBuilding,
'classical-building-with-dome-and-flag': classicalBuildingWithDomeAndFlag,
'classical-building-with-flag': classicalBuildingWithFlag,
@@ -214,6 +220,8 @@ const ICONS = {
'tattoo-machine': tattooMachine,
toolbox,
target,
+ 'trash-2': trash2,
+ 'upload-cloud': uploadCloud,
'tree-and-bench-with-backrest': treeAndBenchWithBackrest,
user,
'village-buildings': villageBuildings,
From a2a61b0fecf51a1ad5bcac57286772897f2eef03 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Mon, 20 Apr 2026 15:22:17 +0400
Subject: [PATCH 21/56] Upload to multiple servers, delete from servers when
removing in dialog
Introduces a dedicated blossom service to tie everything together
---
app/components/place-photo-item.gjs | 74 +-----------
app/components/place-photo-upload.gjs | 37 ++++--
app/services/blossom.js | 166 ++++++++++++++++++++++++++
3 files changed, 200 insertions(+), 77 deletions(-)
create mode 100644 app/services/blossom.js
diff --git a/app/components/place-photo-item.gjs b/app/components/place-photo-item.gjs
index c9845bc..4c0069e 100644
--- a/app/components/place-photo-item.gjs
+++ b/app/components/place-photo-item.gjs
@@ -2,31 +2,17 @@ import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { task } from 'ember-concurrency';
-import { EventFactory } from 'applesauce-core';
import Icon from '#components/icon';
import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
-const DEFAULT_BLOSSOM_SERVER = 'https://blossom.nostr.build';
-
-function bufferToHex(buffer) {
- return Array.from(new Uint8Array(buffer))
- .map((b) => b.toString(16).padStart(2, '0'))
- .join('');
-}
-
export default class PlacePhotoItem extends Component {
- @service nostrAuth;
- @service nostrData;
+ @service blossom;
@tracked thumbnailUrl = '';
@tracked error = '';
@tracked isUploaded = false;
- get blossomServer() {
- return this.nostrData.blossomServers[0] || DEFAULT_BLOSSOM_SERVER;
- }
-
constructor() {
super(...arguments);
if (this.args.file) {
@@ -45,8 +31,6 @@ export default class PlacePhotoItem extends Component {
uploadTask = task(async (file) => {
this.error = '';
try {
- if (!this.nostrAuth.isConnected) throw new Error('Not connected');
-
const dim = await new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve(`${img.width}x${img.height}`);
@@ -54,65 +38,17 @@ export default class PlacePhotoItem extends Component {
img.src = this.thumbnailUrl;
});
- const buffer = await file.arrayBuffer();
- const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
- const payloadHash = bufferToHex(hashBuffer);
-
- let serverUrl = this.blossomServer;
- if (serverUrl.endsWith('/')) {
- serverUrl = serverUrl.slice(0, -1);
- }
- const uploadUrl = `${serverUrl}/upload`;
-
- 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: 'Upload photo for place',
- tags: [
- ['t', 'upload'],
- ['x', payloadHash],
- ['expiration', String(now + 3600)],
- ['server', serverHostname],
- ],
- };
-
- const authEvent = await factory.sign(authTemplate);
- const base64 = btoa(JSON.stringify(authEvent));
- const base64url = base64
- .replace(/\+/g, '-')
- .replace(/\//g, '_')
- .replace(/=+$/, '');
- const authHeader = `Nostr ${base64url}`;
-
- // eslint-disable-next-line warp-drive/no-external-request-patterns
- const response = await fetch(uploadUrl, {
- method: 'PUT',
- headers: {
- Authorization: authHeader,
- 'X-SHA-256': payloadHash,
- },
- body: file,
- });
-
- if (!response.ok) {
- const text = await response.text();
- throw new Error(`Upload failed (${response.status}): ${text}`);
- }
-
- const result = await response.json();
+ const result = await this.blossom.upload(file);
this.isUploaded = true;
if (this.args.onSuccess) {
this.args.onSuccess({
file,
url: result.url,
- type: file.type,
+ fallbackUrls: result.fallbackUrls,
+ type: result.type,
dim,
- hash: payloadHash,
+ hash: result.hash,
});
}
} catch (e) {
diff --git a/app/components/place-photo-upload.gjs b/app/components/place-photo-upload.gjs
index 94ef85d..c3bb442 100644
--- a/app/components/place-photo-upload.gjs
+++ b/app/components/place-photo-upload.gjs
@@ -4,6 +4,7 @@ import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { on } from '@ember/modifier';
import { EventFactory } from 'applesauce-core';
+import { task } from 'ember-concurrency';
import Geohash from 'latlon-geohash';
import PlacePhotoItem from './place-photo-item';
import Icon from '#components/icon';
@@ -12,6 +13,8 @@ import { or, not } from 'ember-truth-helpers';
export default class PlacePhotoUpload extends Component {
@service nostrAuth;
@service nostrRelay;
+ @service blossom;
+ @service toast;
@tracked files = [];
@tracked uploadedPhotos = [];
@@ -74,12 +77,26 @@ export default class PlacePhotoUpload extends Component {
@action
removeFile(fileToRemove) {
+ const photoData = this.uploadedPhotos.find((p) => p.file === fileToRemove);
this.files = this.files.filter((f) => f !== fileToRemove);
this.uploadedPhotos = this.uploadedPhotos.filter(
(p) => p.file !== fileToRemove
);
+
+ if (photoData && photoData.hash && photoData.url) {
+ this.deletePhotoTask.perform(photoData);
+ }
}
+ deletePhotoTask = task(async (photoData) => {
+ try {
+ if (!photoData.hash) return;
+ await this.blossom.delete(photoData.hash);
+ } catch (e) {
+ this.toast.show(`Failed to delete photo from server: ${e.message}`, 5000);
+ }
+ });
+
@action
async publish() {
if (!this.nostrAuth.isConnected) {
@@ -117,17 +134,21 @@ export default class PlacePhotoUpload extends Component {
}
for (const photo of this.uploadedPhotos) {
- const imeta = [
- 'imeta',
- `url ${photo.url}`,
- `m ${photo.type}`,
- 'alt A photo of a place',
- ];
+ const imeta = ['imeta', `url ${photo.url}`];
- if (photo.dim) {
- imeta.splice(3, 0, `dim ${photo.dim}`);
+ if (photo.fallbackUrls && photo.fallbackUrls.length > 0) {
+ for (const fallbackUrl of photo.fallbackUrls) {
+ imeta.push(`url ${fallbackUrl}`);
+ }
}
+ imeta.push(`m ${photo.type}`);
+
+ if (photo.dim) {
+ imeta.push(`dim ${photo.dim}`);
+ }
+
+ imeta.push('alt A photo of a place');
tags.push(imeta);
}
diff --git a/app/services/blossom.js b/app/services/blossom.js
new file mode 100644
index 0000000..b1e9ed9
--- /dev/null
+++ b/app/services/blossom.js
@@ -0,0 +1,166 @@
+import Service, { inject as service } from '@ember/service';
+import { EventFactory } from 'applesauce-core';
+
+export const DEFAULT_BLOSSOM_SERVER = 'https://blossom.nostr.build';
+
+function bufferToHex(buffer) {
+ return Array.from(new Uint8Array(buffer))
+ .map((b) => b.toString(16).padStart(2, '0'))
+ .join('');
+}
+
+function getBlossomUrl(serverUrl, path) {
+ let url = serverUrl || DEFAULT_BLOSSOM_SERVER;
+ if (url.endsWith('/')) {
+ url = url.slice(0, -1);
+ }
+ return path.startsWith('/') ? `${url}${path}` : `${url}/${path}`;
+}
+
+export default class BlossomService extends Service {
+ @service nostrAuth;
+ @service nostrData;
+
+ get servers() {
+ const servers = this.nostrData.blossomServers;
+ return servers.length ? servers : [DEFAULT_BLOSSOM_SERVER];
+ }
+
+ 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: [
+ ['t', action],
+ ['x', hash],
+ ['expiration', String(now + 3600)],
+ ['server', serverHostname],
+ ],
+ };
+
+ const authEvent = await factory.sign(authTemplate);
+ const base64 = btoa(JSON.stringify(authEvent));
+ const base64url = base64
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=+$/, '');
+ return `Nostr ${base64url}`;
+ }
+
+ async _uploadToServer(file, hash, serverUrl) {
+ const uploadUrl = getBlossomUrl(serverUrl, 'upload');
+ const authHeader = await this._getAuthHeader('upload', hash, serverUrl);
+
+ // eslint-disable-next-line warp-drive/no-external-request-patterns
+ const response = await fetch(uploadUrl, {
+ method: 'PUT',
+ headers: {
+ Authorization: authHeader,
+ 'X-SHA-256': hash,
+ },
+ body: file,
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(`Upload failed (${response.status}): ${text}`);
+ }
+
+ return response.json();
+ }
+
+ async upload(file) {
+ if (!this.nostrAuth.isConnected) throw new Error('Not connected');
+
+ const buffer = await file.arrayBuffer();
+ const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
+ const payloadHash = bufferToHex(hashBuffer);
+
+ const servers = this.servers;
+ const mainServer = servers[0];
+ const fallbackServers = servers.slice(1);
+
+ // Start all uploads concurrently
+ const mainPromise = this._uploadToServer(file, payloadHash, mainServer);
+ const fallbackPromises = fallbackServers.map((serverUrl) =>
+ this._uploadToServer(file, payloadHash, serverUrl)
+ );
+
+ // Main server MUST succeed
+ const mainResult = await mainPromise;
+
+ // Fallback servers can fail, but we log the warnings
+ const fallbackResults = await Promise.allSettled(fallbackPromises);
+ const fallbackUrls = [];
+
+ for (let i = 0; i < fallbackResults.length; i++) {
+ const result = fallbackResults[i];
+ if (result.status === 'fulfilled') {
+ fallbackUrls.push(result.value.url);
+ } else {
+ console.warn(
+ `Fallback upload to ${fallbackServers[i]} failed:`,
+ result.reason
+ );
+ }
+ }
+
+ return {
+ url: mainResult.url,
+ fallbackUrls,
+ hash: payloadHash,
+ type: file.type,
+ };
+ }
+
+ async _deleteFromServer(hash, serverUrl) {
+ const deleteUrl = getBlossomUrl(serverUrl, hash);
+ const authHeader = await this._getAuthHeader('delete', hash, serverUrl);
+
+ // eslint-disable-next-line warp-drive/no-external-request-patterns
+ const response = await fetch(deleteUrl, {
+ method: 'DELETE',
+ headers: {
+ Authorization: authHeader,
+ },
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(text || response.statusText);
+ }
+ }
+
+ async delete(hash) {
+ if (!this.nostrAuth.isConnected) throw new Error('Not connected');
+
+ const servers = this.servers;
+ const mainServer = servers[0];
+ const fallbackServers = servers.slice(1);
+
+ const mainPromise = this._deleteFromServer(hash, mainServer);
+ const fallbackPromises = fallbackServers.map((serverUrl) =>
+ this._deleteFromServer(hash, serverUrl)
+ );
+
+ // Main server MUST succeed
+ await mainPromise;
+
+ // Fallback servers can fail, log warnings
+ const fallbackResults = await Promise.allSettled(fallbackPromises);
+ for (let i = 0; i < fallbackResults.length; i++) {
+ const result = fallbackResults[i];
+ if (result.status === 'rejected') {
+ console.warn(
+ `Fallback delete from ${fallbackServers[i]} failed:`,
+ result.reason
+ );
+ }
+ }
+ }
+}
From 1ed66ca7445a79ca3944912524d12b968121b130 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Mon, 20 Apr 2026 15:26:28 +0400
Subject: [PATCH 22/56] Fix deprecation warning
---
app/components/nostr-connect.gjs | 2 +-
app/components/place-photo-item.gjs | 2 +-
app/components/place-photo-upload.gjs | 2 +-
app/components/user-menu.gjs | 2 +-
app/services/blossom.js | 2 +-
app/services/nostr-auth.js | 2 +-
app/services/nostr-data.js | 2 +-
7 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/app/components/nostr-connect.gjs b/app/components/nostr-connect.gjs
index 9e278ad..de728ac 100644
--- a/app/components/nostr-connect.gjs
+++ b/app/components/nostr-connect.gjs
@@ -1,6 +1,6 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
-import { inject as service } from '@ember/service';
+import { service } from '@ember/service';
import { on } from '@ember/modifier';
import { eq } from 'ember-truth-helpers';
import qrCode from '../modifiers/qr-code';
diff --git a/app/components/place-photo-item.gjs b/app/components/place-photo-item.gjs
index 4c0069e..7581afa 100644
--- a/app/components/place-photo-item.gjs
+++ b/app/components/place-photo-item.gjs
@@ -1,6 +1,6 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
-import { inject as service } from '@ember/service';
+import { service } from '@ember/service';
import { task } from 'ember-concurrency';
import Icon from '#components/icon';
import { on } from '@ember/modifier';
diff --git a/app/components/place-photo-upload.gjs b/app/components/place-photo-upload.gjs
index c3bb442..f85e75a 100644
--- a/app/components/place-photo-upload.gjs
+++ b/app/components/place-photo-upload.gjs
@@ -1,7 +1,7 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
-import { inject as service } from '@ember/service';
+import { service } from '@ember/service';
import { on } from '@ember/modifier';
import { EventFactory } from 'applesauce-core';
import { task } from 'ember-concurrency';
diff --git a/app/components/user-menu.gjs b/app/components/user-menu.gjs
index 6b92d66..0f0af0b 100644
--- a/app/components/user-menu.gjs
+++ b/app/components/user-menu.gjs
@@ -1,6 +1,6 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
-import { inject as service } from '@ember/service';
+import { service } from '@ember/service';
import Icon from '#components/icon';
import { on } from '@ember/modifier';
import { tracked } from '@glimmer/tracking';
diff --git a/app/services/blossom.js b/app/services/blossom.js
index b1e9ed9..05b9e38 100644
--- a/app/services/blossom.js
+++ b/app/services/blossom.js
@@ -1,4 +1,4 @@
-import Service, { inject as service } from '@ember/service';
+import Service, { service } from '@ember/service';
import { EventFactory } from 'applesauce-core';
export const DEFAULT_BLOSSOM_SERVER = 'https://blossom.nostr.build';
diff --git a/app/services/nostr-auth.js b/app/services/nostr-auth.js
index cce17d4..7814302 100644
--- a/app/services/nostr-auth.js
+++ b/app/services/nostr-auth.js
@@ -1,4 +1,4 @@
-import Service, { inject as service } from '@ember/service';
+import Service, { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import {
ExtensionSigner,
diff --git a/app/services/nostr-data.js b/app/services/nostr-data.js
index adf99e9..c737046 100644
--- a/app/services/nostr-data.js
+++ b/app/services/nostr-data.js
@@ -1,4 +1,4 @@
-import Service, { inject as service } from '@ember/service';
+import Service, { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { EventStore } from 'applesauce-core/event-store';
import { ProfileModel } from 'applesauce-core/models/profile';
From 79777fb51a4d1eb102b544d8f79f61f6d86d3ce7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Mon, 20 Apr 2026 16:24:28 +0400
Subject: [PATCH 23/56] Process images before upload, add thumbnails, blurhash
---
app/components/place-photo-item.gjs | 56 +++++++++++++++-----
app/components/place-photo-upload.gjs | 29 ++++++++---
app/services/image-processor.js | 74 +++++++++++++++++++++++++++
package.json | 1 +
pnpm-lock.yaml | 8 +++
5 files changed, 148 insertions(+), 20 deletions(-)
create mode 100644 app/services/image-processor.js
diff --git a/app/components/place-photo-item.gjs b/app/components/place-photo-item.gjs
index 7581afa..97493b1 100644
--- a/app/components/place-photo-item.gjs
+++ b/app/components/place-photo-item.gjs
@@ -6,8 +6,14 @@ import Icon from '#components/icon';
import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
+const MAX_IMAGE_DIMENSION = 1920;
+const IMAGE_QUALITY = 0.94;
+const MAX_THUMBNAIL_DIMENSION = 350;
+const THUMBNAIL_QUALITY = 0.9;
+
export default class PlacePhotoItem extends Component {
@service blossom;
+ @service imageProcessor;
@tracked thumbnailUrl = '';
@tracked error = '';
@@ -31,24 +37,50 @@ export default class PlacePhotoItem extends Component {
uploadTask = task(async (file) => {
this.error = '';
try {
- const dim = await new Promise((resolve) => {
- const img = new Image();
- img.onload = () => resolve(`${img.width}x${img.height}`);
- img.onerror = () => resolve('');
- img.src = this.thumbnailUrl;
- });
+ // 1. Process main image
+ const mainData = await this.imageProcessor.process(
+ file,
+ MAX_IMAGE_DIMENSION,
+ IMAGE_QUALITY
+ );
+
+ // 2. Generate blurhash from main image data
+ const blurhash = await this.imageProcessor.generateBlurhash(
+ mainData.imageData
+ );
+
+ // 3. Process thumbnail
+ const thumbData = await this.imageProcessor.process(
+ file,
+ MAX_THUMBNAIL_DIMENSION,
+ THUMBNAIL_QUALITY
+ );
+
+ // 4. Upload main image (to all servers concurrently)
+ const mainUploadPromise = this.blossom.upload(mainData.blob);
+
+ // 5. Upload thumbnail (to all servers concurrently)
+ const thumbUploadPromise = this.blossom.upload(thumbData.blob);
+
+ // Await both uploads
+ const [mainResult, thumbResult] = await Promise.all([
+ mainUploadPromise,
+ thumbUploadPromise,
+ ]);
- const result = await this.blossom.upload(file);
this.isUploaded = true;
if (this.args.onSuccess) {
this.args.onSuccess({
file,
- url: result.url,
- fallbackUrls: result.fallbackUrls,
- type: result.type,
- dim,
- hash: result.hash,
+ url: mainResult.url,
+ fallbackUrls: mainResult.fallbackUrls,
+ thumbUrl: thumbResult.url,
+ blurhash,
+ type: 'image/jpeg',
+ dim: mainData.dim,
+ hash: mainResult.hash,
+ thumbHash: thumbResult.hash,
});
}
} catch (e) {
diff --git a/app/components/place-photo-upload.gjs b/app/components/place-photo-upload.gjs
index f85e75a..7f6df7a 100644
--- a/app/components/place-photo-upload.gjs
+++ b/app/components/place-photo-upload.gjs
@@ -90,8 +90,12 @@ export default class PlacePhotoUpload extends Component {
deletePhotoTask = task(async (photoData) => {
try {
- if (!photoData.hash) return;
- await this.blossom.delete(photoData.hash);
+ if (photoData.hash) {
+ await this.blossom.delete(photoData.hash);
+ }
+ if (photoData.thumbHash) {
+ await this.blossom.delete(photoData.thumbHash);
+ }
} catch (e) {
this.toast.show(`Failed to delete photo from server: ${e.message}`, 5000);
}
@@ -136,12 +140,6 @@ export default class PlacePhotoUpload extends Component {
for (const photo of this.uploadedPhotos) {
const imeta = ['imeta', `url ${photo.url}`];
- if (photo.fallbackUrls && photo.fallbackUrls.length > 0) {
- for (const fallbackUrl of photo.fallbackUrls) {
- imeta.push(`url ${fallbackUrl}`);
- }
- }
-
imeta.push(`m ${photo.type}`);
if (photo.dim) {
@@ -149,6 +147,21 @@ export default class PlacePhotoUpload extends Component {
}
imeta.push('alt A photo of a place');
+
+ if (photo.fallbackUrls && photo.fallbackUrls.length > 0) {
+ for (const fallbackUrl of photo.fallbackUrls) {
+ imeta.push(`fallback ${fallbackUrl}`);
+ }
+ }
+
+ if (photo.thumbUrl) {
+ imeta.push(`thumb ${photo.thumbUrl}`);
+ }
+
+ if (photo.blurhash) {
+ imeta.push(`blurhash ${photo.blurhash}`);
+ }
+
tags.push(imeta);
}
diff --git a/app/services/image-processor.js b/app/services/image-processor.js
new file mode 100644
index 0000000..9cc4b2e
--- /dev/null
+++ b/app/services/image-processor.js
@@ -0,0 +1,74 @@
+import Service from '@ember/service';
+import { encode } from 'blurhash';
+
+export default class ImageProcessorService extends Service {
+ async process(file, maxDimension, quality) {
+ return new Promise((resolve, reject) => {
+ const url = URL.createObjectURL(file);
+ const img = new Image();
+
+ img.onload = () => {
+ URL.revokeObjectURL(url);
+
+ let width = img.width;
+ let height = img.height;
+
+ if (width > height) {
+ if (width > maxDimension) {
+ height = Math.round(height * (maxDimension / width));
+ width = maxDimension;
+ }
+ } else {
+ if (height > maxDimension) {
+ width = Math.round(width * (maxDimension / height));
+ height = maxDimension;
+ }
+ }
+
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+
+ const ctx = canvas.getContext('2d');
+ if (!ctx) {
+ return reject(new Error('Failed to get canvas context'));
+ }
+
+ // Draw image on canvas, this inherently strips EXIF/metadata
+ ctx.drawImage(img, 0, 0, width, height);
+
+ const imageData = ctx.getImageData(0, 0, width, height);
+ const dim = `${width}x${height}`;
+
+ canvas.toBlob(
+ (blob) => {
+ if (blob) {
+ resolve({ blob, dim, imageData });
+ } else {
+ reject(new Error('Canvas toBlob failed'));
+ }
+ },
+ 'image/jpeg',
+ quality
+ );
+ };
+
+ img.onerror = () => {
+ URL.revokeObjectURL(url);
+ reject(new Error('Failed to load image for processing'));
+ };
+
+ img.src = url;
+ });
+ }
+
+ async generateBlurhash(imageData, componentX = 4, componentY = 3) {
+ return encode(
+ imageData.data,
+ imageData.width,
+ imageData.height,
+ componentX,
+ componentY
+ );
+ }
+}
diff --git a/package.json b/package.json
index 3c16d93..d0cec30 100644
--- a/package.json
+++ b/package.json
@@ -107,6 +107,7 @@
"applesauce-factory": "^4.0.0",
"applesauce-relay": "^5.2.0",
"applesauce-signers": "^5.2.0",
+ "blurhash": "^2.0.5",
"ember-concurrency": "^5.2.0",
"ember-lifeline": "^7.0.0",
"oauth2-pkce": "^2.1.3",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3c7b37a..4fc083f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -23,6 +23,9 @@ importers:
applesauce-signers:
specifier: ^5.2.0
version: 5.2.0(@capacitor/core@7.6.2)(typescript@5.9.3)
+ blurhash:
+ specifier: ^2.0.5
+ version: 2.0.5
ember-concurrency:
specifier: ^5.2.0
version: 5.2.0(@babel/core@7.28.6)
@@ -2044,6 +2047,9 @@ packages:
bluebird@3.7.2:
resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==}
+ blurhash@2.0.5:
+ resolution: {integrity: sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==}
+
body-parser@1.20.4:
resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
@@ -8053,6 +8059,8 @@ snapshots:
bluebird@3.7.2: {}
+ blurhash@2.0.5: {}
+
body-parser@1.20.4:
dependencies:
bytes: 3.1.2
From b7cce6eb7efc62e16f7ec78414d684d5feac0877 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Mon, 20 Apr 2026 16:39:37 +0400
Subject: [PATCH 24/56] Improve dropzone styles
---
app/components/place-photo-upload.gjs | 2 +-
app/styles/app.css | 13 ++++++-------
2 files changed, 7 insertions(+), 8 deletions(-)
diff --git a/app/components/place-photo-upload.gjs b/app/components/place-photo-upload.gjs
index 7f6df7a..8a45c7e 100644
--- a/app/components/place-photo-upload.gjs
+++ b/app/components/place-photo-upload.gjs
@@ -215,7 +215,7 @@ export default class PlacePhotoUpload extends Component {
{{on "drop" this.handleDrop}}
>
-
+
Drag and drop photos here, or click to browse
Date: Mon, 20 Apr 2026 16:56:51 +0400
Subject: [PATCH 25/56] Move image processing to worker
---
app/components/place-photo-item.gjs | 21 ++---
app/services/image-processor.js | 136 +++++++++++++++-------------
app/styles/app.css | 2 +-
app/workers/image-processor.js | 68 ++++++++++++++
4 files changed, 150 insertions(+), 77 deletions(-)
create mode 100644 app/workers/image-processor.js
diff --git a/app/components/place-photo-item.gjs b/app/components/place-photo-item.gjs
index 97493b1..6820a33 100644
--- a/app/components/place-photo-item.gjs
+++ b/app/components/place-photo-item.gjs
@@ -37,29 +37,26 @@ export default class PlacePhotoItem extends Component {
uploadTask = task(async (file) => {
this.error = '';
try {
- // 1. Process main image
+ // 1. Process main image and generate blurhash in worker
const mainData = await this.imageProcessor.process(
file,
MAX_IMAGE_DIMENSION,
- IMAGE_QUALITY
+ IMAGE_QUALITY,
+ true // computeBlurhash
);
- // 2. Generate blurhash from main image data
- const blurhash = await this.imageProcessor.generateBlurhash(
- mainData.imageData
- );
-
- // 3. Process thumbnail
+ // 2. Process thumbnail (no blurhash needed)
const thumbData = await this.imageProcessor.process(
file,
MAX_THUMBNAIL_DIMENSION,
- THUMBNAIL_QUALITY
+ THUMBNAIL_QUALITY,
+ false
);
- // 4. Upload main image (to all servers concurrently)
+ // 3. Upload main image (to all servers concurrently)
const mainUploadPromise = this.blossom.upload(mainData.blob);
- // 5. Upload thumbnail (to all servers concurrently)
+ // 4. Upload thumbnail (to all servers concurrently)
const thumbUploadPromise = this.blossom.upload(thumbData.blob);
// Await both uploads
@@ -76,7 +73,7 @@ export default class PlacePhotoItem extends Component {
url: mainResult.url,
fallbackUrls: mainResult.fallbackUrls,
thumbUrl: thumbResult.url,
- blurhash,
+ blurhash: mainData.blurhash,
type: 'image/jpeg',
dim: mainData.dim,
hash: mainResult.hash,
diff --git a/app/services/image-processor.js b/app/services/image-processor.js
index 9cc4b2e..e9402d9 100644
--- a/app/services/image-processor.js
+++ b/app/services/image-processor.js
@@ -1,74 +1,82 @@
import Service from '@ember/service';
-import { encode } from 'blurhash';
+// We use the special Vite query parameter to load this as a web worker
+import Worker from '../workers/image-processor?worker';
export default class ImageProcessorService extends Service {
- async process(file, maxDimension, quality) {
+ _worker = null;
+ _callbacks = new Map();
+ _msgId = 0;
+
+ constructor() {
+ super(...arguments);
+ this._initWorker();
+ }
+
+ _initWorker() {
+ if (!this._worker && typeof Worker !== 'undefined') {
+ try {
+ this._worker = new Worker();
+ this._worker.onmessage = this._handleMessage.bind(this);
+ this._worker.onerror = this._handleError.bind(this);
+ } catch (e) {
+ console.warn('Failed to initialize image-processor worker:', e);
+ }
+ }
+ }
+
+ _handleMessage(e) {
+ const { id, success, blob, dim, blurhash, error } = e.data;
+ const resolver = this._callbacks.get(id);
+
+ if (resolver) {
+ this._callbacks.delete(id);
+ if (success) {
+ resolver.resolve({ blob, dim, blurhash });
+ } else {
+ resolver.reject(new Error(error));
+ }
+ }
+ }
+
+ _handleError(error) {
+ console.error('Image Processor Worker Error:', error);
+ // Reject all pending jobs
+ for (const [, resolver] of this._callbacks.entries()) {
+ resolver.reject(new Error('Worker crashed'));
+ }
+ this._callbacks.clear();
+ // Restart the worker for future jobs
+ this._worker.terminate();
+ this._worker = null;
+ this._initWorker();
+ }
+
+ async process(file, maxDimension, quality, computeBlurhash = false) {
+ if (!this._worker) {
+ // Fallback if worker initialization failed (e.g. incredibly old browsers)
+ throw new Error('Image processor worker is not available.');
+ }
+
return new Promise((resolve, reject) => {
- const url = URL.createObjectURL(file);
- const img = new Image();
+ const id = ++this._msgId;
+ this._callbacks.set(id, { resolve, reject });
- img.onload = () => {
- URL.revokeObjectURL(url);
-
- let width = img.width;
- let height = img.height;
-
- if (width > height) {
- if (width > maxDimension) {
- height = Math.round(height * (maxDimension / width));
- width = maxDimension;
- }
- } else {
- if (height > maxDimension) {
- width = Math.round(width * (maxDimension / height));
- height = maxDimension;
- }
- }
-
- const canvas = document.createElement('canvas');
- canvas.width = width;
- canvas.height = height;
-
- const ctx = canvas.getContext('2d');
- if (!ctx) {
- return reject(new Error('Failed to get canvas context'));
- }
-
- // Draw image on canvas, this inherently strips EXIF/metadata
- ctx.drawImage(img, 0, 0, width, height);
-
- const imageData = ctx.getImageData(0, 0, width, height);
- const dim = `${width}x${height}`;
-
- canvas.toBlob(
- (blob) => {
- if (blob) {
- resolve({ blob, dim, imageData });
- } else {
- reject(new Error('Canvas toBlob failed'));
- }
- },
- 'image/jpeg',
- quality
- );
- };
-
- img.onerror = () => {
- URL.revokeObjectURL(url);
- reject(new Error('Failed to load image for processing'));
- };
-
- img.src = url;
+ this._worker.postMessage({
+ id,
+ file,
+ maxDimension,
+ quality,
+ computeBlurhash,
+ });
});
}
- async generateBlurhash(imageData, componentX = 4, componentY = 3) {
- return encode(
- imageData.data,
- imageData.width,
- imageData.height,
- componentX,
- componentY
- );
+ willDestroy() {
+ super.willDestroy(...arguments);
+ if (this._worker) {
+ this._worker.terminate();
+ this._worker = null;
+ }
+ this._callbacks.clear();
}
}
diff --git a/app/styles/app.css b/app/styles/app.css
index 0ef1904..356f806 100644
--- a/app/styles/app.css
+++ b/app/styles/app.css
@@ -216,7 +216,7 @@ body {
padding: 2rem 1.5rem;
text-align: center;
transition: all 0.2s ease;
- margin: 1.5rem 0 1rem 0;
+ margin: 1.5rem 0 1rem;
background-color: rgb(255 255 255 / 2%);
cursor: pointer;
}
diff --git a/app/workers/image-processor.js b/app/workers/image-processor.js
new file mode 100644
index 0000000..9b99672
--- /dev/null
+++ b/app/workers/image-processor.js
@@ -0,0 +1,68 @@
+import { encode } from 'blurhash';
+
+self.onmessage = async (e) => {
+ const { id, file, maxDimension, quality, computeBlurhash } = e.data;
+
+ try {
+ // 1. Decode image off main thread
+ const bitmap = await createImageBitmap(file);
+
+ let width = bitmap.width;
+ let height = bitmap.height;
+
+ // 2. Calculate aspect-ratio preserving dimensions
+ if (width > height) {
+ if (width > maxDimension) {
+ height = Math.round(height * (maxDimension / width));
+ width = maxDimension;
+ }
+ } else {
+ if (height > maxDimension) {
+ width = Math.round(width * (maxDimension / height));
+ height = maxDimension;
+ }
+ }
+
+ // 3. Create OffscreenCanvas and draw
+ const canvas = new OffscreenCanvas(width, height);
+ const ctx = canvas.getContext('2d');
+
+ if (!ctx) {
+ throw new Error('Failed to get 2d context from OffscreenCanvas');
+ }
+
+ ctx.drawImage(bitmap, 0, 0, width, height);
+
+ // 4. Generate Blurhash (if requested)
+ let blurhash = null;
+ if (computeBlurhash) {
+ const imageData = ctx.getImageData(0, 0, width, height);
+ blurhash = encode(imageData.data, width, height, 4, 3);
+ }
+
+ // 5. Compress to JPEG Blob
+ const blob = await canvas.convertToBlob({
+ type: 'image/jpeg',
+ quality: quality,
+ });
+
+ const dim = `${width}x${height}`;
+
+ // 6. Send results back to main thread
+ self.postMessage({
+ id,
+ success: true,
+ blob,
+ dim,
+ blurhash,
+ });
+
+ bitmap.close();
+ } catch (error) {
+ self.postMessage({
+ id,
+ success: false,
+ error: error.message,
+ });
+ }
+};
From ec31d1a59b7042a006f8cc4ecdb5a8d2c8500b83 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Mon, 20 Apr 2026 18:10:48 +0400
Subject: [PATCH 26/56] Harden image processing, improve image quality
---
app/components/place-photo-item.gjs | 18 +++-
app/services/blossom.js | 14 ++-
app/services/image-processor.js | 67 +++++++++++---
app/styles/app.css | 5 ++
app/workers/image-processor.js | 130 ++++++++++++++++++++--------
package.json | 1 +
pnpm-lock.yaml | 7 +-
7 files changed, 193 insertions(+), 49 deletions(-)
diff --git a/app/components/place-photo-item.gjs b/app/components/place-photo-item.gjs
index 6820a33..ff7543e 100644
--- a/app/components/place-photo-item.gjs
+++ b/app/components/place-photo-item.gjs
@@ -1,6 +1,7 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { service } from '@ember/service';
+import { action } from '@ember/object';
import { task } from 'ember-concurrency';
import Icon from '#components/icon';
import { on } from '@ember/modifier';
@@ -14,6 +15,7 @@ const THUMBNAIL_QUALITY = 0.9;
export default class PlacePhotoItem extends Component {
@service blossom;
@service imageProcessor;
+ @service toast;
@tracked thumbnailUrl = '';
@tracked error = '';
@@ -34,6 +36,13 @@ export default class PlacePhotoItem extends Component {
}
}
+ @action
+ showErrorToast() {
+ if (this.error) {
+ this.toast.show(this.error);
+ }
+ }
+
uploadTask = task(async (file) => {
this.error = '';
try {
@@ -105,9 +114,14 @@ export default class PlacePhotoItem extends Component {
{{/if}}
{{#if this.error}}
-
+
-
+
{{/if}}
{{#if this.isUploaded}}
diff --git a/app/services/blossom.js b/app/services/blossom.js
index 05b9e38..709a4fc 100644
--- a/app/services/blossom.js
+++ b/app/services/blossom.js
@@ -1,5 +1,6 @@
import Service, { service } from '@ember/service';
import { EventFactory } from 'applesauce-core';
+import { sha256 } from '@noble/hashes/sha2.js';
export const DEFAULT_BLOSSOM_SERVER = 'https://blossom.nostr.build';
@@ -78,7 +79,18 @@ export default class BlossomService extends Service {
if (!this.nostrAuth.isConnected) throw new Error('Not connected');
const buffer = await file.arrayBuffer();
- const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
+ let hashBuffer;
+
+ if (
+ typeof crypto !== 'undefined' &&
+ crypto.subtle &&
+ crypto.subtle.digest
+ ) {
+ hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
+ } else {
+ hashBuffer = sha256(new Uint8Array(buffer));
+ }
+
const payloadHash = bufferToHex(hashBuffer);
const servers = this.servers;
diff --git a/app/services/image-processor.js b/app/services/image-processor.js
index e9402d9..792ad0b 100644
--- a/app/services/image-processor.js
+++ b/app/services/image-processor.js
@@ -51,24 +51,71 @@ export default class ImageProcessorService extends Service {
this._initWorker();
}
+ _getImageDimensions(file) {
+ return new Promise((resolve, reject) => {
+ const img = new Image();
+ const url = URL.createObjectURL(file);
+
+ img.onload = () => {
+ const dimensions = { width: img.width, height: img.height };
+ URL.revokeObjectURL(url);
+ resolve(dimensions);
+ };
+
+ img.onerror = () => {
+ URL.revokeObjectURL(url);
+ reject(new Error('Could not read image dimensions'));
+ };
+
+ img.src = url;
+ });
+ }
+
async process(file, maxDimension, quality, computeBlurhash = false) {
if (!this._worker) {
// Fallback if worker initialization failed (e.g. incredibly old browsers)
throw new Error('Image processor worker is not available.');
}
- return new Promise((resolve, reject) => {
- const id = ++this._msgId;
- this._callbacks.set(id, { resolve, reject });
+ try {
+ // 1. Get dimensions safely on the main thread
+ const { width: origWidth, height: origHeight } =
+ await this._getImageDimensions(file);
- this._worker.postMessage({
- id,
- file,
- maxDimension,
- quality,
- computeBlurhash,
+ // 2. Calculate aspect-ratio preserving dimensions
+ let targetWidth = origWidth;
+ let targetHeight = origHeight;
+
+ if (origWidth > origHeight) {
+ if (origWidth > maxDimension) {
+ targetHeight = Math.round(origHeight * (maxDimension / origWidth));
+ targetWidth = maxDimension;
+ }
+ } else {
+ if (origHeight > maxDimension) {
+ targetWidth = Math.round(origWidth * (maxDimension / origHeight));
+ targetHeight = maxDimension;
+ }
+ }
+
+ // 3. Send to worker for processing
+ return new Promise((resolve, reject) => {
+ const id = ++this._msgId;
+ this._callbacks.set(id, { resolve, reject });
+
+ this._worker.postMessage({
+ type: 'PROCESS_IMAGE',
+ id,
+ file,
+ targetWidth,
+ targetHeight,
+ quality,
+ computeBlurhash,
+ });
});
- });
+ } catch (e) {
+ throw new Error(`Failed to process image: ${e.message}`);
+ }
}
willDestroy() {
diff --git a/app/styles/app.css b/app/styles/app.css
index 356f806..a21c6d6 100644
--- a/app/styles/app.css
+++ b/app/styles/app.css
@@ -276,6 +276,11 @@ body {
.error-overlay {
background: rgb(224 108 117 / 80%);
+ cursor: pointer;
+ border: none;
+ padding: 0;
+ margin: 0;
+ width: 100%;
}
.success-overlay {
diff --git a/app/workers/image-processor.js b/app/workers/image-processor.js
index 9b99672..830036c 100644
--- a/app/workers/image-processor.js
+++ b/app/workers/image-processor.js
@@ -1,63 +1,125 @@
import { encode } from 'blurhash';
self.onmessage = async (e) => {
- const { id, file, maxDimension, quality, computeBlurhash } = e.data;
+ // Ignore internal browser/Vite/extension pings that don't match our exact job signature
+ if (e.data?.type !== 'PROCESS_IMAGE') return;
+
+ const { id, file, targetWidth, targetHeight, quality, computeBlurhash } =
+ e.data;
try {
- // 1. Decode image off main thread
- const bitmap = await createImageBitmap(file);
+ let finalCanvas;
+ let finalCtx;
- let width = bitmap.width;
- let height = bitmap.height;
+ // --- 1. Attempt Hardware Resizing (Happy Path) ---
+ try {
+ const resizedBitmap = await createImageBitmap(file, {
+ resizeWidth: targetWidth,
+ resizeHeight: targetHeight,
+ resizeQuality: 'high',
+ });
- // 2. Calculate aspect-ratio preserving dimensions
- if (width > height) {
- if (width > maxDimension) {
- height = Math.round(height * (maxDimension / width));
- width = maxDimension;
+ finalCanvas = new OffscreenCanvas(targetWidth, targetHeight);
+ finalCtx = finalCanvas.getContext('2d');
+ if (!finalCtx) {
+ throw new Error('Failed to get 2d context from OffscreenCanvas');
}
- } else {
- if (height > maxDimension) {
- width = Math.round(width * (maxDimension / height));
- height = maxDimension;
+ finalCtx.drawImage(resizedBitmap, 0, 0, targetWidth, targetHeight);
+ resizedBitmap.close();
+ } catch (hwError) {
+ console.warn(
+ 'Hardware resize failed, falling back to stepped software scaling:',
+ hwError
+ );
+
+ // --- 2. Fallback to Stepped Software Scaling (Robust Path) ---
+ // Bypass Android File descriptor bug by reading into memory
+ const buffer = await file.arrayBuffer();
+ const blob = new Blob([buffer], { type: file.type });
+
+ const source = await createImageBitmap(blob);
+ let srcWidth = source.width;
+ let srcHeight = source.height;
+
+ let currentCanvas = new OffscreenCanvas(srcWidth, srcHeight);
+ let ctx = currentCanvas.getContext('2d');
+
+ ctx.imageSmoothingEnabled = true;
+ ctx.imageSmoothingQuality = 'high';
+ ctx.drawImage(source, 0, 0);
+
+ // Step down by halves until near target
+ while (
+ currentCanvas.width * 0.5 > targetWidth &&
+ currentCanvas.height * 0.5 > targetHeight
+ ) {
+ const nextCanvas = new OffscreenCanvas(
+ Math.floor(currentCanvas.width * 0.5),
+ Math.floor(currentCanvas.height * 0.5)
+ );
+ const nextCtx = nextCanvas.getContext('2d');
+
+ nextCtx.imageSmoothingEnabled = true;
+ nextCtx.imageSmoothingQuality = 'high';
+
+ nextCtx.drawImage(
+ currentCanvas,
+ 0,
+ 0,
+ nextCanvas.width,
+ nextCanvas.height
+ );
+
+ currentCanvas = nextCanvas;
}
+
+ // Final resize to exact target
+ finalCanvas = new OffscreenCanvas(targetWidth, targetHeight);
+ finalCtx = finalCanvas.getContext('2d');
+
+ finalCtx.imageSmoothingEnabled = true;
+ finalCtx.imageSmoothingQuality = 'high';
+
+ finalCtx.drawImage(currentCanvas, 0, 0, targetWidth, targetHeight);
+
+ source.close();
}
- // 3. Create OffscreenCanvas and draw
- const canvas = new OffscreenCanvas(width, height);
- const ctx = canvas.getContext('2d');
-
- if (!ctx) {
- throw new Error('Failed to get 2d context from OffscreenCanvas');
- }
-
- ctx.drawImage(bitmap, 0, 0, width, height);
-
- // 4. Generate Blurhash (if requested)
+ // --- 3. Generate Blurhash (if requested) ---
let blurhash = null;
if (computeBlurhash) {
- const imageData = ctx.getImageData(0, 0, width, height);
- blurhash = encode(imageData.data, width, height, 4, 3);
+ try {
+ const imageData = finalCtx.getImageData(
+ 0,
+ 0,
+ targetWidth,
+ targetHeight
+ );
+ blurhash = encode(imageData.data, targetWidth, targetHeight, 4, 3);
+ } catch (blurhashError) {
+ console.warn(
+ 'Could not generate blurhash (possible canvas fingerprinting protection):',
+ blurhashError
+ );
+ }
}
- // 5. Compress to JPEG Blob
- const blob = await canvas.convertToBlob({
+ // --- 4. Compress to JPEG Blob ---
+ const finalBlob = await finalCanvas.convertToBlob({
type: 'image/jpeg',
quality: quality,
});
- const dim = `${width}x${height}`;
+ const dim = `${targetWidth}x${targetHeight}`;
- // 6. Send results back to main thread
+ // --- 5. Send results back to main thread ---
self.postMessage({
id,
success: true,
- blob,
+ blob: finalBlob,
dim,
blurhash,
});
-
- bitmap.close();
} catch (error) {
self.postMessage({
id,
diff --git a/package.json b/package.json
index d0cec30..aae5195 100644
--- a/package.json
+++ b/package.json
@@ -102,6 +102,7 @@
"edition": "octane"
},
"dependencies": {
+ "@noble/hashes": "^2.2.0",
"@waysidemapping/pinhead": "^15.20.0",
"applesauce-core": "^5.2.0",
"applesauce-factory": "^4.0.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4fc083f..ad0a0df 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
+ '@noble/hashes':
+ specifier: ^2.2.0
+ version: 2.2.0
'@waysidemapping/pinhead':
specifier: ^15.20.0
version: 15.20.0
@@ -7543,7 +7546,7 @@ snapshots:
'@scure/bip32@1.3.1':
dependencies:
'@noble/curves': 1.1.0
- '@noble/hashes': 1.3.1
+ '@noble/hashes': 1.3.2
'@scure/base': 1.1.1
'@scure/bip32@1.7.0':
@@ -7560,7 +7563,7 @@ snapshots:
'@scure/bip39@1.2.1':
dependencies:
- '@noble/hashes': 1.3.1
+ '@noble/hashes': 1.3.2
'@scure/base': 1.1.1
'@scure/bip39@2.0.1':
From 5cd384cf3a566da049cd6210419c1566c188a81d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Mon, 20 Apr 2026 19:37:24 +0400
Subject: [PATCH 27/56] Do sequential image processing/uploads on mobile
Uploading multiple large files at once can fail easily
---
app/components/place-photo-item.gjs | 31 ++++++++++-----
app/services/blossom.js | 62 +++++++++++++++++++----------
app/services/nostr-auth.js | 4 +-
app/utils/device.js | 4 ++
4 files changed, 70 insertions(+), 31 deletions(-)
create mode 100644 app/utils/device.js
diff --git a/app/components/place-photo-item.gjs b/app/components/place-photo-item.gjs
index ff7543e..37a2415 100644
--- a/app/components/place-photo-item.gjs
+++ b/app/components/place-photo-item.gjs
@@ -6,6 +6,7 @@ import { task } from 'ember-concurrency';
import Icon from '#components/icon';
import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
+import { isMobile } from '../utils/device';
const MAX_IMAGE_DIMENSION = 1920;
const IMAGE_QUALITY = 0.94;
@@ -62,17 +63,29 @@ export default class PlacePhotoItem extends Component {
false
);
- // 3. Upload main image (to all servers concurrently)
- const mainUploadPromise = this.blossom.upload(mainData.blob);
+ // 3. Upload main image
+ // 4. Upload thumbnail
+ let mainResult, thumbResult;
+ const isMobileDevice = isMobile();
- // 4. Upload thumbnail (to all servers concurrently)
- const thumbUploadPromise = this.blossom.upload(thumbData.blob);
+ if (isMobileDevice) {
+ // Mobile: sequential uploads to preserve bandwidth and memory
+ mainResult = await this.blossom.upload(mainData.blob, {
+ sequential: true,
+ });
+ thumbResult = await this.blossom.upload(thumbData.blob, {
+ sequential: true,
+ });
+ } else {
+ // Desktop: concurrent uploads
+ const mainUploadPromise = this.blossom.upload(mainData.blob);
+ const thumbUploadPromise = this.blossom.upload(thumbData.blob);
- // Await both uploads
- const [mainResult, thumbResult] = await Promise.all([
- mainUploadPromise,
- thumbUploadPromise,
- ]);
+ [mainResult, thumbResult] = await Promise.all([
+ mainUploadPromise,
+ thumbUploadPromise,
+ ]);
+ }
this.isUploaded = true;
diff --git a/app/services/blossom.js b/app/services/blossom.js
index 709a4fc..05b3648 100644
--- a/app/services/blossom.js
+++ b/app/services/blossom.js
@@ -75,7 +75,7 @@ export default class BlossomService extends Service {
return response.json();
}
- async upload(file) {
+ async upload(file, options = { sequential: false }) {
if (!this.nostrAuth.isConnected) throw new Error('Not connected');
const buffer = await file.arrayBuffer();
@@ -97,28 +97,48 @@ export default class BlossomService extends Service {
const mainServer = servers[0];
const fallbackServers = servers.slice(1);
- // Start all uploads concurrently
- const mainPromise = this._uploadToServer(file, payloadHash, mainServer);
- const fallbackPromises = fallbackServers.map((serverUrl) =>
- this._uploadToServer(file, payloadHash, serverUrl)
- );
-
- // Main server MUST succeed
- const mainResult = await mainPromise;
-
- // Fallback servers can fail, but we log the warnings
- const fallbackResults = await Promise.allSettled(fallbackPromises);
const fallbackUrls = [];
+ let mainResult;
- for (let i = 0; i < fallbackResults.length; i++) {
- const result = fallbackResults[i];
- if (result.status === 'fulfilled') {
- fallbackUrls.push(result.value.url);
- } else {
- console.warn(
- `Fallback upload to ${fallbackServers[i]} failed:`,
- result.reason
- );
+ if (options.sequential) {
+ // Sequential upload logic
+ mainResult = await this._uploadToServer(file, payloadHash, mainServer);
+
+ for (const serverUrl of fallbackServers) {
+ try {
+ const result = await this._uploadToServer(
+ file,
+ payloadHash,
+ serverUrl
+ );
+ fallbackUrls.push(result.url);
+ } catch (error) {
+ console.warn(`Fallback upload to ${serverUrl} failed:`, error);
+ }
+ }
+ } else {
+ // Concurrent upload logic
+ const mainPromise = this._uploadToServer(file, payloadHash, mainServer);
+ const fallbackPromises = fallbackServers.map((serverUrl) =>
+ this._uploadToServer(file, payloadHash, serverUrl)
+ );
+
+ // Main server MUST succeed
+ mainResult = await mainPromise;
+
+ // Fallback servers can fail, but we log the warnings
+ const fallbackResults = await Promise.allSettled(fallbackPromises);
+
+ for (let i = 0; i < fallbackResults.length; i++) {
+ const result = fallbackResults[i];
+ if (result.status === 'fulfilled') {
+ fallbackUrls.push(result.value.url);
+ } else {
+ console.warn(
+ `Fallback upload to ${fallbackServers[i]} failed:`,
+ result.reason
+ );
+ }
}
}
diff --git a/app/services/nostr-auth.js b/app/services/nostr-auth.js
index 7814302..132c7dc 100644
--- a/app/services/nostr-auth.js
+++ b/app/services/nostr-auth.js
@@ -14,6 +14,8 @@ const STORAGE_KEY_CONNECT_RELAY = 'marco:nostr_connect_relay';
const DEFAULT_CONNECT_RELAY = 'wss://relay.nsec.app';
+import { isMobile } from '../utils/device';
+
export default class NostrAuthService extends Service {
@service nostrRelay;
@service nostrData;
@@ -73,7 +75,7 @@ export default class NostrAuthService extends Service {
}
get isMobile() {
- return /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent);
+ return isMobile();
}
get isConnected() {
diff --git a/app/utils/device.js b/app/utils/device.js
new file mode 100644
index 0000000..857cd68
--- /dev/null
+++ b/app/utils/device.js
@@ -0,0 +1,4 @@
+export function isMobile() {
+ if (typeof navigator === 'undefined') return false;
+ return /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent);
+}
From bb2411972fb5fd4fc2658d88f2220e0a8f16641d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Tue, 21 Apr 2026 09:17:44 +0400
Subject: [PATCH 28/56] Improve upload item UI
---
app/components/place-photo-item.gjs | 9 ---------
app/styles/app.css | 12 +-----------
2 files changed, 1 insertion(+), 20 deletions(-)
diff --git a/app/components/place-photo-item.gjs b/app/components/place-photo-item.gjs
index 37a2415..ddc2bc0 100644
--- a/app/components/place-photo-item.gjs
+++ b/app/components/place-photo-item.gjs
@@ -20,7 +20,6 @@ export default class PlacePhotoItem extends Component {
@tracked thumbnailUrl = '';
@tracked error = '';
- @tracked isUploaded = false;
constructor() {
super(...arguments);
@@ -87,8 +86,6 @@ export default class PlacePhotoItem extends Component {
]);
}
- this.isUploaded = true;
-
if (this.args.onSuccess) {
this.args.onSuccess({
file,
@@ -137,12 +134,6 @@ export default class PlacePhotoItem extends Component {
{{/if}}
- {{#if this.isUploaded}}
-
-
-
- {{/if}}
-
Date: Tue, 21 Apr 2026 09:28:34 +0400
Subject: [PATCH 29/56] Rename component, clean up CSS
---
...ce-photo-item.gjs => place-photo-upload-item.gjs} | 10 +++++-----
app/components/place-photo-upload.gjs | 4 ++--
app/styles/app.css | 12 ++++++------
3 files changed, 13 insertions(+), 13 deletions(-)
rename app/components/{place-photo-item.gjs => place-photo-upload-item.gjs} (93%)
diff --git a/app/components/place-photo-item.gjs b/app/components/place-photo-upload-item.gjs
similarity index 93%
rename from app/components/place-photo-item.gjs
rename to app/components/place-photo-upload-item.gjs
index ddc2bc0..b47cecc 100644
--- a/app/components/place-photo-item.gjs
+++ b/app/components/place-photo-upload-item.gjs
@@ -13,7 +13,7 @@ const IMAGE_QUALITY = 0.94;
const MAX_THUMBNAIL_DIMENSION = 350;
const THUMBNAIL_QUALITY = 0.9;
-export default class PlacePhotoItem extends Component {
+export default class PlacePhotoUploadItem extends Component {
@service blossom;
@service imageProcessor;
@service toast;
@@ -106,14 +106,14 @@ export default class PlacePhotoItem extends Component {
-
+
{{#if this.uploadTask.isRunning}}
-
+
diff --git a/app/components/place-photo-upload.gjs b/app/components/place-photo-upload.gjs
index 8a45c7e..46f2e80 100644
--- a/app/components/place-photo-upload.gjs
+++ b/app/components/place-photo-upload.gjs
@@ -6,7 +6,7 @@ import { on } from '@ember/modifier';
import { EventFactory } from 'applesauce-core';
import { task } from 'ember-concurrency';
import Geohash from 'latlon-geohash';
-import PlacePhotoItem from './place-photo-item';
+import PlacePhotoUploadItem from './place-photo-upload-item';
import Icon from '#components/icon';
import { or, not } from 'ember-truth-helpers';
@@ -232,7 +232,7 @@ export default class PlacePhotoUpload extends Component {
{{#if this.files.length}}
{{#each this.files as |file|}}
-
Date: Tue, 21 Apr 2026 09:38:25 +0400
Subject: [PATCH 30/56] Pluralize button text based on number of files
---
app/components/place-photo-upload.gjs | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/app/components/place-photo-upload.gjs b/app/components/place-photo-upload.gjs
index 46f2e80..9b2801f 100644
--- a/app/components/place-photo-upload.gjs
+++ b/app/components/place-photo-upload.gjs
@@ -37,6 +37,10 @@ export default class PlacePhotoUpload extends Component {
);
}
+ get photoWord() {
+ return this.files.length === 1 ? 'Photo' : 'Photos';
+ }
+
@action
handleFileSelect(event) {
this.addFiles(event.target.files);
@@ -251,7 +255,7 @@ export default class PlacePhotoUpload extends Component {
{{else}}
Publish
{{this.files.length}}
- Photo(s)
+ {{this.photoWord}}
{{/if}}
{{/if}}
From 9828ad2714e905ad60360980efb938640ecb1b3c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Tue, 21 Apr 2026 14:16:05 +0400
Subject: [PATCH 31/56] Close photo upload window, show toast when published
---
app/components/place-details.gjs | 5 ++++-
app/components/place-photo-upload.gjs | 7 ++++++-
2 files changed, 10 insertions(+), 2 deletions(-)
diff --git a/app/components/place-details.gjs b/app/components/place-details.gjs
index 309a516..4c3acb6 100644
--- a/app/components/place-details.gjs
+++ b/app/components/place-details.gjs
@@ -551,7 +551,10 @@ export default class PlaceDetails extends Component {
{{#if this.isPhotoUploadModalOpen}}
-
+
{{/if}}
diff --git a/app/components/place-photo-upload.gjs b/app/components/place-photo-upload.gjs
index 9b2801f..46410cc 100644
--- a/app/components/place-photo-upload.gjs
+++ b/app/components/place-photo-upload.gjs
@@ -183,11 +183,16 @@ export default class PlacePhotoUpload extends Component {
const event = await factory.sign(template);
await this.nostrRelay.publish(event);
- this.status = 'Published successfully!';
+ this.toast.show('Photos published successfully');
+ this.status = '';
// Clear out the files so user can upload more or be done
this.files = [];
this.uploadedPhotos = [];
+
+ if (this.args.onClose) {
+ this.args.onClose();
+ }
} catch (e) {
this.error = 'Failed to publish: ' + e.message;
this.status = '';
From 54445f249be474a4eeed8f752c52eeb1a67e9988 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Tue, 21 Apr 2026 14:16:42 +0400
Subject: [PATCH 32/56] Fix app menu header height
Wasn't the same between main menu and sub menus
---
app/styles/app.css | 1 +
1 file changed, 1 insertion(+)
diff --git a/app/styles/app.css b/app/styles/app.css
index 888c790..4f953c5 100644
--- a/app/styles/app.css
+++ b/app/styles/app.css
@@ -967,6 +967,7 @@ abbr[title] {
display: inline-flex;
width: 32px;
height: 32px;
+ margin: -6px 0;
}
.app-logo-icon svg {
From 54ba99673f9fdb8f6e6509b1d4643c0dedfc6566 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Tue, 21 Apr 2026 14:17:14 +0400
Subject: [PATCH 33/56] Break up settings into sub sections
---
app/components/app-menu/settings.gjs | 194 ++++++++++++++++-----------
app/styles/app.css | 2 +-
2 files changed, 115 insertions(+), 81 deletions(-)
diff --git a/app/components/app-menu/settings.gjs b/app/components/app-menu/settings.gjs
index 1e5f909..55673d4 100644
--- a/app/components/app-menu/settings.gjs
+++ b/app/components/app-menu/settings.gjs
@@ -29,6 +29,7 @@ export default class AppMenuSettings extends Component {
}
+ {{! template-lint-disable no-nested-interactive }}
+
+
+
+
diff --git a/app/components/place-photo-upload.gjs b/app/components/place-photo-upload.gjs
index 46410cc..7d8b52b 100644
--- a/app/components/place-photo-upload.gjs
+++ b/app/components/place-photo-upload.gjs
@@ -13,6 +13,7 @@ import { or, not } from 'ember-truth-helpers';
export default class PlacePhotoUpload extends Component {
@service nostrAuth;
@service nostrRelay;
+ @service nostrData;
@service blossom;
@service toast;
@@ -181,7 +182,7 @@ export default class PlacePhotoUpload extends Component {
}
const event = await factory.sign(template);
- await this.nostrRelay.publish(event);
+ await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
this.toast.show('Photos published successfully');
this.status = '';
diff --git a/app/services/nostr-data.js b/app/services/nostr-data.js
index 455af6e..5a1d257 100644
--- a/app/services/nostr-data.js
+++ b/app/services/nostr-data.js
@@ -6,16 +6,21 @@ import { MailboxesModel } from 'applesauce-core/models/mailboxes';
import { npubEncode } from 'applesauce-core/helpers/pointers';
import { persistEventsToCache } from 'applesauce-core/helpers/event-cache';
import { NostrIDB, openDB } from 'nostr-idb';
+import { normalizeRelayUrl } from '../utils/nostr';
-const BOOTSTRAP_RELAYS = [
+const DIRECTORY_RELAYS = [
'wss://purplepag.es',
'wss://relay.damus.io',
'wss://nos.lol',
];
+const DEFAULT_READ_RELAYS = ['wss://nostr.kosmos.org'];
+const DEFAULT_WRITE_RELAYS = [];
+
export default class NostrDataService extends Service {
@service nostrRelay;
@service nostrAuth;
+ @service settings;
store = new EventStore();
@@ -69,6 +74,45 @@ export default class NostrDataService extends Service {
});
}
+ get defaultReadRelays() {
+ const mailboxes = (this.mailboxes?.inboxes || [])
+ .map(normalizeRelayUrl)
+ .filter(Boolean);
+ const defaults = DEFAULT_READ_RELAYS.map(normalizeRelayUrl).filter(Boolean);
+ return Array.from(new Set([...defaults, ...mailboxes]));
+ }
+
+ get defaultWriteRelays() {
+ const mailboxes = (this.mailboxes?.outboxes || [])
+ .map(normalizeRelayUrl)
+ .filter(Boolean);
+ const defaults =
+ DEFAULT_WRITE_RELAYS.map(normalizeRelayUrl).filter(Boolean);
+ return Array.from(new Set([...defaults, ...mailboxes]));
+ }
+
+ get activeReadRelays() {
+ if (this.settings.nostrReadRelays) {
+ return Array.from(
+ new Set(
+ this.settings.nostrReadRelays.map(normalizeRelayUrl).filter(Boolean)
+ )
+ );
+ }
+ return this.defaultReadRelays;
+ }
+
+ get activeWriteRelays() {
+ if (this.settings.nostrWriteRelays) {
+ return Array.from(
+ new Set(
+ this.settings.nostrWriteRelays.map(normalizeRelayUrl).filter(Boolean)
+ )
+ );
+ }
+ return this.defaultWriteRelays;
+ }
+
async loadProfile(pubkey) {
if (!pubkey) return;
@@ -79,22 +123,6 @@ export default class NostrDataService extends Service {
this._cleanupSubscriptions();
- const relays = new Set(BOOTSTRAP_RELAYS);
-
- // Try to get extension relays
- if (typeof window.nostr !== 'undefined' && window.nostr.getRelays) {
- try {
- const extRelays = await window.nostr.getRelays();
- for (const url of Object.keys(extRelays)) {
- relays.add(url);
- }
- } catch {
- console.warn('Failed to get NIP-07 relays');
- }
- }
-
- const relayList = Array.from(relays);
-
// Setup models to track state reactively FIRST
// This way, if cached events populate the store, the UI updates instantly.
this._profileSub = this.store
@@ -142,8 +170,11 @@ export default class NostrDataService extends Service {
}
// 2. Request new events from the network in the background and dump them into the store
+ const profileRelays = Array.from(
+ new Set([...DIRECTORY_RELAYS, ...this.activeWriteRelays])
+ );
this._requestSub = this.nostrRelay.pool
- .request(relayList, [
+ .request(profileRelays, [
{
authors: [pubkey],
kinds: [0, 10002, 10063],
diff --git a/app/services/nostr-relay.js b/app/services/nostr-relay.js
index 98e7e9f..320a38b 100644
--- a/app/services/nostr-relay.js
+++ b/app/services/nostr-relay.js
@@ -4,13 +4,13 @@ import { RelayPool } from 'applesauce-relay';
export default class NostrRelayService extends Service {
pool = new RelayPool();
- // For Phase 1, we hardcode the local relay
- relays = ['ws://127.0.0.1:7777'];
-
- async publish(event) {
+ async publish(relays, event) {
+ if (!relays || relays.length === 0) {
+ throw new Error('No relays provided to publish the event.');
+ }
// The publish method is a wrapper around the event method that returns a Promise
// and automatically handles reconnecting and retrying.
- const responses = await this.pool.publish(this.relays, event);
+ const responses = await this.pool.publish(relays, event);
// Check if at least one relay accepted the event
const success = responses.some((res) => res.ok);
diff --git a/app/services/settings.js b/app/services/settings.js
index 6d3efc8..669a4ed 100644
--- a/app/services/settings.js
+++ b/app/services/settings.js
@@ -7,6 +7,8 @@ const DEFAULT_SETTINGS = {
photonApi: 'https://photon.komoot.io/api/',
showQuickSearchButtons: true,
nostrPhotoFallbackUploads: false,
+ nostrReadRelays: null,
+ nostrWriteRelays: null,
};
export default class SettingsService extends Service {
@@ -16,6 +18,8 @@ export default class SettingsService extends Service {
@tracked showQuickSearchButtons = DEFAULT_SETTINGS.showQuickSearchButtons;
@tracked nostrPhotoFallbackUploads =
DEFAULT_SETTINGS.nostrPhotoFallbackUploads;
+ @tracked nostrReadRelays = DEFAULT_SETTINGS.nostrReadRelays;
+ @tracked nostrWriteRelays = DEFAULT_SETTINGS.nostrWriteRelays;
overpassApis = [
{
@@ -102,6 +106,8 @@ export default class SettingsService extends Service {
this.photonApi = finalSettings.photonApi;
this.showQuickSearchButtons = finalSettings.showQuickSearchButtons;
this.nostrPhotoFallbackUploads = finalSettings.nostrPhotoFallbackUploads;
+ this.nostrReadRelays = finalSettings.nostrReadRelays;
+ this.nostrWriteRelays = finalSettings.nostrWriteRelays;
// Save to ensure migrated settings are stored in the new format
this.saveSettings();
@@ -114,6 +120,8 @@ export default class SettingsService extends Service {
photonApi: this.photonApi,
showQuickSearchButtons: this.showQuickSearchButtons,
nostrPhotoFallbackUploads: this.nostrPhotoFallbackUploads,
+ nostrReadRelays: this.nostrReadRelays,
+ nostrWriteRelays: this.nostrWriteRelays,
};
localStorage.setItem('marco:settings', JSON.stringify(settings));
}
diff --git a/app/styles/app.css b/app/styles/app.css
index eb668b1..b39146f 100644
--- a/app/styles/app.css
+++ b/app/styles/app.css
@@ -8,6 +8,8 @@
--link-color-visited: #6a4fbf;
--marker-color-primary: #ea4335;
--marker-color-dark: #b31412;
+ --danger-color: var(--marker-color-primary);
+ --danger-color-dark: var(--marker-color-dark);
}
html,
@@ -301,7 +303,7 @@ body {
}
.photo-upload-item .btn-remove-photo:hover {
- background: var(--marker-color-primary);
+ background: var(--danger-color);
}
.spin-animation {
@@ -565,6 +567,64 @@ body {
padding: 0 1.4rem 1rem;
animation: details-slide-down 0.2s ease-out;
font-size: 0.9rem;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.relay-list {
+ list-style: none;
+ padding: 0;
+ margin: 0 0 0.75rem;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.relay-list li {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.25rem 0;
+ border-radius: 4px;
+ font-size: 0.9rem;
+ word-break: break-all;
+}
+
+.btn-remove-relay {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ background-color: #fff;
+ border: 1px solid var(--danger-color);
+ color: var(--danger-color);
+ cursor: pointer;
+ padding: 0;
+ transition: all 0.1s ease;
+ flex-shrink: 0;
+}
+
+.btn-remove-relay svg {
+ stroke: currentcolor;
+}
+
+.btn-remove-relay:hover,
+.btn-remove-relay:active {
+ background-color: var(--danger-color);
+ color: #fff;
+}
+
+.add-relay-input {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.btn-link.reset-relays {
+ margin-top: 0.75rem;
+ font-size: 0.85rem;
}
@keyframes details-slide-down {
@@ -639,6 +699,11 @@ select.form-control {
}
.settings-section .form-group {
+ margin-top: 0.5rem;
+ margin-bottom: 0;
+}
+
+.settings-section .form-group:first-of-type {
margin-top: 1rem;
}
@@ -1598,3 +1663,17 @@ button.create-place {
max-width: 100%;
border-radius: 0.25rem;
}
+
+.btn-link {
+ background: none;
+ border: none;
+ padding: 0;
+ color: var(--link-color);
+ text-decoration: none;
+ cursor: pointer;
+ font: inherit;
+}
+
+.btn-link:hover {
+ text-decoration: underline;
+}
diff --git a/app/utils/nostr.js b/app/utils/nostr.js
new file mode 100644
index 0000000..d68c973
--- /dev/null
+++ b/app/utils/nostr.js
@@ -0,0 +1,15 @@
+export function normalizeRelayUrl(url) {
+ if (!url) return '';
+ let normalized = url.trim().toLowerCase();
+ if (!normalized) return '';
+
+ if (!normalized.startsWith('ws://') && !normalized.startsWith('wss://')) {
+ normalized = 'wss://' + normalized;
+ }
+
+ while (normalized.endsWith('/')) {
+ normalized = normalized.slice(0, -1);
+ }
+
+ return normalized;
+}
From 99cfd96ca1e5c8106d85f1737975e51138fc9b61 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Tue, 21 Apr 2026 21:28:57 +0400
Subject: [PATCH 39/56] Fetch and cache photo events while browsing map and
when opening place details
---
app/components/map.gjs | 2 +
app/components/place-photo-upload.gjs | 1 +
app/services/map-ui.js | 6 +-
app/services/nostr-data.js | 148 +++++++++++++++++++++++++-
4 files changed, 154 insertions(+), 3 deletions(-)
diff --git a/app/components/map.gjs b/app/components/map.gjs
index 4e48050..1dc567d 100644
--- a/app/components/map.gjs
+++ b/app/components/map.gjs
@@ -27,6 +27,7 @@ export default class MapComponent extends Component {
@service mapUi;
@service router;
@service settings;
+ @service nostrData;
mapInstance;
bookmarkSource;
@@ -1078,6 +1079,7 @@ export default class MapComponent extends Component {
const bbox = { minLat, minLon, maxLat, maxLon };
this.mapUi.updateBounds(bbox);
await this.storage.loadPlacesInBounds(bbox);
+ this.nostrData.loadPlacesInBounds(bbox);
this.loadBookmarks(this.storage.placesInView);
// Persist view to localStorage
diff --git a/app/components/place-photo-upload.gjs b/app/components/place-photo-upload.gjs
index 7d8b52b..fced83a 100644
--- a/app/components/place-photo-upload.gjs
+++ b/app/components/place-photo-upload.gjs
@@ -183,6 +183,7 @@ export default class PlacePhotoUpload extends Component {
const event = await factory.sign(template);
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
+ this.nostrData.store.add(event);
this.toast.show('Photos published successfully');
this.status = '';
diff --git a/app/services/map-ui.js b/app/services/map-ui.js
index ca6e8e7..63e9603 100644
--- a/app/services/map-ui.js
+++ b/app/services/map-ui.js
@@ -1,7 +1,9 @@
-import Service from '@ember/service';
+import Service, { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
export default class MapUiService extends Service {
+ @service nostrData;
+
@tracked selectedPlace = null;
@tracked isSearching = false;
@tracked isCreating = false;
@@ -19,12 +21,14 @@ export default class MapUiService extends Service {
selectPlace(place, options = {}) {
this.selectedPlace = place;
this.selectionOptions = options;
+ this.nostrData.loadPhotosForPlace(place);
}
clearSelection() {
this.selectedPlace = null;
this.selectionOptions = {};
this.preventNextZoom = false;
+ this.nostrData.loadPhotosForPlace(null);
}
setSearchResults(results) {
diff --git a/app/services/nostr-data.js b/app/services/nostr-data.js
index 5a1d257..a3df768 100644
--- a/app/services/nostr-data.js
+++ b/app/services/nostr-data.js
@@ -7,6 +7,7 @@ import { npubEncode } from 'applesauce-core/helpers/pointers';
import { persistEventsToCache } from 'applesauce-core/helpers/event-cache';
import { NostrIDB, openDB } from 'nostr-idb';
import { normalizeRelayUrl } from '../utils/nostr';
+import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage';
const DIRECTORY_RELAYS = [
'wss://purplepag.es',
@@ -27,13 +28,16 @@ export default class NostrDataService extends Service {
@tracked profile = null;
@tracked mailboxes = null;
@tracked blossomServers = [];
+ @tracked placePhotos = [];
_profileSub = null;
_mailboxesSub = null;
_blossomSub = null;
+ _photosSub = null;
_requestSub = null;
_cachePromise = null;
+ loadedGeohashPrefixes = new Set();
constructor() {
super(...arguments);
@@ -51,9 +55,13 @@ export default class NostrDataService extends Service {
this._stopPersisting = persistEventsToCache(
this.store,
async (events) => {
- // Only cache profiles, mailboxes, and blossom servers
+ // Only cache profiles, mailboxes, blossom servers, and place photos
const toCache = events.filter(
- (e) => e.kind === 0 || e.kind === 10002 || e.kind === 10063
+ (e) =>
+ e.kind === 0 ||
+ e.kind === 10002 ||
+ e.kind === 10063 ||
+ e.kind === 360
);
if (toCache.length > 0) {
@@ -113,6 +121,138 @@ export default class NostrDataService extends Service {
return this.defaultWriteRelays;
}
+ async loadPlacesInBounds(bbox) {
+ const requiredPrefixes = getGeohashPrefixesInBbox(bbox);
+
+ const missingPrefixes = requiredPrefixes.filter(
+ (p) => !this.loadedGeohashPrefixes.has(p)
+ );
+
+ if (missingPrefixes.length === 0) {
+ return;
+ }
+
+ console.debug(
+ '[nostr-data] Loading place photos for prefixes:',
+ missingPrefixes
+ );
+
+ try {
+ await this._cachePromise;
+
+ const cachedEvents = await this.cache.query([
+ {
+ kinds: [360],
+ '#g': missingPrefixes,
+ },
+ ]);
+
+ if (cachedEvents && cachedEvents.length > 0) {
+ for (const event of cachedEvents) {
+ this.store.add(event);
+ }
+ }
+ } catch (e) {
+ console.warn(
+ '[nostr-data] Failed to read photos from local Nostr IDB cache',
+ e
+ );
+ }
+
+ // 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
+ );
+ },
+ });
+
+ for (const p of missingPrefixes) {
+ this.loadedGeohashPrefixes.add(p);
+ }
+ }
+
+ async loadPhotosForPlace(place) {
+ if (this._photosSub) {
+ this._photosSub.unsubscribe();
+ this._photosSub = null;
+ }
+
+ this.placePhotos = [];
+
+ if (!place || !place.osmId || !place.osmType) {
+ return;
+ }
+
+ const entityId = `osm:${place.osmType}:${place.osmId}`;
+
+ // Setup reactive store query
+ this._photosSub = this.store
+ .timeline([
+ {
+ kinds: [360],
+ '#i': [entityId],
+ },
+ ])
+ .subscribe((events) => {
+ this.placePhotos = events;
+ });
+
+ try {
+ await this._cachePromise;
+
+ const cachedEvents = await this.cache.query([
+ {
+ kinds: [360],
+ '#i': [entityId],
+ },
+ ]);
+
+ if (cachedEvents && cachedEvents.length > 0) {
+ for (const event of cachedEvents) {
+ this.store.add(event);
+ }
+ }
+ } catch (e) {
+ console.warn(
+ '[nostr-data] Failed to read photos for place from local Nostr IDB cache',
+ e
+ );
+ }
+
+ // Fire network request specifically for this place
+ this.nostrRelay.pool
+ .request(this.activeReadRelays, [
+ {
+ kinds: [360],
+ '#i': [entityId],
+ },
+ ])
+ .subscribe({
+ next: (event) => {
+ this.store.add(event);
+ },
+ error: (err) => {
+ console.error(
+ '[nostr-data] Error fetching place photos for place:',
+ err
+ );
+ },
+ });
+ }
+
async loadProfile(pubkey) {
if (!pubkey) return;
@@ -233,6 +373,10 @@ export default class NostrDataService extends Service {
this._blossomSub.unsubscribe();
this._blossomSub = null;
}
+ if (this._photosSub) {
+ this._photosSub.unsubscribe();
+ this._photosSub = null;
+ }
}
willDestroy() {
From 85a8699b7892eeb02aca8b471b62d1d66f81a075 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Tue, 21 Apr 2026 23:07:06 +0400
Subject: [PATCH 40/56] Render header photo in place details
Shows the blurhash and fades in the image once downloaded
---
app/components/blurhash.gjs | 37 +++++++++++++++++
app/components/place-details.gjs | 68 +++++++++++++++++++++++++++++++
app/components/places-sidebar.gjs | 11 ++++-
app/modifiers/fade-in-image.js | 30 ++++++++++++++
app/styles/app.css | 43 +++++++++++++++++++
app/utils/poi-categories.js | 2 +-
6 files changed, 189 insertions(+), 2 deletions(-)
create mode 100644 app/components/blurhash.gjs
create mode 100644 app/modifiers/fade-in-image.js
diff --git a/app/components/blurhash.gjs b/app/components/blurhash.gjs
new file mode 100644
index 0000000..0dee902
--- /dev/null
+++ b/app/components/blurhash.gjs
@@ -0,0 +1,37 @@
+import Component from '@glimmer/component';
+import { modifier } from 'ember-modifier';
+import { decode } from 'blurhash';
+
+export default class Blurhash extends Component {
+ renderBlurhash = modifier((canvas, [hash, width, height]) => {
+ if (!hash || !canvas) return;
+
+ // Default size to a small multiple of aspect ratio to save CPU
+ // 32x18 is a good balance of speed vs quality for 16:9
+ const renderWidth = width || 32;
+ const renderHeight = height || 18;
+
+ canvas.width = renderWidth;
+ canvas.height = renderHeight;
+
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+
+ try {
+ const pixels = decode(hash, renderWidth, renderHeight);
+ const imageData = ctx.createImageData(renderWidth, renderHeight);
+ imageData.data.set(pixels);
+ ctx.putImageData(imageData, 0, 0);
+ } catch (e) {
+ console.warn('Failed to decode blurhash:', e);
+ }
+ });
+
+
+
+
+}
diff --git a/app/components/place-details.gjs b/app/components/place-details.gjs
index 4c3acb6..662fa30 100644
--- a/app/components/place-details.gjs
+++ b/app/components/place-details.gjs
@@ -12,6 +12,8 @@ import PlaceListsManager from './place-lists-manager';
import PlacePhotoUpload from './place-photo-upload';
import NostrConnect from './nostr-connect';
import Modal from './modal';
+import Blurhash from './blurhash';
+import fadeInImage from '../modifiers/fade-in-image';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
@@ -19,6 +21,7 @@ import { action } from '@ember/object';
export default class PlaceDetails extends Component {
@service storage;
@service nostrAuth;
+ @service nostrData;
@tracked isEditing = false;
@tracked showLists = false;
@tracked isPhotoUploadModalOpen = false;
@@ -76,6 +79,53 @@ export default class PlaceDetails extends Component {
return this.place.title || getLocalizedName(this.tags) || 'Unnamed Place';
}
+ get headerPhoto() {
+ const photos = this.nostrData.placePhotos;
+ if (!photos || photos.length === 0) return null;
+
+ // Sort by created_at ascending (oldest first)
+ const sortedEvents = [...photos].sort(
+ (a, b) => a.created_at - b.created_at
+ );
+
+ let firstPortrait = null;
+
+ for (const event of sortedEvents) {
+ // Find all imeta tags
+ const imetas = event.tags.filter((t) => t[0] === 'imeta');
+ for (const imeta of imetas) {
+ let url = null;
+ let blurhash = null;
+ let isLandscape = false;
+
+ for (const tag of imeta.slice(1)) {
+ if (tag.startsWith('url ')) {
+ url = tag.substring(4);
+ } else if (tag.startsWith('blurhash ')) {
+ blurhash = tag.substring(9);
+ } else if (tag.startsWith('dim ')) {
+ const dimStr = tag.substring(4);
+ const [width, height] = dimStr.split('x').map(Number);
+ if (width && height && width > height) {
+ isLandscape = true;
+ }
+ }
+ }
+
+ if (url) {
+ const photoData = { url, blurhash };
+ if (isLandscape) {
+ return photoData; // Return the first landscape photo found
+ } else if (!firstPortrait) {
+ firstPortrait = photoData; // Save the first portrait as fallback
+ }
+ }
+ }
+ }
+
+ return firstPortrait;
+ }
+
@action
startEditing() {
if (!this.isSaved) return; // Only allow editing saved places
@@ -339,6 +389,24 @@ export default class PlaceDetails extends Component {
@onCancel={{this.cancelEditing}}
/>
{{else}}
+ {{#if this.headerPhoto}}
+
+ {{/if}}
{{this.name}}
{{this.type}}
diff --git a/app/components/places-sidebar.gjs b/app/components/places-sidebar.gjs
index bf39204..e3bcc2d 100644
--- a/app/components/places-sidebar.gjs
+++ b/app/components/places-sidebar.gjs
@@ -14,6 +14,7 @@ export default class PlacesSidebar extends Component {
@service storage;
@service router;
@service mapUi;
+ @service nostrData;
@action
createNewPlace() {
@@ -149,9 +150,17 @@ export default class PlacesSidebar extends Component {
return !qp.q && !qp.category && qp.lat && qp.lon;
}
+ get hasHeaderPhoto() {
+ return (
+ this.args.selectedPlace &&
+ this.nostrData.placePhotos &&
+ this.nostrData.placePhotos.length > 0
+ );
+ }
+