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; + } + + +} 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(); + } + } 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; - } - } 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}} -
- -
- {{#if this.photoUrl}} -
-

Photo Preview:

- Preview -
- - {{else}} - - {{/if}} -
- {{else}} - - {{/if}} +
+ {{#if this.photoUrl}} +
+

Photo Preview:

+ Preview +
+ + {{else}} + + {{/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/services/nostr-auth.js b/app/services/nostr-auth.js index 5c10564..25d5c60 100644 --- a/app/services/nostr-auth.js +++ b/app/services/nostr-auth.js @@ -125,7 +125,7 @@ export default class NostrAuthService extends Service { } // We use a specific relay for the connection handshake. - const relay = 'wss://relay.damus.io'; + const relay = 'wss://relay.nsec.app'; localStorage.setItem(STORAGE_KEY_CONNECT_RELAY, relay); this._signerInstance = new NostrConnectSigner({ @@ -190,7 +190,7 @@ export default class NostrAuthService extends Service { STORAGE_KEY_CONNECT_REMOTE_PUBKEY ); const relay = - localStorage.getItem(STORAGE_KEY_CONNECT_RELAY) || 'wss://relay.damus.io'; + localStorage.getItem(STORAGE_KEY_CONNECT_RELAY) || 'wss://relay.nsec.app'; if (!localKeyHex || !remotePubkey) { throw new Error('Missing Nostr Connect local state.'); From 6cfe2b40b9dbd08c6712c6079ee82f69015851fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Sun, 19 Apr 2026 16:01:45 +0400 Subject: [PATCH 12/56] Add bunker login for desktop via QR code --- app/components/nostr-connect.gjs | 13 +++- app/modifiers/qr-code.js | 17 +++++ app/services/nostr-auth.js | 10 ++- app/styles/app.css | 12 +++ package.json | 1 + pnpm-lock.yaml | 123 +++++++++++++++++++++++++++++++ 6 files changed, 172 insertions(+), 4 deletions(-) create mode 100644 app/modifiers/qr-code.js diff --git a/app/components/nostr-connect.gjs b/app/components/nostr-connect.gjs index 4e3340f..818fbf6 100644 --- a/app/components/nostr-connect.gjs +++ b/app/components/nostr-connect.gjs @@ -3,6 +3,7 @@ import { action } from '@ember/object'; import { inject as service } from '@ember/service'; import { on } from '@ember/modifier'; import { eq } from 'ember-truth-helpers'; +import qrCode from '../modifiers/qr-code'; export default class NostrConnectComponent extends Component { @service nostrAuth; @@ -72,8 +73,16 @@ export default class NostrConnectComponent extends Component { {{#if (eq this.nostrAuth.connectStatus "waiting")}}
-

Waiting for you to approve the connection in your mobile signer - app...

+ {{#if this.nostrAuth.isMobile}} +

Waiting for you to approve the connection in your mobile signer + app...

+ {{else}} +

Scan this QR code with a compatible Nostr signer app (like + Amber):

+
+ +
+ {{/if}}
{{/if}} 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/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; + } + }); + + +} 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; } } } 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}} > 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}} -