Compare commits

...

71 Commits

Author SHA1 Message Date
c9465c8fa8 1.20.0
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 57s
2026-04-22 13:33:55 +04:00
6c5c1fea27 Merge pull request 'Connect Nostr, integrate place photos' (#45) from feature/nostr_place_reviews into master
All checks were successful
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 1m0s
Reviewed-on: #45
2026-04-22 09:31:59 +00:00
fe41369754 Reset scroll position when switching between places
All checks were successful
CI / Lint (pull_request) Successful in 36s
CI / Test (pull_request) Successful in 1m3s
Release Drafter / Update release notes draft (pull_request) Successful in 7s
2026-04-22 13:10:11 +04:00
1498c5a713 Improve dropzone size 2026-04-22 13:02:12 +04:00
b6e2964f8e Show placeholder on mobile when photos not filling space 2026-04-22 12:40:45 +04:00
d1d179bb93 Lazy-load place photos
Only preload photos in view as well as the next one(s), not all of them
2026-04-22 12:02:44 +04:00
b83a16bf13 Use button element for add-photo link 2026-04-22 11:32:57 +04:00
c853418fbb Fix auto-scroll to new photo on mobile 2026-04-22 11:32:37 +04:00
4fed8c05c5 Change routing to always use OSM IDs except for custom places
Also implements a short term cache for OSM place data, so we can load it
multiple times without multiplying network requests where needed
2026-04-22 11:01:32 +04:00
670128cbda Immediately render newly uploaded photo and scroll to it 2026-04-22 10:38:06 +04:00
d8fa30c74b Revert to single photo per upload and event
See NIP changes for reasoning. It also keeps the UI a bit cleaner and
we don't have to queue processing on mobile for mass uploads.
2026-04-22 10:18:47 +04:00
0f8d7046ac Improve blurhash decode warning, use valid hashes in tests 2026-04-22 09:19:31 +04:00
8ca7481a79 Mock nostr service globally in tests 2026-04-22 09:03:38 +04:00
cd25c55bd7 Smaller gap between photos in carousel on mobile 2026-04-22 08:44:17 +04:00
32c4f7da57 Add integration tests for carousel 2026-04-22 08:34:03 +04:00
71939a30c3 Fix carousel chevron links
Correct color, hide while disabled
2026-04-22 08:30:55 +04:00
7285ace882 Move parsing of place photos to util
Can be used in gallery later
2026-04-22 07:40:18 +04:00
94ba33ecc1 Render all place photos in a carousel 2026-04-22 07:32:55 +04:00
85a8699b78 Render header photo in place details
Shows the blurhash and fades in the image once downloaded
2026-04-21 23:07:06 +04:00
99cfd96ca1 Fetch and cache photo events while browsing map and when opening place details 2026-04-21 21:28:57 +04:00
8d40b3bb35 Add and use relay list settings 2026-04-21 19:16:52 +04:00
c5316bf336 Refactor settings, DRY up everything 2026-04-21 15:59:55 +04:00
a384e83dd0 Cache own Nostr avatar image 2026-04-21 15:17:04 +04:00
b23d54d74f Cache user profile/settings events in IndexedDB 2026-04-21 14:58:05 +04:00
5bd4dba907 Refactor settings menu, add Nostr settings
Adds a setting to control if photos should be uploaded only to the
main/default server, or all known servers of a user.

Only upload to the main server by default, to speed up adding photos.
2026-04-21 14:39:38 +04:00
54ba99673f Break up settings into sub sections 2026-04-21 14:17:14 +04:00
54445f249b Fix app menu header height
Wasn't the same between main menu and sub menus
2026-04-21 14:16:42 +04:00
9828ad2714 Close photo upload window, show toast when published 2026-04-21 14:16:05 +04:00
a89ba904c8 Pluralize button text based on number of files 2026-04-21 09:38:25 +04:00
4c540bc713 Rename component, clean up CSS 2026-04-21 09:28:34 +04:00
bb2411972f Improve upload item UI 2026-04-21 09:17:44 +04:00
5cd384cf3a Do sequential image processing/uploads on mobile
Uploading multiple large files at once can fail easily
2026-04-20 19:37:24 +04:00
ec31d1a59b Harden image processing, improve image quality 2026-04-20 18:10:48 +04:00
4f55f26851 Move image processing to worker 2026-04-20 16:56:51 +04:00
b7cce6eb7e Improve dropzone styles 2026-04-20 16:39:37 +04:00
79777fb51a Process images before upload, add thumbnails, blurhash 2026-04-20 16:24:28 +04:00
1ed66ca744 Fix deprecation warning 2026-04-20 15:26:28 +04:00
a2a61b0fec Upload to multiple servers, delete from servers when removing in dialog
Introduces a dedicated blossom service to tie everything together
2026-04-20 15:22:17 +04:00
d9ba73559e WIP Upload multiple photos 2026-04-20 14:25:15 +04:00
f1ebafc1f0 Fix default blossom server, move to constant 2026-04-20 14:06:41 +04:00
10501b64bd Fix avatar placement for new avatar image 2026-04-20 14:06:13 +04:00
7607f27013 Upload photos to user's Blossom server 2026-04-20 13:55:13 +04:00
8cc579e271 Load user profile from Nostr, display name and avatar 2026-04-20 13:37:05 +04:00
3a56464926 Improve Nostr connect UI 2026-04-20 13:09:51 +04:00
1dc0c4119b Refactor Nostr auth service 2026-04-20 12:34:44 +04:00
c57a665655 Add applesauce debug logs, fix aggressive connect timeout 2026-04-20 12:14:45 +04:00
6cfe2b40b9 Add bunker login for desktop via QR code 2026-04-19 16:01:45 +04:00
99d8ca9174 Create dedicated Nostr Connect component, use nsec.app relay 2026-04-19 15:15:55 +04:00
629a308b79 Connect Nostr via mobile app 2026-04-19 14:45:11 +04:00
798ed0c8dd Use camera icon from Feather 2026-04-19 14:44:37 +04:00
03583e5a52 Add generic modal component, refactor photo upload modal (WIP) 2026-04-19 11:09:41 +04:00
b9f64f30e1 Cut off overlong account status lines with ellipsis 2026-04-19 11:09:23 +04:00
4bd5c4bf2a Load signer on launch, disconnect or switch pubkey if necessary 2026-04-19 11:08:55 +04:00
f875fc1877 WIP Add Nostr auth 2026-04-18 18:36:35 +04:00
2268a607d5 Format docs 2026-04-18 18:35:45 +04:00
f01b5f8faa Add place photos NIP, update reviews NIP 2026-04-18 17:30:25 +04:00
9075089221 Update notes 2026-04-18 12:47:33 +04:00
5ad702e6e6 Add planning docs for Nostr place reviews 2026-04-18 11:35:29 +04:00
bae01a3c9b Add Nostr agent skills
All checks were successful
CI / Lint (push) Successful in 29s
CI / Test (push) Successful in 45s
2026-04-14 10:34:43 +04:00
0efc8994e9 Add AGENTS.md
All checks were successful
CI / Lint (push) Successful in 29s
CI / Test (push) Successful in 44s
2026-04-14 10:20:59 +04:00
5c71523d90 1.19.1
All checks were successful
CI / Lint (push) Successful in 30s
CI / Test (push) Successful in 45s
2026-04-13 13:17:52 +04:00
bea1b97fb7 Update remotestorage-widget 2026-04-13 13:16:54 +04:00
6ba1cf31cf Show RS widget when Unauthorized error received
Allows the user to re-auth via the widget
2026-04-13 13:16:49 +04:00
9cdd021cda 1.19.0
All checks were successful
CI / Lint (push) Successful in 28s
CI / Test (push) Successful in 43s
2026-04-12 16:17:22 +04:00
ef53870b35 Merge pull request 'Add mobile numbers and WhatsApp links to place details' (#41) from feature/place_details into master
All checks were successful
CI / Lint (push) Successful in 28s
CI / Test (push) Successful in 44s
Reviewed-on: #41
2026-04-12 12:15:25 +00:00
918a794784 Fix lint errors
All checks were successful
CI / Lint (pull_request) Successful in 30s
CI / Test (pull_request) Successful in 43s
Release Drafter / Update release notes draft (pull_request) Successful in 8s
2026-04-12 16:10:05 +04:00
344a3067fa Add WhatsApp numbers/links
Some checks failed
CI / Lint (pull_request) Failing after 32s
CI / Test (pull_request) Successful in 47s
2026-04-12 16:06:02 +04:00
ad3e6ea402 Remove spaces and dashes from tel links 2026-04-12 15:47:22 +04:00
9e2545da7b Add mobile phone numbers 2026-04-12 15:42:36 +04:00
480c97fb9d 1.18.4
All checks were successful
CI / Lint (push) Successful in 29s
CI / Test (push) Successful in 45s
2026-04-02 14:41:19 +04:00
179cf49370 Fix OSM auth not being loaded correctly on launch 2026-04-02 14:40:20 +04:00
61 changed files with 6094 additions and 186 deletions

359
.agents/skills/nak/SKILL.md Normal file
View File

@@ -0,0 +1,359 @@
---
name: nak
description: Interact with Nostr protocol using the nak CLI tool. Use to generate Nostr secret keys, encode and decode Nostr identifiers (hex/npub/nsec/nip05/etc), fetch events from relays, sign and publish Nostr events, and more.
license: CC-BY-SA-4.0
---
# nak - Nostr Army Knife
Work with the Nostr protocol using the `nak` CLI tool.
GitHub: https://github.com/fiatjaf/nak
## Installation
To install (or upgrade) nak:
```bash
curl -sSL https://raw.githubusercontent.com/fiatjaf/nak/master/install.sh | sh
```
## Core Concepts
Based on analyzing extensive real-world usage, here are the fundamental concepts distilled down, then branched out:
---
### CONCEPT 1: Query & Discovery
*"Finding events and data on Nostr"*
**Basic:**
- Fetch by identifier: "Get event/profile without knowing relays"
- `nak fetch nevent1...` (uses embedded relay hints)
- `nak fetch alex@gleasonator.dev` (NIP-05 resolution)
- Query by event ID: "I want THIS specific event from a relay"
- `nak req -i <event_id> <relay>`
- Query by author: "Show me everything from this person"
- `nak req -a <pubkey> <relay>`
- Query by kind: "Show me all profiles/notes/videos"
- `nak req -k <kind> <relay>`
**Intermediate:**
- Fetch addressable events: "Get event by naddr/nprofile"
- `nak fetch naddr1...` (kind, author, identifier encoded)
- `nak fetch -r relay.primal.net naddr1...` (override relays)
- Filter by multiple criteria: "Find posts by author X of kind Y"
- `nak req -k 1 -a <pubkey> <relay>`
- Tag-based queries: "Find events tagged with bitcoin"
- `nak req --tag t=bitcoin <relay>`
**Advanced:**
- Search with ranking: "Find trending/top content"
- `nak req --search "sort:hot" <relay>`
- `nak req --search "popular:24h" <relay>`
- Live monitoring: "Watch events in real-time"
- `nak req --stream <relay>`
- Cross-protocol queries: "Find bridged Bluesky content"
- `nak req --tag "proxy=at://..." <relay>`
**Use cases:** Discovering content, debugging relay data, testing search algorithms, monitoring live feeds
---
### CONCEPT 2: Broadcast & Migration
*"Moving/copying events between relays"*
**Basic:**
- Publish a single event: "Put this on relay X"
- `cat event.json | nak event <relay>`
- Query and republish: "Copy event from relay A to relay B"
- `nak req -i <id> relay1 | nak event relay2`
**Intermediate:**
- Batch migration: "Copy all events of kind X"
- `nak req -k 30717 source_relay | nak event dest_relay`
- Paginated backup: "Download everything from a relay"
- `nak req --paginate source_relay | nak event backup_relay`
- Multi-relay broadcast: "Publish to multiple relays at once"
- `cat event.json | nak event relay1 relay2 relay3`
**Advanced:**
- Selective migration: "Copy follow list members' data"
- Loop through follow list, query each author, republish to new relay
- Filter and migrate: "Copy only tagged/searched content"
- `nak req --tag client=X relay1 | nak event relay2`
- Cross-relay synchronization: "Keep two relays in sync"
- `nak sync source_relay dest_relay`
**Use cases:** Seeding new relays, backing up data, migrating content between relays, bridging Mostr/Fediverse content
---
### CONCEPT 3: Identity & Encoding
*"Working with Nostr identifiers and keys"*
**Basic:**
- Decode identifiers: "What's inside this npub/nevent?"
- `nak decode npub1...`
- `nak decode user@domain.com`
- Encode identifiers: "Turn this pubkey into npub"
- `nak encode npub <hex_pubkey>`
**Intermediate:**
- Generate keys: "Create a new identity"
- `nak key generate`
- `nak key generate | nak key public | nak encode npub`
- Extract hex from NIP-05: "Get the raw pubkey from an address"
- `nak decode user@domain.com | jq -r .pubkey`
- Create shareable references: "Make a nevent with relay hints"
- `nak encode nevent <event_id> --relay <relay>`
**Advanced:**
- Complex naddr creation: "Create addressable event reference with metadata"
- `nak encode naddr -k 30717 -a <author> -d <identifier> -r <relay>`
- Multi-relay nprofile: "Create profile with multiple relay hints"
- `nak encode nprofile <pubkey> -r relay1 -r relay2 -r relay3`
**Use cases:** Converting between formats, sharing references with relay hints, managing multiple identities, extracting pubkeys for scripting
---
### CONCEPT 4: Event Creation & Publishing
*"Creating and signing new events"*
**Basic:**
- Interactive creation: "Create an event with prompts"
- `nak event --prompt-sec <relay>`
- Simple note: "Publish a text note"
- `nak event -k 1 -c "Hello Nostr" <relay>`
**Intermediate:**
- Events with tags: "Create tagged content"
- `nak event -k 1 -t t=bitcoin -t t=nostr <relay>`
- Event deletion: "Delete a previous event"
- `nak event -k 5 -e <event_id> --prompt-sec <relay>`
- Replaceable events: "Create/update a profile or app data"
- `nak event -k 10019 -t mint=<url> -t pubkey=<key> <relay>`
**Advanced:**
- Remote signing with bunker: "Sign without exposing keys"
- `nak event --connect "bunker://..." <relay>`
- Batch event creation: "Generate many events via script"
- `for i in {1..100}; do nak event localhost:8000; done`
- Complex events from JSON: "Craft specific event structure"
- Modify JSON, then `cat event.json | nak event <relay>`
**Use cases:** Testing event creation, developing apps (kind 31990, 37515), wallet integration (kind 10019), moderation (kind 5), bunker/remote signing implementation
---
### CONCEPT 5: Development & Testing
*"Building on Nostr"*
**Basic:**
- Local relay testing: "Test against dev relay"
- `nak req localhost:8000`
- `nak event ws://127.0.0.1:7777`
**Intermediate:**
- Inspect JSON: "Examine event structure"
- `nak req -i <id> <relay> | jq .`
- Test search: "Verify search functionality"
- `nak req --search "<query>" localhost:8000`
- Admin operations: "Manage relay content"
- `nak admin --prompt-sec banevent --id <id> <relay>`
**Advanced:**
- Protocol bridging: "Query/test ATProto integration"
- `nak req --tag "proxy=at://..." eclipse.pub/relay`
- Git over Nostr: "Use git with Nostr transport"
- `nak git clone nostr://...`
- `nak req -k 30617 git.shakespeare.diy`
- Performance testing: "Measure query speed"
- `time nak req -k 0 -a <pubkey> <relay>`
- Custom event kinds: "Test proprietary event types"
- `nak req -k 37515 -a <author> -d <id> ditto.pub/relay`
**Use cases:** Relay development (Ditto), testing bridges (Mostr/Bluesky), developing video platforms, implementing Git-over-Nostr, testing search ranking algorithms, performance benchmarking
---
### CONCEPT 6: Analytics & Monitoring
*"Understanding Nostr data"*
**Basic:**
- Count results: "How many events match?"
- `nak req -k 1 <relay> | wc -l`
- `nak count -k 7 -e <event_id> <relay>`
**Intermediate:**
- Live monitoring: "Watch relay activity"
- `nak req --stream <relay>`
- `nak req -l 0 --stream relay1 relay2 relay3`
- Client analytics: "What apps are posting?"
- `nak req --tag client=<app> <relay>`
**Advanced:**
- Event chain analysis: "Track engagement"
- `nak count -k 7 -e <event_id> <relay>` (reactions)
- `nak req -k 6 -k 7 -e <event_id> <relay>` (reposts + reactions)
- Content ranking: "Find top/hot content"
- `nak req --search "sort:top" <relay>`
- Cross-relay comparison: "Compare event availability"
- Query same event from multiple relays, compare results
**Use cases:** Monitoring relay health, tracking client usage (Ditto, noStrudel, moStard), analyzing engagement, testing ranking algorithms
---
## Summary: The 6 Core Mental Models
1. **Query & Discovery**: "How do I find things?"
2. **Broadcast & Migration**: "How do I move things?"
3. **Identity & Encoding**: "How do I represent things?"
4. **Event Creation**: "How do I make things?"
5. **Development & Testing**: "How do I build things?"
6. **Analytics & Monitoring**: "How do I measure things?"
---
## Command Shapes and Edge-Cases
Non-obvious patterns and edge cases for nak commands.
### Signing Methods
**Using environment variable:**
```bash
export NOSTR_SECRET_KEY=<hex_key>
nak event -c "hello" # Automatically uses $NOSTR_SECRET_KEY
```
**Reading key from file:**
```bash
nak event -c "hello" --sec $(cat /path/to/key.txt)
```
### Content from File
**Using @ prefix to read content from file:**
```bash
echo "hello world" > content.txt
nak event -c @content.txt
```
### Tag Syntax
**Tag with multiple values (semicolon-separated):**
```bash
nak event -t custom="value1;value2;value3"
# Creates: ["custom", "value1", "value2", "value3"]
```
### Filter Output Modes
**Print bare filter (JSON only):**
```bash
nak req -k 1 -l 5 --bare
# Output: {"kinds":[1],"limit":5}
```
**Filter from stdin can be modified with flags:**
```bash
echo '{"kinds": [1]}' | nak req -l 5 -k 3 --bare
```
**Unlimited stream:**
```bash
nak req -l 0 --stream wss://relay.example.com
```
### Relay Specification
**Local relays and WebSocket schemes:**
```bash
nak req localhost:8000
nak req ws://127.0.0.1:7777
nak req relay.example.com # Assumes wss:// if not specified
```
### Encoding
**Encode from JSON stdin (auto-detects type):**
```bash
echo '{"pubkey":"<hex>","relays":["wss://relay.example.com"]}' | nak encode
```
### Key Operations
**Complete key generation pipeline:**
```bash
nak key generate | tee secret.key | nak key public | nak encode npub
# Saves private key to file AND prints the public npub
```
### Verification
**Verify and pipe:**
```bash
nak event -c "test" --sec <key> | nak verify && echo "Valid"
```
### Fetch vs Req
**nak fetch uses relay hints from identifiers:**
```bash
nak fetch nevent1... # Uses relays encoded in nevent
nak fetch naddr1... # Uses relays encoded in naddr
nak fetch alex@gleasonator.dev # Resolves NIP-05
nak fetch -r relay.primal.net naddr1... # Override relays
```
**nak req requires explicit relay specification:**
```bash
nak req -i <event_id> wss://relay.example.com
```
### Edge Cases
**No relays specified (prints event without publishing):**
```bash
nak event -c "test" --sec <key> # Just prints the event JSON
```
**Tag order matters for addressable events:**
```bash
# The first 'd' tag is the identifier
nak event -k 30023 -d first -d second # "first" is the identifier
```
**Timestamp override:**
```bash
nak event --ts 1700000000 -c "backdated" --sec <key>
nak event --ts 0 -c "genesis" --sec <key> # Event at Unix epoch
```
**Kind 0 (profile) requires JSON content:**
```bash
nak event -k 0 -c '{"name":"Alice","about":"Developer"}' --sec <key>
```
**POW (Proof of Work):**
```bash
nak event --pow 20 -c "mined event" --sec <key>
# Will compute hash until difficulty target is met
```
**NIP-42 AUTH:**
```bash
nak req --auth -k 1 wss://relay.example.com
nak event --auth -c "test" --sec <key> wss://relay.example.com
# Automatically handles AUTH challenges
```
**Stdin takes precedence over flags:**
```bash
echo '{"content":"from stdin"}' | nak event -c "from flag" --sec <key>
# Uses "from stdin" (stdin overrides flags)
```

View File

@@ -0,0 +1,51 @@
---
name: nostr
description: Knowledge about the Nostr protocol. Use to view up-to-date NIPs, discover capabilities of the Nostr protocol, and to implement Nostr functionality correctly.
license: CC-BY-SA-4.0
---
# Nostr Protocol
Nostr is a simple, open protocol that enables truly censorship-resistant and global social networks using cryptographic keys and signatures.
## Finding Nostr Documentation
All Nostr protocol documentation is maintained in the NIPs (Nostr Implementation Possibilities) repository. To access this information:
### Reading Individual NIPs
Individual NIPs can be fetched from:
```
https://github.com/nostr-protocol/nips/blob/master/{NIP}.md
```
For example, NIP-01 (the basic protocol specification) is available at:
```
https://github.com/nostr-protocol/nips/blob/master/01.md
```
### Finding Event Kinds
Documentation for Nostr event kinds is spread across one or more NIPs. **There is no direct relationship between the NIP number and the kind number.**
To find which NIPs document a specific event kind:
1. First, fetch the README:
```
https://github.com/nostr-protocol/nips/blob/master/README.md
```
2. Reference the "Event Kinds" table in the README to find which NIP(s) document the kind you're looking for
### Discovering Existing Capabilities
The README should be consulted to:
- **View the list of NIPs** - Use this to discover what capabilities already exist on the Nostr network
- **Review client and relay messages** - Understand the communication protocol
- **Check the list of tags** - See what standardized tags are available
- **Decide on using existing NIPs** - Before implementing a feature, check if an existing NIP already covers it
### Best Practices
Always start by fetching and reviewing the README to understand the current state of the protocol and avoid reinventing existing functionality.

49
AGENTS.md Normal file
View File

@@ -0,0 +1,49 @@
# Agent Instructions for Marco
Marco is a privacy-respecting, unhosted maps application that allows users to connect their own remote storage to sync place bookmarks across apps and devices.
## Relevant Commands
All commands must be executed using `pnpm`. Never use `npm`.
- **Install dependencies:** `pnpm install`
- **Start development server:** `pnpm start` (Runs Vite)
- **Run tests:** `pnpm test` (Builds and runs via Testem)
- **Linting:**
- `pnpm lint` (Runs ESLint, Stylelint, Prettier, and ember-template-lint concurrently)
- `pnpm lint:fix` (Automatically fixes linting and formatting errors)
### NEVER RUN
- `pnpm build` (only run for releases by the devs)
- `git add`, `git commit`, or any other commands that modify git history or the staging area
## Application Architecture & Frameworks
### Core Stack
- **Framework:** Ember.js (Octane / Polaris editions). The project heavily uses modern Ember paradigms including `.gjs` (template-tag format), `@glimmer/component`, and `@glimmer/tracking`.
- **Build System:** Vite coupled with Embroider (`@embroider/vite`) for fast, modern asset compilation.
### Mapping & Geocoding
- **Maps:** Uses OpenLayers (`ol` and `ol-mapbox-style`) for rendering the map interface.
- **Search:** Uses the Photon API (via `app/services/photon.js`) for geocoding and search functionality.
- **Data Source:** OpenStreetMap (OSM) data is fetched and parsed to display rich place details.
### Storage & Authentication
- **RemoteStorage:** The app is "unhosted" meaning user data isn't locked in a central database. It uses `remotestoragejs` and `@remotestorage/module-places` to sync bookmarks to a user's chosen storage provider. (Managed in `app/services/storage.js`).
- **OSM Auth:** Allows users to log into OpenStreetMap using OAuth 2.0 PKCE (`oauth2-pkce`) to fetch user info and potentially interact with OSM directly (Managed in `app/services/osm-auth.js`).
### Directory Structure Highlights
- `app/components/`: UI components using `.gjs` (Glimmer JS with embedded `<template>` tags).
- `app/services/`: Core business logic, state management, and API integrations.
- `app/utils/`: Helper functions for geohashing, parsing OSM tags, icon mapping, and link formatting.
- `tests/`: Comprehensive QUnit test suite containing unit, integration, and acceptance tests.
### Key Libraries
- `ember-concurrency` / `ember-lifeline`: Managing async tasks and debouncing.
- `remotestorage-widget`: The UI widget for connecting to a RemoteStorage provider.

View File

@@ -7,10 +7,14 @@ import Icon from '#components/icon';
import UserMenu from '#components/user-menu';
import SearchBox from '#components/search-box';
import CategoryChips from '#components/category-chips';
import { and } from 'ember-truth-helpers';
import cachedImage from '../modifiers/cached-image';
export default class AppHeaderComponent extends Component {
@service storage;
@service settings;
@service nostrAuth;
@service nostrData;
@tracked isUserMenuOpen = false;
@tracked searchQuery = '';
@@ -64,9 +68,19 @@ export default class AppHeaderComponent extends Component {
aria-label="User Menu"
{{on "click" this.toggleUserMenu}}
>
{{#if
(and this.nostrAuth.isConnected this.nostrData.profile.picture)
}}
<img
{{cachedImage this.nostrData.profile.picture}}
class="user-avatar"
alt="User Avatar"
/>
{{else}}
<div class="user-avatar-placeholder">
<Icon @name="user" @size={{20}} @color="white" />
</div>
{{/if}}
</button>
{{#if this.isUserMenuOpen}}

View File

@@ -1,31 +1,22 @@
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { service } from '@ember/service';
import Icon from '#components/icon';
import eq from 'ember-truth-helpers/helpers/eq';
import AppMenuSettingsMapUi from './settings/map-ui';
import AppMenuSettingsApis from './settings/apis';
import AppMenuSettingsNostr from './settings/nostr';
export default class AppMenuSettings extends Component {
@service settings;
@action
updateApi(event) {
this.settings.updateOverpassApi(event.target.value);
}
updateSetting(key, event) {
let value = event.target.value;
if (value === 'true') value = true;
if (value === 'false') value = false;
@action
toggleKinetic(event) {
this.settings.updateMapKinetic(event.target.value === 'true');
}
@action
toggleQuickSearchButtons(event) {
this.settings.updateShowQuickSearchButtons(event.target.value === 'true');
}
@action
updatePhotonApi(event) {
this.settings.updatePhotonApi(event.target.value);
this.settings.update(key, value);
}
<template>
@@ -41,88 +32,9 @@ export default class AppMenuSettings extends Component {
<div class="sidebar-content">
<section class="settings-section">
<div class="form-group">
<label for="show-quick-search">Quick search buttons visible</label>
<select
id="show-quick-search"
class="form-control"
{{on "change" this.toggleQuickSearchButtons}}
>
<option
value="true"
selected={{if this.settings.showQuickSearchButtons "selected"}}
>
Yes
</option>
<option
value="false"
selected={{unless
this.settings.showQuickSearchButtons
"selected"
}}
>
No
</option>
</select>
</div>
<div class="form-group">
<label for="map-kinetic">Map Inertia (Kinetic Panning)</label>
<select
id="map-kinetic"
class="form-control"
{{on "change" this.toggleKinetic}}
>
<option
value="true"
selected={{if this.settings.mapKinetic "selected"}}
>
On
</option>
<option
value="false"
selected={{unless this.settings.mapKinetic "selected"}}
>
Off
</option>
</select>
</div>
<div class="form-group">
<label for="overpass-api">Overpass API Provider</label>
<select
id="overpass-api"
class="form-control"
{{on "change" this.updateApi}}
>
{{#each this.settings.overpassApis as |api|}}
<option
value={{api.url}}
selected={{if
(eq api.url this.settings.overpassApi)
"selected"
}}
>
{{api.name}}
</option>
{{/each}}
</select>
</div>
<div class="form-group">
<label for="photon-api">Photon API Provider</label>
<select
id="photon-api"
class="form-control"
{{on "change" this.updatePhotonApi}}
>
{{#each this.settings.photonApis as |api|}}
<option
value={{api.url}}
selected={{if (eq api.url this.settings.photonApi) "selected"}}
>
{{api.name}}
</option>
{{/each}}
</select>
</div>
<AppMenuSettingsMapUi @onChange={{this.updateSetting}} />
<AppMenuSettingsApis @onChange={{this.updateSetting}} />
<AppMenuSettingsNostr @onChange={{this.updateSetting}} />
</section>
</div>
</template>

View File

@@ -0,0 +1,59 @@
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
import { service } from '@ember/service';
import { fn } from '@ember/helper';
import Icon from '#components/icon';
import eq from 'ember-truth-helpers/helpers/eq';
export default class AppMenuSettingsApis extends Component {
@service settings;
<template>
{{! template-lint-disable no-nested-interactive }}
<details>
<summary>
<Icon @name="server" @size={{20}} />
<span>API Providers</span>
</summary>
<div class="details-content">
<div class="form-group">
<label for="overpass-api">Overpass API Provider</label>
<select
id="overpass-api"
class="form-control"
{{on "change" (fn @onChange "overpassApi")}}
>
{{#each this.settings.overpassApis as |api|}}
<option
value={{api.url}}
selected={{if
(eq api.url this.settings.overpassApi)
"selected"
}}
>
{{api.name}}
</option>
{{/each}}
</select>
</div>
<div class="form-group">
<label for="photon-api">Photon API Provider</label>
<select
id="photon-api"
class="form-control"
{{on "change" (fn @onChange "photonApi")}}
>
{{#each this.settings.photonApis as |api|}}
<option
value={{api.url}}
selected={{if (eq api.url this.settings.photonApi) "selected"}}
>
{{api.name}}
</option>
{{/each}}
</select>
</div>
</div>
</details>
</template>
}

View File

@@ -0,0 +1,66 @@
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
import { service } from '@ember/service';
import { fn } from '@ember/helper';
import Icon from '#components/icon';
export default class AppMenuSettingsMapUi extends Component {
@service settings;
<template>
{{! template-lint-disable no-nested-interactive }}
<details>
<summary>
<Icon @name="map" @size={{20}} />
<span>Map & UI</span>
</summary>
<div class="details-content">
<div class="form-group">
<label for="show-quick-search">Quick search buttons visible</label>
<select
id="show-quick-search"
class="form-control"
{{on "change" (fn @onChange "showQuickSearchButtons")}}
>
<option
value="true"
selected={{if this.settings.showQuickSearchButtons "selected"}}
>
Yes
</option>
<option
value="false"
selected={{unless
this.settings.showQuickSearchButtons
"selected"
}}
>
No
</option>
</select>
</div>
<div class="form-group">
<label for="map-kinetic">Map Inertia (Kinetic Panning)</label>
<select
id="map-kinetic"
class="form-control"
{{on "change" (fn @onChange "mapKinetic")}}
>
<option
value="true"
selected={{if this.settings.mapKinetic "selected"}}
>
On
</option>
<option
value="false"
selected={{unless this.settings.mapKinetic "selected"}}
>
Off
</option>
</select>
</div>
</div>
</details>
</template>
}

View File

@@ -0,0 +1,219 @@
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { service } from '@ember/service';
import { fn } from '@ember/helper';
import Icon from '#components/icon';
import { normalizeRelayUrl } from '../../../utils/nostr';
const stripProtocol = (url) => (url ? url.replace(/^wss?:\/\//, '') : '');
export default class AppMenuSettingsNostr extends Component {
@service settings;
@service nostrData;
@tracked newReadRelay = '';
@tracked newWriteRelay = '';
@action
updateNewReadRelay(event) {
this.newReadRelay = event.target.value;
}
@action
updateNewWriteRelay(event) {
this.newWriteRelay = event.target.value;
}
@action
addReadRelay() {
const url = normalizeRelayUrl(this.newReadRelay);
if (!url) return;
const current =
this.settings.nostrReadRelays || this.nostrData.defaultReadRelays;
const set = new Set([...current, url]);
this.settings.update('nostrReadRelays', Array.from(set));
this.newReadRelay = '';
}
@action
removeReadRelay(url) {
const current =
this.settings.nostrReadRelays || this.nostrData.defaultReadRelays;
const filtered = current.filter((r) => r !== url);
this.settings.update('nostrReadRelays', filtered);
}
@action
handleReadRelayKeydown(event) {
if (event.key === 'Enter') {
this.addReadRelay();
}
}
@action
handleWriteRelayKeydown(event) {
if (event.key === 'Enter') {
this.addWriteRelay();
}
}
@action
resetReadRelays() {
this.settings.update('nostrReadRelays', null);
}
@action
addWriteRelay() {
const url = normalizeRelayUrl(this.newWriteRelay);
if (!url) return;
const current =
this.settings.nostrWriteRelays || this.nostrData.defaultWriteRelays;
const set = new Set([...current, url]);
this.settings.update('nostrWriteRelays', Array.from(set));
this.newWriteRelay = '';
}
@action
removeWriteRelay(url) {
const current =
this.settings.nostrWriteRelays || this.nostrData.defaultWriteRelays;
const filtered = current.filter((r) => r !== url);
this.settings.update('nostrWriteRelays', filtered);
}
@action
resetWriteRelays() {
this.settings.update('nostrWriteRelays', null);
}
<template>
{{! template-lint-disable no-nested-interactive }}
<details>
<summary>
<Icon @name="zap" @size={{20}} />
<span>Nostr</span>
</summary>
<div class="details-content">
<div class="form-group">
<label for="nostr-photo-fallback-uploads">Upload photos to fallback
servers</label>
<select
id="nostr-photo-fallback-uploads"
class="form-control"
{{on "change" (fn @onChange "nostrPhotoFallbackUploads")}}
>
<option
value="true"
selected={{if this.settings.nostrPhotoFallbackUploads "selected"}}
>
Yes
</option>
<option
value="false"
selected={{unless
this.settings.nostrPhotoFallbackUploads
"selected"
}}
>
No
</option>
</select>
</div>
<div class="form-group">
<label for="new-read-relay">Read Relays</label>
<ul class="relay-list">
{{#each this.nostrData.activeReadRelays as |relay|}}
<li>
<span>{{stripProtocol relay}}</span>
<button
type="button"
class="btn-remove-relay"
title="Remove relay"
aria-label="Remove"
{{on "click" (fn this.removeReadRelay relay)}}
>
<Icon @name="x" @size={{14}} @color="currentColor" />
</button>
</li>
{{/each}}
</ul>
<div class="add-relay-input">
<input
id="new-read-relay"
type="text"
class="form-control"
placeholder="relay.example.com"
value={{this.newReadRelay}}
{{on "input" this.updateNewReadRelay}}
{{on "keydown" this.handleReadRelayKeydown}}
/>
<button
type="button"
class="btn btn-secondary"
{{on "click" this.addReadRelay}}
>Add</button>
</div>
{{#if this.settings.nostrReadRelays}}
<button
type="button"
class="btn-link reset-relays"
{{on "click" this.resetReadRelays}}
>
Reset to Defaults
</button>
{{/if}}
</div>
<div class="form-group">
<label for="new-write-relay">Write Relays</label>
<ul class="relay-list">
{{#each this.nostrData.activeWriteRelays as |relay|}}
<li>
<span>{{stripProtocol relay}}</span>
<button
type="button"
class="btn-remove-relay"
title="Remove relay"
aria-label="Remove"
{{on "click" (fn this.removeWriteRelay relay)}}
>
<Icon @name="x" @size={{14}} @color="currentColor" />
</button>
</li>
{{/each}}
</ul>
<div class="add-relay-input">
<input
id="new-write-relay"
type="text"
class="form-control"
placeholder="relay.example.com"
value={{this.newWriteRelay}}
{{on "input" this.updateNewWriteRelay}}
{{on "keydown" this.handleWriteRelayKeydown}}
/>
<button
type="button"
class="btn btn-secondary"
{{on "click" this.addWriteRelay}}
>Add</button>
</div>
{{#if this.settings.nostrWriteRelays}}
<button
type="button"
class="btn-link reset-relays"
{{on "click" this.resetWriteRelays}}
>
Reset to Defaults
</button>
{{/if}}
</div>
</div>
</details>
</template>
}

View File

@@ -0,0 +1,37 @@
import Component from '@glimmer/component';
import { modifier } from 'ember-modifier';
import { decode } from 'blurhash';
export default class Blurhash extends Component {
renderBlurhash = modifier((canvas, [hash, width, height]) => {
if (!hash || !canvas) return;
// Default size to a small multiple of aspect ratio to save CPU
// 32x18 is a good balance of speed vs quality for 16:9
const renderWidth = width || 32;
const renderHeight = height || 18;
canvas.width = renderWidth;
canvas.height = renderHeight;
const ctx = canvas.getContext('2d');
if (!ctx) return;
try {
const pixels = decode(hash, renderWidth, renderHeight);
const imageData = ctx.createImageData(renderWidth, renderHeight);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);
} catch (e) {
console.warn('Failed to decode blurhash:', e.message || e);
}
});
<template>
<canvas
class="blurhash-canvas"
...attributes
{{this.renderBlurhash @hash @width @height}}
></canvas>
</template>
}

View File

@@ -27,6 +27,7 @@ export default class MapComponent extends Component {
@service mapUi;
@service router;
@service settings;
@service nostrData;
mapInstance;
bookmarkSource;
@@ -1033,7 +1034,7 @@ export default class MapComponent extends Component {
}
handleMapMove = async () => {
if (!this.mapInstance) return;
if (!this.mapInstance || this.isDestroying || this.isDestroyed) return;
const view = this.mapInstance.getView();
const center = toLonLat(view.getCenter());
@@ -1078,6 +1079,7 @@ export default class MapComponent extends Component {
const bbox = { minLat, minLon, maxLat, maxLon };
this.mapUi.updateBounds(bbox);
await this.storage.loadPlacesInBounds(bbox);
this.nostrData.loadPlacesInBounds(bbox);
this.loadBookmarks(this.storage.placesInView);
// Persist view to localStorage

43
app/components/modal.gjs Normal file
View File

@@ -0,0 +1,43 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
import Icon from './icon';
export default class Modal extends Component {
@action
stopProp(e) {
e.stopPropagation();
}
@action
close() {
if (this.args.onClose) {
this.args.onClose();
}
}
<template>
<div
class="modal-overlay"
role="dialog"
tabindex="-1"
{{on "click" this.close}}
>
<div
class="modal-content"
role="document"
tabindex="0"
{{on "click" this.stopProp}}
>
<button
type="button"
class="close-modal-btn btn-text"
{{on "click" this.close}}
>
<Icon @name="x" @size={{24}} />
</button>
{{yield}}
</div>
</div>
</template>
}

View File

@@ -0,0 +1,93 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { 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;
@service toast;
get hasExtension() {
return typeof window !== 'undefined' && typeof window.nostr !== 'undefined';
}
@action
async connectExtension() {
try {
await this.nostrAuth.connectWithExtension();
this.toast.show('Nostr connected successfully');
if (this.args.onConnect) {
this.args.onConnect();
}
} catch (e) {
console.error(e);
alert(e.message);
}
}
@action
async connectApp() {
try {
await this.nostrAuth.connectWithApp();
this.toast.show('Nostr connected successfully');
if (this.args.onConnect) {
this.args.onConnect();
}
} catch (e) {
console.error(e);
alert(e.message);
}
}
<template>
<div class="nostr-connect-modal">
<h2>Connect with Nostr</h2>
<div class="nostr-connect-options">
{{#if this.hasExtension}}
<button
class="btn btn-primary"
type="button"
{{on "click" this.connectExtension}}
>
Browser Extension (nos2x, Alby)
</button>
{{else}}
<button
class="btn btn-outline"
type="button"
disabled
title="No Nostr extension found in your browser."
>
Browser Extension (Not Found)
</button>
{{/if}}
<button
class="btn btn-primary"
type="button"
{{on "click" this.connectApp}}
>
Mobile Signer App (Amber, etc.)
</button>
</div>
{{#if (eq this.nostrAuth.connectStatus "waiting")}}
<div class="nostr-connect-status">
{{#if this.nostrAuth.isMobile}}
<p>Waiting for you to approve the connection in your mobile signer
app...</p>
{{else}}
<p>Scan this QR code with a compatible Nostr signer app (like
Amber):</p>
<div class="qr-code-container">
<canvas {{qrCode this.nostrAuth.connectUri}}></canvas>
</div>
{{/if}}
</div>
{{/if}}
</div>
</template>
}

View File

@@ -6,17 +6,66 @@ import { humanizeOsmTag } from '../utils/format-text';
import { getLocalizedName, getPlaceType } from '../utils/osm';
import { mapToStorageSchema } from '../utils/place-mapping';
import { getSocialInfo } from '../utils/social-links';
import { parsePlacePhotos } from '../utils/nostr';
import Icon from '../components/icon';
import PlaceEditForm from './place-edit-form';
import PlaceListsManager from './place-lists-manager';
import PlacePhotoUpload from './place-photo-upload';
import NostrConnect from './nostr-connect';
import Modal from './modal';
import PlacePhotosCarousel from './place-photos-carousel';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class PlaceDetails extends Component {
@service storage;
@service nostrAuth;
@service nostrData;
@tracked isEditing = false;
@tracked showLists = false;
@tracked isPhotoUploadModalOpen = false;
@tracked isNostrConnectModalOpen = false;
@tracked newlyUploadedPhotoId = null;
@action
openPhotoUploadModal(e) {
if (e) {
e.preventDefault();
}
if (!this.nostrAuth.isConnected) {
this.isNostrConnectModalOpen = true;
} else {
this.isPhotoUploadModalOpen = true;
}
}
@action
closePhotoUploadModal(eventId) {
this.isPhotoUploadModalOpen = false;
if (typeof eventId === 'string') {
this.newlyUploadedPhotoId = eventId;
// Allow DOM to update first, then scroll to the top to show the new photo in the carousel
setTimeout(() => {
const sidebar = document.querySelector('.sidebar-content');
if (sidebar) {
sidebar.scrollTop = 0;
}
}, 50);
}
}
@action
closeNostrConnectModal() {
this.isNostrConnectModalOpen = false;
}
@action
onNostrConnected() {
this.isNostrConnectModalOpen = false;
this.isPhotoUploadModalOpen = true;
}
get isSaved() {
return this.storage.isPlaceSaved(this.place.id || this.place.osmId);
@@ -42,6 +91,16 @@ export default class PlaceDetails extends Component {
return this.place.title || getLocalizedName(this.tags) || 'Unnamed Place';
}
get photos() {
const rawPhotos = this.nostrData.placePhotos;
const parsedPhotos = parsePlacePhotos(rawPhotos);
return parsedPhotos.map((photo) => ({
...photo,
style: htmlSafe(`--slide-ratio: ${photo.aspectRatio};`),
}));
}
@action
startEditing() {
if (!this.isSaved) return; // Only allow editing saved places
@@ -130,15 +189,24 @@ export default class PlaceDetails extends Component {
formatMultiLine(val, type) {
if (!val) return null;
const parts = val
const parts = [
...new Set(
val
.split(';')
.map((s) => s.trim())
.filter(Boolean);
.filter(Boolean)
),
];
if (parts.length === 0) return null;
if (type === 'phone') {
return htmlSafe(
parts.map((p) => `<a href="tel:${p}">${p}</a>`).join('<br>')
parts
.map((p) => {
const safeTel = p.replace(/[\s-]+/g, '');
return `<a href="tel:${safeTel}">${p}</a>`;
})
.join('<br>')
);
}
@@ -148,6 +216,17 @@ export default class PlaceDetails extends Component {
);
}
if (type === 'whatsapp') {
return htmlSafe(
parts
.map((p) => {
const safeTel = p.replace(/[\s-]+/g, '');
return `<a href="https://wa.me/${safeTel}" target="_blank" rel="noopener noreferrer">${p}</a>`;
})
.join('<br>')
);
}
if (type === 'url') {
return htmlSafe(
parts
@@ -165,8 +244,27 @@ export default class PlaceDetails extends Component {
}
get phone() {
const val = this.tags.phone || this.tags['contact:phone'];
return this.formatMultiLine(val, 'phone');
const rawValues = [
this.tags.phone,
this.tags['contact:phone'],
this.tags.mobile,
this.tags['contact:mobile'],
].filter(Boolean);
if (rawValues.length === 0) return null;
return this.formatMultiLine(rawValues.join(';'), 'phone');
}
get whatsapp() {
const rawValues = [
this.tags.whatsapp,
this.tags['contact:whatsapp'],
].filter(Boolean);
if (rawValues.length === 0) return null;
return this.formatMultiLine(rawValues.join(';'), 'whatsapp');
}
get email() {
@@ -266,6 +364,12 @@ export default class PlaceDetails extends Component {
@onCancel={{this.cancelEditing}}
/>
{{else}}
<PlacePhotosCarousel
@photos={{this.photos}}
@name={{this.name}}
@resetKey={{this.place.osmId}}
@scrollToEventId={{this.newlyUploadedPhotoId}}
/>
<h3>{{this.name}}</h3>
<p class="place-type">
{{this.type}}
@@ -343,6 +447,15 @@ export default class PlaceDetails extends Component {
</p>
{{/if}}
{{#if this.whatsapp}}
<p class="content-with-icon">
<Icon @name="whatsapp" @title="WhatsApp" />
<span>
{{this.whatsapp}}
</span>
</p>
{{/if}}
{{#if this.website}}
<p class="content-with-icon">
<Icon @name="globe" @title="Website" />
@@ -452,6 +565,38 @@ export default class PlaceDetails extends Component {
{{/if}}
</div>
{{#if this.osmUrl}}
<div class="meta-info">
<p class="content-with-icon">
<Icon @name="camera" />
<span>
<button
type="button"
class="btn-link"
{{on "click" this.openPhotoUploadModal}}
>
Add a photo
</button>
</span>
</p>
</div>
{{/if}}
</div>
{{#if this.isPhotoUploadModalOpen}}
<Modal @onClose={{this.closePhotoUploadModal}}>
<PlacePhotoUpload
@place={{this.saveablePlace}}
@onClose={{this.closePhotoUploadModal}}
/>
</Modal>
{{/if}}
{{#if this.isNostrConnectModalOpen}}
<Modal @onClose={{this.closeNostrConnectModal}}>
<NostrConnect @onConnect={{this.onNostrConnected}} />
</Modal>
{{/if}}
</template>
}

View File

@@ -0,0 +1,154 @@
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';
import { fn } from '@ember/helper';
import { isMobile } from '../utils/device';
import Blurhash from './blurhash';
const MAX_IMAGE_DIMENSION = 1920;
const IMAGE_QUALITY = 0.94;
const MAX_THUMBNAIL_DIMENSION = 350;
const THUMBNAIL_QUALITY = 0.9;
export default class PlacePhotoUploadItem extends Component {
@service blossom;
@service imageProcessor;
@service toast;
@tracked thumbnailUrl = '';
@tracked blurhash = '';
@tracked error = '';
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);
}
}
@action
showErrorToast() {
if (this.error) {
this.toast.show(this.error);
}
}
uploadTask = task(async (file) => {
this.error = '';
try {
// 1. Process main image and generate blurhash in worker
const mainData = await this.imageProcessor.process(
file,
MAX_IMAGE_DIMENSION,
IMAGE_QUALITY,
true // computeBlurhash
);
this.blurhash = mainData.blurhash;
// 2. Process thumbnail (no blurhash needed)
const thumbData = await this.imageProcessor.process(
file,
MAX_THUMBNAIL_DIMENSION,
THUMBNAIL_QUALITY,
false
);
// 3. Upload main image
// 4. Upload thumbnail
let mainResult, thumbResult;
const isMobileDevice = isMobile();
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);
[mainResult, thumbResult] = await Promise.all([
mainUploadPromise,
thumbUploadPromise,
]);
}
if (this.args.onSuccess) {
this.args.onSuccess({
file,
url: mainResult.url,
fallbackUrls: mainResult.fallbackUrls,
thumbUrl: thumbResult.url,
blurhash: mainData.blurhash,
type: 'image/jpeg',
dim: mainData.dim,
hash: mainResult.hash,
thumbHash: thumbResult.hash,
});
}
} catch (e) {
this.error = e.message;
}
});
<template>
<div
class="photo-upload-item
{{if this.uploadTask.isRunning 'is-uploading'}}
{{if this.error 'has-error'}}"
>
{{#if this.blurhash}}
<Blurhash @hash={{this.blurhash}} class="place-header-photo-blur" />
{{/if}}
<img src={{this.thumbnailUrl}} alt="thumbnail" />
{{#if this.uploadTask.isRunning}}
<div class="overlay">
<Icon
@name="loading-ring"
@size={{24}}
@color="white"
class="spin-animation"
/>
</div>
{{/if}}
{{#if this.error}}
<button
type="button"
class="overlay error-overlay"
title={{this.error}}
{{on "click" this.showErrorToast}}
>
<Icon @name="alert-circle" @size={{24}} @color="white" />
</button>
{{/if}}
<button
type="button"
class="btn-remove-photo"
title="Remove photo"
{{on "click" (fn @onRemove @file)}}
>
<Icon @name="x" @size={{16}} @color="white" />
</button>
</div>
</template>
}

View File

@@ -0,0 +1,265 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { 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 PlacePhotoUploadItem from './place-photo-upload-item';
import Icon from '#components/icon';
import { or, not } from 'ember-truth-helpers';
export default class PlacePhotoUpload extends Component {
@service nostrAuth;
@service nostrRelay;
@service nostrData;
@service blossom;
@service toast;
@tracked file = null;
@tracked uploadedPhoto = null;
@tracked status = '';
@tracked error = '';
@tracked isPublishing = false;
@tracked isDragging = false;
get place() {
return this.args.place || {};
}
get title() {
return this.place.title || 'this place';
}
get allUploaded() {
return (
this.file && this.uploadedPhoto && this.file === this.uploadedPhoto.file
);
}
@action
handleFileSelect(event) {
this.addFile(event.target.files[0]);
event.target.value = ''; // Reset input
}
@action
handleDragOver(event) {
event.preventDefault();
this.isDragging = true;
}
@action
handleDragLeave(event) {
event.preventDefault();
this.isDragging = false;
}
@action
handleDrop(event) {
event.preventDefault();
this.isDragging = false;
if (event.dataTransfer.files.length > 0) {
this.addFile(event.dataTransfer.files[0]);
}
}
addFile(file) {
if (!file || !file.type.startsWith('image/')) {
this.error = 'Please select a valid image file.';
return;
}
this.error = '';
// If a photo was already uploaded but not published, delete it from the server
if (this.uploadedPhoto) {
this.deletePhotoTask.perform(this.uploadedPhoto);
}
this.file = file;
this.uploadedPhoto = null;
}
@action
handleUploadSuccess(photoData) {
this.uploadedPhoto = photoData;
}
@action
removeFile() {
if (this.uploadedPhoto) {
this.deletePhotoTask.perform(this.uploadedPhoto);
}
this.file = null;
this.uploadedPhoto = null;
}
deletePhotoTask = task(async (photoData) => {
try {
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);
}
});
@action
async publish() {
if (!this.nostrAuth.isConnected) {
this.error = 'You must connect Nostr first.';
return;
}
if (!this.allUploaded) {
this.error = 'Please wait for all photos to finish uploading.';
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;
}
this.status = 'Publishing event...';
this.error = '';
this.isPublishing = true;
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)]);
}
const photo = this.uploadedPhoto;
const imeta = ['imeta', `url ${photo.url}`];
imeta.push(`m ${photo.type}`);
if (photo.dim) {
imeta.push(`dim ${photo.dim}`);
}
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);
// NIP-XX draft Place Photo event
const template = {
kind: 360,
content: '',
tags,
};
if (!template.created_at) {
template.created_at = Math.floor(Date.now() / 1000);
}
const event = await factory.sign(template);
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
this.nostrData.store.add(event);
this.toast.show('Photo published successfully');
this.status = '';
// Clear out the file so user can upload more or be done
this.file = null;
this.uploadedPhoto = null;
if (this.args.onClose) {
this.args.onClose(event.id);
}
} catch (e) {
this.error = 'Failed to publish: ' + e.message;
this.status = '';
} finally {
this.isPublishing = false;
}
}
<template>
<div class="place-photo-upload">
<h2>Add Photo for {{this.title}}</h2>
{{#if this.error}}
<div class="alert alert-error">
{{this.error}}
</div>
{{/if}}
{{#if this.status}}
<div class="alert alert-info">
{{this.status}}
</div>
{{/if}}
{{#if this.file}}
<div class="photo-grid">
<PlacePhotoUploadItem
@file={{this.file}}
@onSuccess={{this.handleUploadSuccess}}
@onRemove={{this.removeFile}}
/>
</div>
<button
type="button"
class="btn btn-primary btn-publish"
disabled={{or (not this.allUploaded) this.isPublishing}}
{{on "click" this.publish}}
>
{{#if this.isPublishing}}
Publishing...
{{else}}
Publish Photo
{{/if}}
</button>
{{else}}
<div
class="dropzone {{if this.isDragging 'is-dragging'}}"
{{on "dragover" this.handleDragOver}}
{{on "dragleave" this.handleDragLeave}}
{{on "drop" this.handleDrop}}
>
<label for="photo-upload-input" class="dropzone-label">
<Icon @name="upload-cloud" @size={{48}} @color="#ccc" />
<p>Drag and drop a photo here, or click to browse</p>
</label>
<input
id="photo-upload-input"
type="file"
accept="image/*"
class="file-input-hidden"
disabled={{this.isPublishing}}
{{on "change" this.handleFileSelect}}
/>
</div>
{{/if}}
</div>
</template>
}

View File

@@ -0,0 +1,188 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import Blurhash from './blurhash';
import Icon from './icon';
import fadeInImage from '../modifiers/fade-in-image';
import { on } from '@ember/modifier';
import { modifier } from 'ember-modifier';
export default class PlacePhotosCarousel extends Component {
@tracked canScrollLeft = false;
@tracked canScrollRight = false;
carouselElement = null;
get photos() {
return this.args.photos || [];
}
get showChevrons() {
return this.photos.length > 1;
}
get cannotScrollLeft() {
return !this.canScrollLeft;
}
get cannotScrollRight() {
return !this.canScrollRight;
}
lastResetKey = null;
resetScrollPosition = modifier((element, [resetKey]) => {
if (resetKey !== undefined && resetKey !== this.lastResetKey) {
this.lastResetKey = resetKey;
element.scrollLeft = 0;
setTimeout(() => this.updateScrollState(), 50);
}
});
scrollToNewPhoto = modifier((element, [eventId]) => {
if (eventId && eventId !== this.lastEventId) {
this.lastEventId = eventId;
// Allow DOM to update first since the photo was *just* added to the store
setTimeout(() => {
const targetSlide = element.querySelector(
`[data-event-id="${eventId}"]`
);
if (targetSlide) {
element.scrollLeft = targetSlide.offsetLeft;
}
}, 100);
}
});
setupCarousel = modifier((element) => {
this.carouselElement = element;
// Defer the initial calculation slightly to ensure CSS and images have applied
setTimeout(() => {
this.updateScrollState();
}, 50);
let resizeObserver;
if (window.ResizeObserver) {
resizeObserver = new ResizeObserver(() => this.updateScrollState());
resizeObserver.observe(element);
}
return () => {
if (resizeObserver) {
resizeObserver.unobserve(element);
}
};
});
@action
updateScrollState() {
if (!this.carouselElement) return;
const { scrollLeft, scrollWidth, clientWidth } = this.carouselElement;
// tolerance of 1px for floating point rounding issues
this.canScrollLeft = scrollLeft > 1;
this.canScrollRight = scrollLeft + clientWidth < scrollWidth - 1;
}
@action
scrollLeft() {
if (!this.carouselElement) return;
this.carouselElement.scrollBy({
left: -this.carouselElement.clientWidth,
behavior: 'smooth',
});
}
@action
scrollRight() {
if (!this.carouselElement) return;
this.carouselElement.scrollBy({
left: this.carouselElement.clientWidth,
behavior: 'smooth',
});
}
<template>
{{#if this.photos.length}}
<div class="place-photos-carousel-wrapper">
<div
class="place-photos-carousel-track"
{{this.setupCarousel}}
{{this.resetScrollPosition @resetKey}}
{{this.scrollToNewPhoto @scrollToEventId}}
{{on "scroll" this.updateScrollState}}
>
{{#each this.photos as |photo|}}
{{! template-lint-disable no-inline-styles }}
<div
class="carousel-slide"
style={{photo.style}}
data-event-id={{photo.eventId}}
>
{{#if photo.blurhash}}
<Blurhash
@hash={{photo.blurhash}}
@width={{32}}
@height={{18}}
class="place-header-photo-blur"
/>
{{/if}}
{{#if photo.isLandscape}}
<picture>
{{#if photo.thumbUrl}}
<source
media="(max-width: 768px)"
data-srcset={{photo.thumbUrl}}
/>
{{/if}}
<img
data-src={{photo.url}}
class="place-header-photo landscape"
alt={{@name}}
{{fadeInImage photo.url}}
/>
</picture>
{{else}}
{{! Portrait uses thumb everywhere if available }}
<img
data-src={{if photo.thumbUrl photo.thumbUrl photo.url}}
class="place-header-photo portrait"
alt={{@name}}
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
/>
{{/if}}
</div>
{{/each}}
<div class="carousel-placeholder"></div>
</div>
{{#if this.showChevrons}}
<button
type="button"
class="carousel-nav-btn prev
{{if this.cannotScrollLeft 'disabled'}}"
{{on "click" this.scrollLeft}}
disabled={{this.cannotScrollLeft}}
aria-label="Previous photo"
>
<Icon @name="chevron-left" @color="currentColor" />
</button>
<button
type="button"
class="carousel-nav-btn next
{{if this.cannotScrollRight 'disabled'}}"
{{on "click" this.scrollRight}}
disabled={{this.cannotScrollRight}}
aria-label="Next photo"
>
<Icon @name="chevron-right" @color="currentColor" />
</button>
{{/if}}
</div>
{{/if}}
</template>
}

View File

@@ -14,6 +14,7 @@ export default class PlacesSidebar extends Component {
@service storage;
@service router;
@service mapUi;
@service nostrData;
@action
createNewPlace() {
@@ -149,9 +150,17 @@ export default class PlacesSidebar extends Component {
return !qp.q && !qp.category && qp.lat && qp.lon;
}
get hasHeaderPhoto() {
return (
this.args.selectedPlace &&
this.nostrData.placePhotos &&
this.nostrData.placePhotos.length > 0
);
}
<template>
<div class="sidebar">
<div class="sidebar-header">
<div class="sidebar-header {{if this.hasHeaderPhoto 'no-border'}}">
{{#if @selectedPlace}}
<button
type="button"

View File

@@ -3,15 +3,22 @@ import { action } from '@ember/object';
import { service } from '@ember/service';
import Icon from '#components/icon';
import { on } from '@ember/modifier';
import { tracked } from '@glimmer/tracking';
import Modal from './modal';
import NostrConnect from './nostr-connect';
export default class UserMenuComponent extends Component {
@service storage;
@service osmAuth;
@service nostrAuth;
@service nostrData;
@tracked isNostrConnectModalOpen = false;
@action
connectRS() {
this.args.onClose();
this.args.storage.connect();
this.args.storage.showConnectWidget();
}
@action
@@ -30,6 +37,21 @@ export default class UserMenuComponent extends Component {
this.osmAuth.logout();
}
@action
openNostrConnectModal() {
this.isNostrConnectModalOpen = true;
}
@action
closeNostrConnectModal() {
this.isNostrConnectModalOpen = false;
}
@action
disconnectNostr() {
this.nostrAuth.disconnect();
}
<template>
<div class="user-menu-popover">
<ul class="account-list">
@@ -91,18 +113,43 @@ export default class UserMenuComponent extends Component {
</div>
</li>
<li class="account-item disabled">
<li class="account-item">
<div class="account-header">
<div class="account-info">
<Icon @name="zap" @size={{18}} />
<span>Nostr</span>
</div>
{{#if this.nostrAuth.isConnected}}
<button
class="btn-text text-danger"
type="button"
{{on "click" this.disconnectNostr}}
>Disconnect</button>
{{else}}
<button
class="btn-text text-primary"
type="button"
{{on "click" this.openNostrConnectModal}}
>Connect</button>
{{/if}}
</div>
<div class="account-status">
Coming soon
{{#if this.nostrAuth.isConnected}}
<strong title={{this.nostrAuth.pubkey}}>
{{this.nostrData.userDisplayName}}
</strong>
{{else}}
Not connected
{{/if}}
</div>
</li>
</ul>
</div>
{{#if this.isNostrConnectModalOpen}}
<Modal @onClose={{this.closeNostrConnectModal}}>
<NostrConnect @onConnect={{this.closeNostrConnectModal}} />
</Modal>
{{/if}}
</template>
}

4
app/icons/whatsapp.svg Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="-1.66 0 740.82 740.82" xmlns="http://www.w3.org/2000/svg">
<path d="m630.06 107.66c-69.329-69.387-161.53-107.62-259.76-107.66-202.4 0-367.13 164.67-367.22 367.07-0.027 64.699 16.883 127.86 49.016 183.52l-52.095 190.23 194.67-51.047c53.634 29.244 114.02 44.656 175.48 44.682h0.151c202.38 0 367.13-164.69 367.21-367.09 0.039-98.088-38.121-190.32-107.45-259.71m-259.76 564.8h-0.125c-54.766-0.021-108.48-14.729-155.34-42.529l-11.146-6.613-115.52 30.293 30.834-112.59-7.258-11.543c-30.552-48.58-46.689-104.73-46.665-162.38 0.067-168.23 136.99-305.1 305.34-305.1 81.521 0.031 158.15 31.81 215.78 89.482s89.342 134.33 89.311 215.86c-0.07 168.24-136.99 305.12-305.21 305.12m167.42-228.51c-9.176-4.591-54.286-26.782-62.697-29.843-8.41-3.061-14.526-4.591-20.644 4.592-6.116 9.182-23.7 29.843-29.054 35.964-5.351 6.122-10.703 6.888-19.879 2.296-9.175-4.591-38.739-14.276-73.786-45.526-27.275-24.32-45.691-54.36-51.043-63.542-5.352-9.183-0.569-14.148 4.024-18.72 4.127-4.11 9.175-10.713 13.763-16.07 4.587-5.356 6.116-9.182 9.174-15.303 3.059-6.122 1.53-11.479-0.764-16.07s-20.643-49.739-28.29-68.104c-7.447-17.886-15.012-15.466-20.644-15.746-5.346-0.266-11.469-0.323-17.585-0.323-6.117 0-16.057 2.296-24.468 11.478-8.41 9.183-32.112 31.374-32.112 76.521s32.877 88.763 37.465 94.885c4.587 6.122 64.699 98.771 156.74 138.5 21.891 9.45 38.982 15.093 52.307 19.323 21.981 6.979 41.983 5.994 57.793 3.633 17.628-2.633 54.285-22.19 61.932-43.616 7.646-21.426 7.646-39.791 5.352-43.617-2.293-3.826-8.41-6.122-17.585-10.714" clip-rule="evenodd" fill-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,64 @@
import { modifier } from 'ember-modifier';
const CACHE_NAME = 'nostr-image-cache-v1';
export default modifier((element, [url]) => {
let objectUrl = null;
async function loadImage() {
if (!url) {
element.src = '';
return;
}
try {
const cache = await caches.open(CACHE_NAME);
const cachedResponse = await cache.match(url);
if (cachedResponse) {
const blob = await cachedResponse.blob();
objectUrl = URL.createObjectURL(blob);
element.src = objectUrl;
return;
}
// Not in cache, try to fetch it
// eslint-disable-next-line warp-drive/no-external-request-patterns
const response = await fetch(url, {
mode: 'cors', // Required to read the blob for caching
credentials: 'omit',
});
if (response.ok) {
// Clone the response before reading the blob because a response stream can only be read once
const cacheResponse = response.clone();
await cache.put(url, cacheResponse);
const blob = await response.blob();
objectUrl = URL.createObjectURL(blob);
element.src = objectUrl;
} else {
// Fetch failed (e.g. 404), fallback to standard browser loading
element.src = url;
}
} catch (error) {
// CORS errors or network failures will land here.
// Fallback to letting the browser handle it directly.
console.warn(
`Failed to cache image ${url}, falling back to standard src`,
error
);
element.src = url;
}
}
loadImage();
// Cleanup: revoke the object URL when the element is destroyed or the URL changes
return () => {
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
objectUrl = null;
}
};
});

View File

@@ -0,0 +1,75 @@
import { modifier } from 'ember-modifier';
export default modifier((element, [url]) => {
if (!url) return;
// Remove classes when URL changes
element.classList.remove('loaded');
element.classList.remove('loaded-instant');
let observer;
const handleLoad = () => {
// Only apply the fade-in animation if it wasn't already loaded instantly
if (!element.classList.contains('loaded-instant')) {
element.classList.add('loaded');
}
};
element.addEventListener('load', handleLoad);
const loadWhenVisible = (entries, obs) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// Stop observing once we start loading
obs.unobserve(element);
// Check if the image is already in the browser cache
// Create an off-DOM image to reliably check cache status
// without waiting for the actual DOM element to load it
const img = new Image();
img.src = url;
if (img.complete) {
// Already in browser cache, skip the animation
element.classList.add('loaded-instant');
}
// If this image is inside a <picture> tag, we also need to swap <source> tags
const parent = element.parentElement;
if (parent && parent.tagName === 'PICTURE') {
const sources = parent.querySelectorAll('source');
sources.forEach((source) => {
if (source.dataset.srcset) {
source.srcset = source.dataset.srcset;
}
});
}
// Swap data-src to src to trigger the actual network fetch (or render from cache)
if (element.dataset.src) {
element.src = element.dataset.src;
} else {
// Fallback if data-src wasn't used but the modifier was called
element.src = url;
}
}
});
};
// Setup Intersection Observer to only load when the image enters the viewport
observer = new IntersectionObserver(loadWhenVisible, {
root: null, // Use the viewport as the root
rootMargin: '100px 100%', // Load one full viewport width ahead/behind
threshold: 0, // Trigger immediately when any part enters the expanded margin
});
observer.observe(element);
return () => {
element.removeEventListener('load', handleLoad);
if (observer) {
observer.disconnect();
}
};
});

17
app/modifiers/qr-code.js Normal file
View File

@@ -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);
});
}
});

View File

@@ -9,25 +9,47 @@ export default class PlaceRoute extends Route {
async model(params) {
const id = params.place_id;
let type, osmId;
let isExplicitOsm = false;
if (
id.startsWith('osm:node:') ||
id.startsWith('osm:way:') ||
id.startsWith('osm:relation:')
) {
const [, type, osmId] = id.split(':');
isExplicitOsm = true;
[, type, osmId] = id.split(':');
console.debug(`Fetching explicit OSM ${type}:`, osmId);
return this.loadOsmPlace(osmId, type);
}
let backgroundFetchPromise = null;
if (isExplicitOsm) {
backgroundFetchPromise = this.loadOsmPlace(osmId, type);
}
await this.waitForSync();
let bookmark = this.storage.findPlaceById(id);
let lookupId = isExplicitOsm ? osmId : id;
let bookmark = this.storage.findPlaceById(lookupId);
// Ensure type matches if we are looking up by osmId
if (bookmark && isExplicitOsm && bookmark.osmType !== type) {
bookmark = null; // Type mismatch, not the same OSM object
}
if (bookmark) {
console.debug('Found in bookmarks:', bookmark.title);
return bookmark;
}
if (isExplicitOsm) {
console.debug(
`Not in bookmarks, using explicitly fetched OSM ${type}:`,
osmId
);
return await backgroundFetchPromise;
}
console.warn('Not in bookmarks:', id);
return null;
}
@@ -119,14 +141,14 @@ export default class PlaceRoute extends Route {
}
serialize(model) {
// If the model is a saved bookmark, use its ID
if (model.id) {
return { place_id: model.id };
}
// If it's an OSM POI, use the explicit format
// If it's an OSM POI, use the explicit format first
if (model.osmId && model.osmType) {
return { place_id: `osm:${model.osmType}:${model.osmId}` };
}
// If the model is a saved bookmark (and not OSM, e.g. custom place), use its ID
if (model.id) {
return { place_id: model.id };
}
// Fallback
return { place_id: model.osmId };
}

205
app/services/blossom.js Normal file
View File

@@ -0,0 +1,205 @@
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';
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;
@service settings;
get servers() {
const servers = this.nostrData.blossomServers;
const allServers = servers.length ? servers : [DEFAULT_BLOSSOM_SERVER];
if (!this.settings.nostrPhotoFallbackUploads) {
return [allServers[0]];
}
return allServers;
}
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, options = { sequential: false }) {
if (!this.nostrAuth.isConnected) throw new Error('Not connected');
const buffer = await file.arrayBuffer();
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;
const mainServer = servers[0];
const fallbackServers = servers.slice(1);
const fallbackUrls = [];
let mainResult;
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
);
}
}
}
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
);
}
}
}
}

View File

@@ -0,0 +1,129 @@
import Service from '@ember/service';
// 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 {
_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();
}
_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.');
}
try {
// 1. Get dimensions safely on the main thread
const { width: origWidth, height: origHeight } =
await this._getImageDimensions(file);
// 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() {
super.willDestroy(...arguments);
if (this._worker) {
this._worker.terminate();
this._worker = null;
}
this._callbacks.clear();
}
}

View File

@@ -1,7 +1,9 @@
import Service from '@ember/service';
import Service, { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
export default class MapUiService extends Service {
@service nostrData;
@tracked selectedPlace = null;
@tracked isSearching = false;
@tracked isCreating = false;
@@ -19,12 +21,14 @@ export default class MapUiService extends Service {
selectPlace(place, options = {}) {
this.selectedPlace = place;
this.selectionOptions = options;
this.nostrData.loadPhotosForPlace(place);
}
clearSelection() {
this.selectedPlace = null;
this.selectionOptions = {};
this.preventNextZoom = false;
this.nostrData.loadPhotosForPlace(null);
}
setSearchResults(results) {

295
app/services/nostr-auth.js Normal file
View File

@@ -0,0 +1,295 @@
import Service, { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import {
ExtensionSigner,
NostrConnectSigner,
PrivateKeySigner,
} from 'applesauce-signers';
const STORAGE_KEY = 'marco:nostr_pubkey';
const STORAGE_KEY_TYPE = 'marco:nostr_signer_type'; // 'extension' | 'connect'
const STORAGE_KEY_CONNECT_LOCAL_KEY = 'marco:nostr_connect_local_key';
const STORAGE_KEY_CONNECT_REMOTE_PUBKEY = 'marco:nostr_connect_remote_pubkey';
const STORAGE_KEY_CONNECT_RELAY = 'marco:nostr_connect_relay';
const DEFAULT_CONNECT_RELAY = 'wss://relay.nsec.app';
import { isMobile } from '../utils/device';
export default class NostrAuthService extends Service {
@service nostrRelay;
@service nostrData;
@tracked pubkey = null;
@tracked signerType = null; // 'extension' or 'connect'
// Track NostrConnect state for the UI
@tracked connectStatus = null; // null | 'waiting' | 'connected'
@tracked connectUri = null; // For displaying a QR code if needed
_signerInstance = null;
constructor() {
super(...arguments);
// 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) {
this.pubkey = saved;
this.signerType = type || 'extension';
this._verifyPubkey();
}
}
async _verifyPubkey() {
if (this.signerType === 'extension') {
if (typeof window.nostr === 'undefined') {
this.disconnect();
return;
}
try {
const signer = new ExtensionSigner();
const extensionPubkey = await signer.getPublicKey();
if (extensionPubkey !== this.pubkey) {
this.pubkey = extensionPubkey;
localStorage.setItem(STORAGE_KEY, this.pubkey);
}
this.nostrData.loadProfile(this.pubkey);
} catch (e) {
console.warn('Failed to verify extension nostr pubkey, logging out', e);
this.disconnect();
}
} else if (this.signerType === 'connect') {
try {
await this._initConnectSigner();
} catch (e) {
console.warn('Failed to verify connect nostr pubkey, logging out', e);
this.disconnect();
}
}
}
get isMobile() {
return isMobile();
}
get isConnected() {
return (
!!this.pubkey &&
(this.signerType === 'extension'
? typeof window.nostr !== 'undefined'
: true)
);
}
get signer() {
if (this._signerInstance) return this._signerInstance;
if (
this.signerType === 'extension' &&
typeof window.nostr !== 'undefined'
) {
return new ExtensionSigner();
}
if (this.signerType === 'connect') {
// Must be initialized async due to the connect handshakes
return null;
}
return null;
}
async connectWithExtension() {
if (typeof window.nostr === 'undefined') {
throw new Error('No NIP-07 Nostr extension found (e.g., Alby, nos2x).');
}
try {
this._signerInstance = new ExtensionSigner();
this.pubkey = await this._signerInstance.getPublicKey();
this.signerType = 'extension';
localStorage.setItem(STORAGE_KEY, this.pubkey);
localStorage.setItem(STORAGE_KEY_TYPE, 'extension');
this.nostrData.loadProfile(this.pubkey);
return this.pubkey;
} catch (error) {
console.error('Failed to get public key from extension:', error);
throw error;
}
}
_getLocalSigner() {
let localKeyHex = localStorage.getItem(STORAGE_KEY_CONNECT_LOCAL_KEY);
let localSigner;
if (localKeyHex) {
localSigner = PrivateKeySigner.fromKey(localKeyHex);
} else {
localSigner = new PrivateKeySigner();
// Store the raw Uint8Array as hex string
localKeyHex = Array.from(localSigner.key)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
localStorage.setItem(STORAGE_KEY_CONNECT_LOCAL_KEY, localKeyHex);
}
return localSigner;
}
async connectWithApp() {
this.connectStatus = 'waiting';
try {
const localSigner = this._getLocalSigner();
// We use a specific relay for the connection handshake.
const relay = DEFAULT_CONNECT_RELAY;
localStorage.setItem(STORAGE_KEY_CONNECT_RELAY, relay);
// Override aggressive 10s EOSE timeout to allow time for QR scanning
this.nostrRelay.pool.relay(relay).eoseTimeout = 180000; // 3 minutes
this._signerInstance = new NostrConnectSigner({
pool: this.nostrRelay.pool,
relays: [relay],
signer: localSigner,
onAuth: async (url) => {
// NIP-46 auth callback. Normally the signer app does this natively via notification.
// But if it requires an explicit browser window:
if (
confirm(
`Your signer app requests authentication via a web page. Open it now?\n\nURL: ${url}`
)
) {
window.open(url, '_blank');
}
},
});
// Set the uri for display (e.g., to redirect via intent)
this.connectUri = this._signerInstance.getNostrConnectURI({
name: 'Marco',
url: window.location.origin,
description: 'An unhosted maps application.',
icons: [],
});
// Trigger the deep link intent immediately for the user if on mobile
if (this.isMobile) {
console.debug('Mobile detected, triggering deep link intent.');
window.location.href = this.connectUri;
}
// Start listening to the relay
console.debug('Opening signer connection to relay...');
await this._signerInstance.open();
console.debug('Signer connection opened successfully.');
// Wait for the remote signer to reply with their pubkey
console.debug('Waiting for remote signer to ack via relay...');
try {
await this._signerInstance.waitForSigner();
console.debug('Remote signer ack received!');
} catch (waitErr) {
console.error('Error while waiting for remote signer ack:', waitErr);
throw waitErr;
}
// Once connected, get the actual user pubkey
this.pubkey = await this._signerInstance.getPublicKey();
this.signerType = 'connect';
this.connectStatus = 'connected';
// Save connection state
localStorage.setItem(STORAGE_KEY, this.pubkey);
localStorage.setItem(STORAGE_KEY_TYPE, 'connect');
localStorage.setItem(
STORAGE_KEY_CONNECT_REMOTE_PUBKEY,
this._signerInstance.remote
);
this.nostrData.loadProfile(this.pubkey);
return this.pubkey;
} catch (error) {
this.connectStatus = null;
console.error('Failed to connect via Nostr Connect:', error);
throw error;
}
}
async _initConnectSigner() {
const remotePubkey = localStorage.getItem(
STORAGE_KEY_CONNECT_REMOTE_PUBKEY
);
const relay =
localStorage.getItem(STORAGE_KEY_CONNECT_RELAY) || DEFAULT_CONNECT_RELAY;
if (!remotePubkey) {
throw new Error('Missing Nostr Connect remote pubkey.');
}
const localSigner = this._getLocalSigner();
// Override aggressive 10s EOSE timeout to allow time for QR scanning
this.nostrRelay.pool.relay(relay).eoseTimeout = 180000; // 3 minutes
this._signerInstance = new NostrConnectSigner({
pool: this.nostrRelay.pool,
relays: [relay],
signer: localSigner,
remote: remotePubkey,
onAuth: async (url) => {
if (
confirm(
`Your signer app requests authentication via a web page. Open it now?\n\nURL: ${url}`
)
) {
window.open(url, '_blank');
}
},
});
await this._signerInstance.open();
// Validate we can still get the pubkey from the remote signer
const pubkey = await this._signerInstance.getPublicKey();
if (pubkey !== this.pubkey) {
throw new Error('Remote signer pubkey mismatch');
}
this.nostrData.loadProfile(this.pubkey);
}
async signEvent(event) {
if (!this.signer) {
throw new Error(
'Not connected or extension missing. Please connect Nostr again.'
);
}
return await this.signer.signEvent(event);
}
async disconnect() {
this.pubkey = null;
this.nostrData?.loadProfile(null);
this.signerType = null;
this.connectStatus = null;
this.connectUri = null;
if (
this._signerInstance &&
typeof this._signerInstance.close === 'function'
) {
await this._signerInstance.close();
}
this._signerInstance = null;
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(STORAGE_KEY_TYPE);
localStorage.removeItem(STORAGE_KEY_CONNECT_LOCAL_KEY);
localStorage.removeItem(STORAGE_KEY_CONNECT_REMOTE_PUBKEY);
localStorage.removeItem(STORAGE_KEY_CONNECT_RELAY);
}
}

394
app/services/nostr-data.js Normal file
View File

@@ -0,0 +1,394 @@
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';
import { MailboxesModel } from 'applesauce-core/models/mailboxes';
import { npubEncode } from 'applesauce-core/helpers/pointers';
import { persistEventsToCache } from 'applesauce-core/helpers/event-cache';
import { NostrIDB, openDB } from 'nostr-idb';
import { normalizeRelayUrl } from '../utils/nostr';
import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage';
const DIRECTORY_RELAYS = [
'wss://purplepag.es',
'wss://relay.damus.io',
'wss://nos.lol',
];
const DEFAULT_READ_RELAYS = ['wss://nostr.kosmos.org'];
const DEFAULT_WRITE_RELAYS = [];
export default class NostrDataService extends Service {
@service nostrRelay;
@service nostrAuth;
@service settings;
store = new EventStore();
@tracked profile = null;
@tracked mailboxes = null;
@tracked blossomServers = [];
@tracked placePhotos = [];
_profileSub = null;
_mailboxesSub = null;
_blossomSub = null;
_photosSub = null;
_requestSub = null;
_cachePromise = null;
loadedGeohashPrefixes = new Set();
constructor() {
super(...arguments);
// Initialize the IndexedDB cache
this._cachePromise = openDB('applesauce-events').then(async (db) => {
this.cache = new NostrIDB(db, {
cacheIndexes: 1000,
maxEvents: 10000,
});
await this.cache.start();
// Automatically persist new events to the cache
this._stopPersisting = persistEventsToCache(
this.store,
async (events) => {
// Only cache profiles, mailboxes, blossom servers, and place photos
const toCache = events.filter(
(e) =>
e.kind === 0 ||
e.kind === 10002 ||
e.kind === 10063 ||
e.kind === 360
);
if (toCache.length > 0) {
await Promise.all(toCache.map((event) => this.cache.add(event)));
}
},
{
batchTime: 1000, // Batch writes every 1 second
maxBatchSize: 100,
}
);
});
// Feed events from the relay pool into the event store
this.nostrRelay.pool.relays$.subscribe(() => {
// Setup relay subscription tracking if needed, or we just rely on request()
// which returns an Observable<NostrEvent>
});
}
get defaultReadRelays() {
const mailboxes = (this.mailboxes?.inboxes || [])
.map(normalizeRelayUrl)
.filter(Boolean);
const defaults = DEFAULT_READ_RELAYS.map(normalizeRelayUrl).filter(Boolean);
return Array.from(new Set([...defaults, ...mailboxes]));
}
get defaultWriteRelays() {
const mailboxes = (this.mailboxes?.outboxes || [])
.map(normalizeRelayUrl)
.filter(Boolean);
const defaults =
DEFAULT_WRITE_RELAYS.map(normalizeRelayUrl).filter(Boolean);
return Array.from(new Set([...defaults, ...mailboxes]));
}
get activeReadRelays() {
if (this.settings.nostrReadRelays) {
return Array.from(
new Set(
this.settings.nostrReadRelays.map(normalizeRelayUrl).filter(Boolean)
)
);
}
return this.defaultReadRelays;
}
get activeWriteRelays() {
if (this.settings.nostrWriteRelays) {
return Array.from(
new Set(
this.settings.nostrWriteRelays.map(normalizeRelayUrl).filter(Boolean)
)
);
}
return this.defaultWriteRelays;
}
async loadPlacesInBounds(bbox) {
const requiredPrefixes = getGeohashPrefixesInBbox(bbox);
const missingPrefixes = requiredPrefixes.filter(
(p) => !this.loadedGeohashPrefixes.has(p)
);
if (missingPrefixes.length === 0) {
return;
}
console.debug(
'[nostr-data] Loading place photos for prefixes:',
missingPrefixes
);
try {
await this._cachePromise;
const cachedEvents = await this.cache.query([
{
kinds: [360],
'#g': missingPrefixes,
},
]);
if (cachedEvents && cachedEvents.length > 0) {
for (const event of cachedEvents) {
this.store.add(event);
}
}
} catch (e) {
console.warn(
'[nostr-data] Failed to read photos from local Nostr IDB cache',
e
);
}
// Fire network request for new prefixes
this.nostrRelay.pool
.request(this.activeReadRelays, [
{
kinds: [360],
'#g': missingPrefixes,
},
])
.subscribe({
next: (event) => {
this.store.add(event);
},
error: (err) => {
console.error(
'[nostr-data] Error fetching place photos by geohash:',
err
);
},
});
for (const p of missingPrefixes) {
this.loadedGeohashPrefixes.add(p);
}
}
async loadPhotosForPlace(place) {
if (this._photosSub) {
this._photosSub.unsubscribe();
this._photosSub = null;
}
this.placePhotos = [];
if (!place || !place.osmId || !place.osmType) {
return;
}
const entityId = `osm:${place.osmType}:${place.osmId}`;
// Setup reactive store query
this._photosSub = this.store
.timeline([
{
kinds: [360],
'#i': [entityId],
},
])
.subscribe((events) => {
this.placePhotos = events;
});
try {
await this._cachePromise;
const cachedEvents = await this.cache.query([
{
kinds: [360],
'#i': [entityId],
},
]);
if (cachedEvents && cachedEvents.length > 0) {
for (const event of cachedEvents) {
this.store.add(event);
}
}
} catch (e) {
console.warn(
'[nostr-data] Failed to read photos for place from local Nostr IDB cache',
e
);
}
// Fire network request specifically for this place
this.nostrRelay.pool
.request(this.activeReadRelays, [
{
kinds: [360],
'#i': [entityId],
},
])
.subscribe({
next: (event) => {
this.store.add(event);
},
error: (err) => {
console.error(
'[nostr-data] Error fetching place photos for place:',
err
);
},
});
}
async loadProfile(pubkey) {
if (!pubkey) return;
// Reset state
this.profile = null;
this.mailboxes = null;
this.blossomServers = [];
this._cleanupSubscriptions();
// Setup models to track state reactively FIRST
// This way, if cached events populate the store, the UI updates instantly.
this._profileSub = this.store
.model(ProfileModel, pubkey)
.subscribe((profileContent) => {
this.profile = profileContent;
});
this._mailboxesSub = this.store
.model(MailboxesModel, pubkey)
.subscribe((mailboxesData) => {
this.mailboxes = mailboxesData;
});
this._blossomSub = this.store
.replaceable(10063, pubkey)
.subscribe((event) => {
if (event && event.tags) {
this.blossomServers = event.tags
.filter((t) => t[0] === 'server' && t[1])
.map((t) => t[1]);
} else {
this.blossomServers = [];
}
});
// 1. Await cache initialization and populate the EventStore with local data
try {
await this._cachePromise;
const cachedEvents = await this.cache.query([
{
authors: [pubkey],
kinds: [0, 10002, 10063],
},
]);
if (cachedEvents && cachedEvents.length > 0) {
for (const event of cachedEvents) {
this.store.add(event);
}
}
} catch (e) {
console.warn('Failed to read from local Nostr IDB cache', e);
}
// 2. Request new events from the network in the background and dump them into the store
const profileRelays = Array.from(
new Set([...DIRECTORY_RELAYS, ...this.activeWriteRelays])
);
this._requestSub = this.nostrRelay.pool
.request(profileRelays, [
{
authors: [pubkey],
kinds: [0, 10002, 10063],
},
])
.subscribe({
next: (event) => {
this.store.add(event);
},
error: (err) => {
console.error('Error fetching profile events:', err);
},
});
}
get userDisplayName() {
if (this.profile) {
if (this.profile.nip05) {
return this.profile.nip05;
}
if (this.profile.displayName || this.profile.display_name) {
return this.profile.displayName || this.profile.display_name;
}
if (this.profile.name) {
return this.profile.name;
}
}
// Fallback to npub
if (this.nostrAuth.pubkey) {
try {
const npub = npubEncode(this.nostrAuth.pubkey);
return `${npub.slice(0, 9)}...${npub.slice(-4)}`;
} catch {
return this.nostrAuth.pubkey;
}
}
return 'Not connected';
}
_cleanupSubscriptions() {
if (this._requestSub) {
this._requestSub.unsubscribe();
this._requestSub = null;
}
if (this._profileSub) {
this._profileSub.unsubscribe();
this._profileSub = null;
}
if (this._mailboxesSub) {
this._mailboxesSub.unsubscribe();
this._mailboxesSub = null;
}
if (this._blossomSub) {
this._blossomSub.unsubscribe();
this._blossomSub = null;
}
if (this._photosSub) {
this._photosSub.unsubscribe();
this._photosSub = null;
}
}
willDestroy() {
super.willDestroy(...arguments);
this._cleanupSubscriptions();
if (this._stopPersisting) {
this._stopPersisting();
}
if (this.cache) {
this.cache.stop();
}
}
}

View File

@@ -0,0 +1,25 @@
import Service from '@ember/service';
import { RelayPool } from 'applesauce-relay';
export default class NostrRelayService extends Service {
pool = new RelayPool();
async publish(relays, event) {
if (!relays || relays.length === 0) {
throw new Error('No relays provided to publish the event.');
}
// The publish method is a wrapper around the event method that returns a Promise<PublishResponse[]>
// and automatically handles reconnecting and retrying.
const responses = await this.pool.publish(relays, event);
// Check if at least one relay accepted the event
const success = responses.some((res) => res.ok);
if (!success) {
throw new Error(
`Failed to publish event. Responses: ${JSON.stringify(responses)}`
);
}
return responses;
}
}

View File

@@ -7,7 +7,15 @@ class MarcoOsmAuthStorage {
localStorage.setItem('marco:osm_auth_state', serializedState);
}
loadState() {
return localStorage.getItem('marco:osm_auth_state');
const state = localStorage.getItem('marco:osm_auth_state');
if (!state) return false;
try {
JSON.parse(state);
return state;
} catch (e) {
console.warn('Failed to parse OSM auth state', e);
return false;
}
}
}
@@ -45,6 +53,11 @@ export default class OsmAuthService extends Service {
}
async restoreSession() {
try {
await this.oauthClient.ready;
} catch (e) {
console.warn('oauthClient.ready failed', e);
}
const isAuthorized = await this.oauthClient.isAuthorized();
if (isAuthorized) {
this.isConnected = true;

View File

@@ -8,6 +8,7 @@ export default class OsmService extends Service {
controller = null;
cachedResults = null;
lastQueryKey = null;
cachedPlaces = new Map();
cancelAll() {
if (this.controller) {
@@ -232,6 +233,13 @@ out center;
async fetchOsmObject(osmId, osmType) {
if (!osmId || !osmType) return null;
const cacheKey = `${osmType}:${osmId}`;
const cached = this.cachedPlaces.get(cacheKey);
if (cached && Date.now() - cached.timestamp < 10000) {
console.debug(`Using in-memory cached OSM object for ${cacheKey}`);
return cached.data;
}
let url;
if (osmType === 'node') {
url = `https://www.openstreetmap.org/api/0.6/node/${osmId}.json`;
@@ -253,8 +261,25 @@ out center;
}
throw new Error(`OSM API request failed: ${res.status}`);
}
const data = await res.json();
return this.normalizeOsmApiData(data.elements, osmId, osmType);
const normalizedData = this.normalizeOsmApiData(
data.elements,
osmId,
osmType
);
this.cachedPlaces.set(cacheKey, {
data: normalizedData,
timestamp: Date.now(),
});
// Cleanup cache entry automatically after 10 seconds
setTimeout(() => {
this.cachedPlaces.delete(cacheKey);
}, 10000);
return normalizedData;
} catch (e) {
console.error('Failed to fetch OSM object:', e);
return null;

View File

@@ -1,11 +1,25 @@
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
const DEFAULT_SETTINGS = {
overpassApi: 'https://overpass-api.de/api/interpreter',
mapKinetic: true,
photonApi: 'https://photon.komoot.io/api/',
showQuickSearchButtons: true,
nostrPhotoFallbackUploads: false,
nostrReadRelays: null,
nostrWriteRelays: null,
};
export default class SettingsService extends Service {
@tracked overpassApi = 'https://overpass-api.de/api/interpreter';
@tracked mapKinetic = true;
@tracked photonApi = 'https://photon.komoot.io/api/';
@tracked showQuickSearchButtons = true;
@tracked overpassApi = DEFAULT_SETTINGS.overpassApi;
@tracked mapKinetic = DEFAULT_SETTINGS.mapKinetic;
@tracked photonApi = DEFAULT_SETTINGS.photonApi;
@tracked showQuickSearchButtons = DEFAULT_SETTINGS.showQuickSearchButtons;
@tracked nostrPhotoFallbackUploads =
DEFAULT_SETTINGS.nostrPhotoFallbackUploads;
@tracked nostrReadRelays = DEFAULT_SETTINGS.nostrReadRelays;
@tracked nostrWriteRelays = DEFAULT_SETTINGS.nostrWriteRelays;
overpassApis = [
{
@@ -39,49 +53,83 @@ export default class SettingsService extends Service {
}
loadSettings() {
const savedApi = localStorage.getItem('marco:overpass-api');
if (savedApi) {
// Check if saved API is still in the allowed list
const isValid = this.overpassApis.some((api) => api.url === savedApi);
if (isValid) {
this.overpassApi = savedApi;
let settings = {};
const savedSettings = localStorage.getItem('marco:settings');
if (savedSettings) {
try {
settings = JSON.parse(savedSettings);
} catch (e) {
console.error('Failed to parse settings from localStorage', e);
}
} else {
// If not valid, revert to default
this.overpassApi = 'https://overpass-api.de/api/interpreter';
localStorage.setItem('marco:overpass-api', this.overpassApi);
}
}
// Migration from old individual keys
const savedApi = localStorage.getItem('marco:overpass-api');
if (savedApi) settings.overpassApi = savedApi;
const savedKinetic = localStorage.getItem('marco:map-kinetic');
if (savedKinetic !== null) {
this.mapKinetic = savedKinetic === 'true';
}
// Default is true (initialized in class field)
if (savedKinetic !== null) settings.mapKinetic = savedKinetic === 'true';
const savedShowQuickSearch = localStorage.getItem(
'marco:show-quick-search'
);
if (savedShowQuickSearch !== null) {
this.showQuickSearchButtons = savedShowQuickSearch === 'true';
}
settings.showQuickSearchButtons = savedShowQuickSearch === 'true';
}
updateOverpassApi(url) {
this.overpassApi = url;
localStorage.setItem('marco:overpass-api', url);
const savedNostrPhotoFallbackUploads = localStorage.getItem(
'marco:nostr-photo-fallback-uploads'
);
if (savedNostrPhotoFallbackUploads !== null) {
settings.nostrPhotoFallbackUploads =
savedNostrPhotoFallbackUploads === 'true';
}
updateMapKinetic(enabled) {
this.mapKinetic = enabled;
localStorage.setItem('marco:map-kinetic', String(enabled));
const savedPhotonApi = localStorage.getItem('marco:photon-api');
if (savedPhotonApi) settings.photonApi = savedPhotonApi;
}
updateShowQuickSearchButtons(enabled) {
this.showQuickSearchButtons = enabled;
localStorage.setItem('marco:show-quick-search', String(enabled));
// Merge with defaults
const finalSettings = { ...DEFAULT_SETTINGS, ...settings };
// Validate overpass API
const isValid = this.overpassApis.some(
(api) => api.url === finalSettings.overpassApi
);
if (!isValid) {
finalSettings.overpassApi = DEFAULT_SETTINGS.overpassApi;
}
updatePhotonApi(url) {
this.photonApi = url;
// Apply to tracked properties
this.overpassApi = finalSettings.overpassApi;
this.mapKinetic = finalSettings.mapKinetic;
this.photonApi = finalSettings.photonApi;
this.showQuickSearchButtons = finalSettings.showQuickSearchButtons;
this.nostrPhotoFallbackUploads = finalSettings.nostrPhotoFallbackUploads;
this.nostrReadRelays = finalSettings.nostrReadRelays;
this.nostrWriteRelays = finalSettings.nostrWriteRelays;
// Save to ensure migrated settings are stored in the new format
this.saveSettings();
}
saveSettings() {
const settings = {
overpassApi: this.overpassApi,
mapKinetic: this.mapKinetic,
photonApi: this.photonApi,
showQuickSearchButtons: this.showQuickSearchButtons,
nostrPhotoFallbackUploads: this.nostrPhotoFallbackUploads,
nostrReadRelays: this.nostrReadRelays,
nostrWriteRelays: this.nostrWriteRelays,
};
localStorage.setItem('marco:settings', JSON.stringify(settings));
}
update(key, value) {
if (key in DEFAULT_SETTINGS) {
this[key] = value;
this.saveSettings();
}
}
}

View File

@@ -46,6 +46,14 @@ export default class StorageService extends Service {
// console.debug('[rs] client ready');
});
this.rs.on('error', (error) => {
if (!error) return;
console.info('[rs] Error —', `${error.name}: ${error.message}`);
if (error.name === 'Unauthorized') {
this.showConnectWidget();
}
});
this.rs.on('connected', () => {
this.connected = true;
this.userAddress = this.rs.remote.userAddress;
@@ -445,7 +453,7 @@ export default class StorageService extends Service {
}
@action
connect() {
showConnectWidget() {
this.isWidgetOpen = true;
// Check if widget is already attached

View File

@@ -8,6 +8,8 @@
--link-color-visited: #6a4fbf;
--marker-color-primary: #ea4335;
--marker-color-dark: #b31412;
--danger-color: var(--marker-color-primary);
--danger-color-dark: var(--marker-color-dark);
}
html,
@@ -180,6 +182,9 @@ body {
border: none;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
.user-avatar-placeholder {
@@ -190,7 +195,146 @@ body {
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 5px rgb(0 0 0 / 20%);
flex-shrink: 0;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
display: block;
}
.photo-preview-img {
max-width: 100%;
height: auto;
}
.dropzone {
border: 2px dashed #ccc;
border-radius: 8px;
text-align: center;
transition: all 0.2s ease;
margin: 1.5rem 0 1rem;
background-color: rgb(255 255 255 / 2%);
cursor: pointer;
aspect-ratio: 4 / 3;
}
.dropzone.is-dragging {
border-color: #61afef;
background-color: rgb(97 175 239 / 5%);
}
.dropzone-label {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
cursor: pointer;
color: #898989;
width: 100%;
height: 100%;
}
.dropzone-label p {
margin: 0;
}
.file-input-hidden {
display: none;
}
.photo-grid {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 20px;
}
.photo-upload-item {
position: relative;
aspect-ratio: 4 / 3;
border-radius: 6px;
overflow: hidden;
background: #1e262e;
width: 100%;
}
.photo-upload-item img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: contain;
display: block;
z-index: 1;
}
.photo-upload-item .overlay,
.photo-upload-item .btn-remove-photo {
z-index: 2;
}
.photo-upload-item .overlay {
position: absolute;
inset: 0;
background: rgb(0 0 0 / 60%);
display: flex;
align-items: center;
justify-content: center;
}
.photo-upload-item .error-overlay {
background: rgb(224 108 117 / 80%);
cursor: pointer;
border: none;
padding: 0;
margin: 0;
width: 100%;
}
.photo-upload-item .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;
}
.photo-upload-item .btn-remove-photo:hover {
background: var(--danger-color);
}
.spin-animation {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.btn-publish {
width: 100%;
}
/* User Menu Popover */
@@ -252,6 +396,9 @@ body {
color: #898989;
margin-top: 0.35rem;
margin-left: calc(18px + 0.75rem);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.account-status strong {
@@ -328,6 +475,10 @@ body {
align-items: center;
}
.sidebar-header.no-border {
border-bottom-color: transparent;
}
.sidebar-header h2 {
margin: 0;
font-size: 1.2rem;
@@ -433,6 +584,64 @@ body {
padding: 0 1.4rem 1rem;
animation: details-slide-down 0.2s ease-out;
font-size: 0.9rem;
display: flex;
flex-direction: column;
gap: 16px;
}
.relay-list {
list-style: none;
padding: 0;
margin: 0 0 0.75rem;
display: flex;
flex-direction: column;
gap: 4px;
}
.relay-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.25rem 0;
border-radius: 4px;
font-size: 0.9rem;
word-break: break-all;
}
.btn-remove-relay {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background-color: #fff;
border: 1px solid var(--danger-color);
color: var(--danger-color);
cursor: pointer;
padding: 0;
transition: all 0.1s ease;
flex-shrink: 0;
}
.btn-remove-relay svg {
stroke: currentcolor;
}
.btn-remove-relay:hover,
.btn-remove-relay:active {
background-color: var(--danger-color);
color: #fff;
}
.add-relay-input {
display: flex;
gap: 0.5rem;
}
.btn-link.reset-relays {
margin-top: 0.75rem;
font-size: 0.85rem;
}
@keyframes details-slide-down {
@@ -463,7 +672,7 @@ body {
display: block;
font-size: 0.85rem;
color: #666;
margin-bottom: 0.25rem;
margin-bottom: 0.5rem;
}
.form-control {
@@ -507,6 +716,11 @@ select.form-control {
}
.settings-section .form-group {
margin-top: 0.5rem;
margin-bottom: 0;
}
.settings-section .form-group:first-of-type {
margin-top: 1rem;
}
@@ -560,12 +774,19 @@ select.form-control {
border-top: 1px solid #eee;
}
.meta-info a {
.meta-info a,
.meta-info .btn-link {
color: var(--link-color);
text-decoration: none;
background: none;
border: none;
padding: 0;
font: inherit;
cursor: pointer;
}
.meta-info a:hover {
.meta-info a:hover,
.meta-info .btn-link:hover {
text-decoration: underline;
}
@@ -659,6 +880,153 @@ abbr[title] {
padding-bottom: 2rem;
}
.place-photos-carousel-wrapper {
position: relative;
margin: -1rem -1rem 1rem;
}
.place-photos-carousel-track {
display: flex;
overflow-x: auto;
scroll-behavior: smooth;
scroll-snap-type: x mandatory;
scrollbar-width: none; /* Firefox */
background-color: var(--hover-bg);
}
.place-photos-carousel-track::-webkit-scrollbar {
display: none; /* Safari and Chrome */
}
.carousel-slide {
position: relative;
flex: 0 0 100%;
scroll-snap-align: start;
aspect-ratio: 16 / 9;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.carousel-placeholder {
display: none;
}
.place-header-photo-blur {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.place-header-photo {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: block;
opacity: 0;
transition: opacity 0.3s ease-in-out;
z-index: 1; /* Stay above blurhash */
}
.place-header-photo.landscape {
object-fit: cover;
}
.place-header-photo.portrait {
object-fit: contain;
}
.place-header-photo.loaded {
opacity: 1;
}
.place-header-photo.loaded-instant {
opacity: 1;
transition: none;
}
.carousel-nav-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgb(0 0 0 / 50%);
color: white;
border: none;
border-radius: 50%;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 2;
opacity: 0;
transition:
opacity 0.2s,
background-color 0.2s;
padding: 0;
}
.place-photos-carousel-wrapper:hover .carousel-nav-btn:not(.disabled) {
opacity: 1;
}
.carousel-nav-btn:not(.disabled):hover {
background: rgb(0 0 0 / 80%);
}
.carousel-nav-btn.disabled {
opacity: 0;
pointer-events: none;
}
.carousel-nav-btn.prev {
left: 0.5rem;
}
.carousel-nav-btn.next {
right: 0.5rem;
}
@media (width <= 768px) {
.place-photos-carousel-track {
scroll-snap-type: none;
gap: 2px;
background-color: #fff;
}
.carousel-slide {
flex: 0 0 auto;
height: 100px;
width: auto;
aspect-ratio: var(--slide-ratio, 16 / 9);
scroll-snap-align: none;
}
.carousel-placeholder {
display: block;
background-color: var(--hover-bg);
flex: 1 1 0%;
min-width: 0;
}
.place-header-photo.landscape,
.place-header-photo.portrait {
object-fit: cover;
}
.carousel-nav-btn {
display: none;
}
}
.place-details h3 {
font-size: 1.2rem;
margin-top: 0;
@@ -835,6 +1203,7 @@ abbr[title] {
display: inline-flex;
width: 32px;
height: 32px;
margin: -6px 0;
}
.app-logo-icon svg {
@@ -1374,3 +1743,108 @@ button.create-place {
transform: translate(-50%, 0);
}
}
/* Nostr Connect */
.nostr-connect-modal h2 {
margin-top: 0;
}
.nostr-connect-options {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1.5rem;
}
.nostr-connect-status {
margin-top: 1.5rem;
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 */
.modal-overlay {
position: fixed;
inset: 0;
background: rgb(0 0 0 / 50%);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
padding: 1.5rem;
max-width: 90vw;
width: 450px;
position: relative;
}
.close-modal-btn {
position: absolute;
top: 1rem;
right: 1rem;
cursor: pointer;
}
.place-photo-upload h2 {
margin-top: 0;
font-size: 1.2rem;
}
.alert {
padding: 0.5rem;
margin-bottom: 1rem;
border-radius: 0.25rem;
}
.alert-error {
background: #fee;
color: #c00;
}
.alert-info {
background: #eef;
color: #00c;
}
.preview-group {
margin-bottom: 1rem;
}
.preview-group p {
margin-bottom: 0.25rem;
font-weight: bold;
}
.preview-group img {
max-width: 100%;
border-radius: 0.25rem;
}
.btn-link {
background: none;
border: none;
padding: 0;
color: var(--link-color);
text-decoration: none;
cursor: pointer;
font: inherit;
}
.btn-link:hover {
text-decoration: underline;
}

4
app/utils/device.js Normal file
View File

@@ -0,0 +1,4 @@
export function isMobile() {
if (typeof navigator === 'undefined') return false;
return /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent);
}

View File

@@ -2,7 +2,10 @@
import activity from 'feather-icons/dist/icons/activity.svg?raw';
import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
import camera from 'feather-icons/dist/icons/camera.svg?raw';
import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw';
import chevronLeft from 'feather-icons/dist/icons/chevron-left.svg?raw';
import chevronRight from 'feather-icons/dist/icons/chevron-right.svg?raw';
import clock from 'feather-icons/dist/icons/clock.svg?raw';
import edit from 'feather-icons/dist/icons/edit.svg?raw';
import facebook from 'feather-icons/dist/icons/facebook.svg?raw';
@@ -25,8 +28,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';
@@ -39,7 +46,6 @@ import beerMugWithFoam from '@waysidemapping/pinhead/dist/icons/beer_mug_with_fo
import burgerAndDrinkCupWithStraw from '@waysidemapping/pinhead/dist/icons/burger_and_drink_cup_with_straw.svg?raw';
import bus from '@waysidemapping/pinhead/dist/icons/bus.svg?raw';
import boxingGloveUp from '@waysidemapping/pinhead/dist/icons/boxing_glove_up.svg?raw';
import camera from '@waysidemapping/pinhead/dist/icons/camera.svg?raw';
import car from '@waysidemapping/pinhead/dist/icons/car.svg?raw';
import cigaretteWithSmokeCurl from '@waysidemapping/pinhead/dist/icons/cigarette_with_smoke_curl.svg?raw';
import classicalBuilding from '@waysidemapping/pinhead/dist/icons/classical_building.svg?raw';
@@ -110,6 +116,7 @@ import womensAndMensRestroomSymbol from '@waysidemapping/pinhead/dist/icons/wome
import loadingRing from '../icons/270-ring.svg?raw';
import nostrich from '../icons/nostrich-2.svg?raw';
import remotestorage from '../icons/remotestorage.svg?raw';
import whatsapp from '../icons/whatsapp.svg?raw';
import wikipedia from '../icons/wikipedia.svg?raw';
const ICONS = {
@@ -127,8 +134,12 @@ const ICONS = {
bus,
camera,
'check-square': checkSquare,
'chevron-left': chevronLeft,
'chevron-right': chevronRight,
'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,
@@ -213,11 +224,14 @@ const ICONS = {
'tattoo-machine': tattooMachine,
toolbox,
target,
'trash-2': trash2,
'upload-cloud': uploadCloud,
'tree-and-bench-with-backrest': treeAndBenchWithBackrest,
user,
'village-buildings': villageBuildings,
'wall-hanging-with-mountains-and-sun': wallHangingWithMountainsAndSun,
'womens-and-mens-restroom-symbol': womensAndMensRestroomSymbol,
whatsapp,
wikipedia,
parking_p: parkingP,
car,
@@ -229,10 +243,10 @@ const ICONS = {
const FILLED_ICONS = [
'fork-and-knife',
'wikipedia',
'whatsapp',
'cup-and-saucer',
'coffee-bean',
'shopping-basket',
'camera',
'person-sleeping-in-bed',
'loading-ring',
'nostrich',

88
app/utils/nostr.js Normal file
View File

@@ -0,0 +1,88 @@
export function normalizeRelayUrl(url) {
if (!url) return '';
let normalized = url.trim().toLowerCase();
if (!normalized) return '';
if (!normalized.startsWith('ws://') && !normalized.startsWith('wss://')) {
normalized = 'wss://' + normalized;
}
while (normalized.endsWith('/')) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
/**
* Extracts and normalizes photo data from NIP-360 (Place Photos) events.
* Sorts chronologically and guarantees the first landscape photo (or first portrait) is at index 0.
*
* @param {Array} events NIP-360 events
* @returns {Array} Array of photo objects
*/
export function parsePlacePhotos(events) {
if (!events || events.length === 0) return [];
// Sort by created_at ascending (oldest first)
const sortedEvents = [...events].sort((a, b) => a.created_at - b.created_at);
const allPhotos = [];
for (const event of sortedEvents) {
// Find all imeta tags
const imetas = event.tags.filter((t) => t[0] === 'imeta');
for (const imeta of imetas) {
let url = null;
let thumbUrl = null;
let blurhash = null;
let isLandscape = false;
let aspectRatio = 16 / 9; // default
for (const tag of imeta.slice(1)) {
if (tag.startsWith('url ')) {
url = tag.substring(4);
} else if (tag.startsWith('thumb ')) {
thumbUrl = tag.substring(6);
} else if (tag.startsWith('blurhash ')) {
blurhash = tag.substring(9);
} else if (tag.startsWith('dim ')) {
const dimStr = tag.substring(4);
const [width, height] = dimStr.split('x').map(Number);
if (width && height) {
aspectRatio = width / height;
if (width > height) {
isLandscape = true;
}
}
}
}
if (url) {
allPhotos.push({
eventId: event.id,
pubkey: event.pubkey,
createdAt: event.created_at,
url,
thumbUrl,
blurhash,
isLandscape,
aspectRatio,
});
}
}
}
if (allPhotos.length === 0) return [];
// Find the first landscape photo
const firstLandscapeIndex = allPhotos.findIndex((p) => p.isLandscape);
if (firstLandscapeIndex > 0) {
// Move the first landscape photo to the front
const [firstLandscape] = allPhotos.splice(firstLandscapeIndex, 1);
allPhotos.unshift(firstLandscape);
}
return allPhotos;
}

View File

@@ -55,7 +55,7 @@ export const POI_CATEGORIES = [
id: 'accommodation',
label: 'Hotels',
icon: 'person-sleeping-in-bed',
filter: ['["tourism"~"^(hotel|hostel|motel)$"]'],
filter: ['["tourism"~"^(hotel|hostel|motel|chalet)$"]'],
types: ['node', 'way', 'relation'],
},
];

View File

@@ -0,0 +1,130 @@
import { encode } from 'blurhash';
self.onmessage = async (e) => {
// 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 {
let finalCanvas;
let finalCtx;
// --- 1. Attempt Hardware Resizing (Happy Path) ---
try {
const resizedBitmap = await createImageBitmap(file, {
resizeWidth: targetWidth,
resizeHeight: targetHeight,
resizeQuality: 'high',
});
finalCanvas = new OffscreenCanvas(targetWidth, targetHeight);
finalCtx = finalCanvas.getContext('2d');
if (!finalCtx) {
throw new Error('Failed to get 2d context from OffscreenCanvas');
}
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. Generate Blurhash (if requested) ---
let blurhash = null;
if (computeBlurhash) {
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
);
}
}
// --- 4. Compress to JPEG Blob ---
const finalBlob = await finalCanvas.convertToBlob({
type: 'image/jpeg',
quality: quality,
});
const dim = `${targetWidth}x${targetHeight}`;
// --- 5. Send results back to main thread ---
self.postMessage({
id,
success: true,
blob: finalBlob,
dim,
blurhash,
});
} catch (error) {
self.postMessage({
id,
success: false,
error: error.message,
});
}
};

View File

@@ -0,0 +1,111 @@
# 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, `<type>` 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
An event MUST contain exactly one `imeta` tag representing a single 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.
### Single Photo per Event
Restricting events to a single `imeta` attachment (one photo per event) is an intentional design choice. Batching photos into a single event forces all engagement (likes, zaps) to apply to the entire batch, rendering granular tagging and sorting impossible. Single-photo events enable per-photo engagement, fine-grained categorization (e.g., tagging one photo as "food" and another as "menu"), and richer sorting algorithms based on individual photo popularity.

View File

@@ -0,0 +1,313 @@
# 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.
The design prioritizes:
- Small event size
- Interoperability across clients
- Flexibility for different place types
- Efficient geospatial querying using geohashes
## Event Kind
`kind: 30360`
## 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:<type>:<id>"]
```
Requirements:
- For OSM POIs, `<type>` 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

7
doc/nostr/notes.md Normal file
View File

@@ -0,0 +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

250
doc/nostr/ranking.md Normal file
View File

@@ -0,0 +1,250 @@
# Ranking Algorithm
Your inputs:
- many users
- partial ratings
- different priorities
Your output:
> “Best place _for this user right now_”
---
## Step 1: Normalize scores
Convert 110 → 01:
```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
Thats your killer feature.
---
# 4. Critical design choices (dont skip these)
## A. No global score in protocol
Let clients compute it.
---
## B. Embrace incomplete data
Most reviews will have:
- 13 aspects only
Thats 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 youve built (zooming out)
This is not a review system.
Its:
> A decentralized, multi-dimensional reputation graph for real-world places
Thats 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.

100
doc/nostr/ratings.md Normal file
View File

@@ -0,0 +1,100 @@
# 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”

View File

@@ -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
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "marco",
"version": "1.18.3",
"version": "1.20.0",
"private": true,
"description": "Unhosted maps app",
"repository": {
@@ -87,7 +87,7 @@
"prettier-plugin-ember-template-tag": "^2.1.2",
"qunit": "^2.25.0",
"qunit-dom": "^3.5.0",
"remotestorage-widget": "^1.8.0",
"remotestorage-widget": "^1.8.1",
"remotestoragejs": "2.0.0-beta.8",
"sinon": "^21.0.1",
"stylelint": "^16.26.1",
@@ -102,9 +102,18 @@
"edition": "octane"
},
"dependencies": {
"@noble/hashes": "^2.2.0",
"@waysidemapping/pinhead": "^15.20.0",
"applesauce-core": "^5.2.0",
"applesauce-factory": "^4.0.0",
"applesauce-relay": "^5.2.0",
"applesauce-signers": "^5.2.0",
"blurhash": "^2.0.5",
"ember-concurrency": "^5.2.0",
"ember-lifeline": "^7.0.0",
"oauth2-pkce": "^2.1.3"
"nostr-idb": "^5.0.0",
"oauth2-pkce": "^2.1.3",
"qrcode": "^1.5.4",
"rxjs": "^7.8.2"
}
}

964
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
!function(){"use strict";var t=["0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","#","$","%","*","+",",","-",".",":",";","=","?","@","[","]","^","_","{","|","}","~"],e=(e,a)=>{var o="";for(let r=1;r<=a;r++){let h=Math.floor(e)/Math.pow(83,a-r)%83;o+=t[Math.floor(h)]}return o},a=t=>{let e=t/255;return e<=.04045?e/12.92:Math.pow((e+.055)/1.055,2.4)},o=t=>{let e=Math.max(0,Math.min(1,t));return e<=.0031308?Math.trunc(12.92*e*255+.5):Math.trunc(255*(1.055*Math.pow(e,.4166666666666667)-.055)+.5)},r=(t,e)=>(t=>t<0?-1:1)(t)*Math.pow(Math.abs(t),e),h=class extends Error{constructor(t){super(t),this.name="ValidationError",this.message=t}},i=(t,e,o,r)=>{let h=0,i=0,n=0,s=4*e;for(let g=0;g<e;g++){let e=4*g;for(let l=0;l<o;l++){let o=e+l*s,c=r(g,l);h+=c*a(t[o]),i+=c*a(t[o+1]),n+=c*a(t[o+2])}}let l=1/(e*o);return[h*l,i*l,n*l]};self.onmessage=async t=>{if("PROCESS_IMAGE"!==t.data?.type)return;const{id:a,file:n,targetWidth:s,targetHeight:l,quality:g,computeBlurhash:c}=t.data;try{let t,M;try{const e=await createImageBitmap(n,{resizeWidth:s,resizeHeight:l,resizeQuality:"high"});if(t=new OffscreenCanvas(s,l),M=t.getContext("2d"),!M)throw new Error("Failed to get 2d context from OffscreenCanvas");M.drawImage(e,0,0,s,l),e.close()}catch(f){console.warn("Hardware resize failed, falling back to stepped software scaling:",f);const e=await n.arrayBuffer(),a=new Blob([e],{type:n.type}),o=await createImageBitmap(a);let r=o.width,h=o.height,i=new OffscreenCanvas(r,h),g=i.getContext("2d");for(g.imageSmoothingEnabled=!0,g.imageSmoothingQuality="high",g.drawImage(o,0,0);.5*i.width>s&&.5*i.height>l;){const t=new OffscreenCanvas(Math.floor(.5*i.width),Math.floor(.5*i.height)),e=t.getContext("2d");e.imageSmoothingEnabled=!0,e.imageSmoothingQuality="high",e.drawImage(i,0,0,t.width,t.height),i=t}t=new OffscreenCanvas(s,l),M=t.getContext("2d"),M.imageSmoothingEnabled=!0,M.imageSmoothingQuality="high",M.drawImage(i,0,0,s,l),o.close()}let d=null;if(c)try{d=((t,a,n)=>{if(a*n*4!==t.length)throw new h("Width and height must match the pixels array");let s=[];for(let e=0;e<3;e++)for(let o=0;o<4;o++){let r=0==o&&0==e?1:2,h=i(t,a,n,(t,h)=>r*Math.cos(Math.PI*o*t/a)*Math.cos(Math.PI*e*h/n));s.push(h)}let l,g=s[0],c=s.slice(1),f="";if(f+=e(21,1),c.length>0){let t=Math.max(...c.map(t=>Math.max(...t))),a=Math.floor(Math.max(0,Math.min(82,Math.floor(166*t-.5))));l=(a+1)/166,f+=e(a,1)}else l=1,f+=e(0,1);return f+=e((t=>(o(t[0])<<16)+(o(t[1])<<8)+o(t[2]))(g),4),c.forEach(t=>{f+=e(((t,e)=>19*Math.floor(Math.max(0,Math.min(18,Math.floor(9*r(t[0]/e,.5)+9.5))))*19+19*Math.floor(Math.max(0,Math.min(18,Math.floor(9*r(t[1]/e,.5)+9.5))))+Math.floor(Math.max(0,Math.min(18,Math.floor(9*r(t[2]/e,.5)+9.5)))))(t,l),2)}),f})(M.getImageData(0,0,s,l).data,s,l)}catch(m){console.warn("Could not generate blurhash (possible canvas fingerprinting protection):",m)}const u=await t.convertToBlob({type:"image/jpeg",quality:g}),w=`${s}x${l}`;self.postMessage({id:a,success:!0,blob:u,dim:w,blurhash:d})}catch(M){self.postMessage({id:a,success:!1,error:M.message})}}}();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -39,8 +39,8 @@
<meta name="msapplication-TileColor" content="#F6E9A6">
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
<script type="module" crossorigin src="/assets/main-BPMVqwjL.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-BF2Ls-fG.css">
<script type="module" crossorigin src="/assets/main-AsE4IKjj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-BA3LWr76.css">
</head>
<body>
</body>

View File

@@ -5,6 +5,16 @@
"source": "nullvoxpopuli/agent-skills",
"sourceType": "github",
"computedHash": "7909c3def6c4ddefb358d1973cf724269ede9f6cdba1dd2888e4e6072a897f3e"
},
"nak": {
"source": "soapbox-pub/nostr-skills",
"sourceType": "github",
"computedHash": "710d3f3945ff421ed2b7f40ecd32c5e263bc029d43fe8f4fd1491a8013c7389a"
},
"nostr": {
"source": "soapbox-pub/nostr-skills",
"sourceType": "github",
"computedHash": "e1e6834c18d18a5deef4cd9555f6eee0fc0b968acf1c619253999eda76beab8e"
}
}
}

View File

@@ -3,6 +3,7 @@ import {
setupRenderingTest as upstreamSetupRenderingTest,
setupTest as upstreamSetupTest,
} from 'ember-qunit';
import { setupNostrMocks } from './mock-nostr';
// This file exists to provide wrappers around ember-qunit's
// test setup functions. This way, you can easily extend the setup that is
@@ -10,6 +11,7 @@ import {
function setupApplicationTest(hooks, options) {
upstreamSetupApplicationTest(hooks, options);
setupNostrMocks(hooks);
// Additional setup for application tests can be done here.
//
@@ -29,12 +31,14 @@ function setupApplicationTest(hooks, options) {
function setupRenderingTest(hooks, options) {
upstreamSetupRenderingTest(hooks, options);
setupNostrMocks(hooks);
// Additional setup for rendering tests can be done here.
}
function setupTest(hooks, options) {
upstreamSetupTest(hooks, options);
setupNostrMocks(hooks);
// Additional setup for unit tests can be done here.
}

View File

@@ -0,0 +1,96 @@
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { Promise } from 'rsvp';
export class MockNostrAuthService extends Service {
@tracked pubkey = null;
@tracked signerType = null;
@tracked connectStatus = null;
@tracked connectUri = null;
get isConnected() {
return false;
}
get isMobile() {
return false;
}
get signer() {
return null;
}
async connectWithExtension() {
return Promise.resolve();
}
async connectWithApp() {
return Promise.resolve();
}
disconnect() {}
}
export class MockNostrDataService extends Service {
@tracked profile = null;
@tracked mailboxes = null;
@tracked blossomServers = [];
@tracked placePhotos = [];
store = {
add: () => {},
};
get activeReadRelays() {
return [];
}
get activeWriteRelays() {
return [];
}
get defaultReadRelays() {
return [];
}
get defaultWriteRelays() {
return [];
}
get userDisplayName() {
return 'Mock User';
}
loadPlacesInBounds() {
return Promise.resolve();
}
loadPhotosForPlace() {
return Promise.resolve();
}
loadPlacePhotos() {
return Promise.resolve();
}
}
export class MockNostrRelayService extends Service {
pool = {
publish: () => Promise.resolve([{ ok: true }]),
subscribe: () => {},
unsubscribe: () => {},
close: () => {},
};
async publish() {
return [{ ok: true }];
}
}
export function setupNostrMocks(hooks) {
hooks.beforeEach(function () {
this.owner.register('service:nostrAuth', MockNostrAuthService);
this.owner.register('service:nostrData', MockNostrDataService);
this.owner.register('service:nostrRelay', MockNostrRelayService);
});
}

View File

@@ -255,4 +255,83 @@ module('Integration | Component | place-details', function (hooks) {
assert.dom('.actions button').hasText('Save');
assert.dom('.actions button').doesNotHaveClass('btn-secondary');
});
test('it aggregates phone and mobile tags without duplicates', async function (assert) {
const place = {
title: 'Phone Shop',
osmTags: {
phone: '+1-234-567-8900',
'contact:phone': '+1-234-567-8900; +1 000 000 0000',
mobile: '+1 987 654 3210',
'contact:mobile': '+1 987 654 3210',
},
};
await render(<template><PlaceDetails @place={{place}} /></template>);
// Use specific selector for the phone block since there's no cuisine or opening_hours
const metaInfos = Array.from(
this.element.querySelectorAll('.meta-info .content-with-icon')
);
const phoneBlock = metaInfos.find((el) => {
const iconSpan = el.querySelector('span.icon[title="Phone"]');
return !!iconSpan;
});
assert.ok(phoneBlock, 'Phone block is rendered');
const links = phoneBlock.querySelectorAll('a[href^="tel:"]');
assert.strictEqual(
links.length,
3,
'Rendered exactly 3 unique phone links'
);
assert.strictEqual(links[0].getAttribute('href'), 'tel:+12345678900');
assert.strictEqual(links[1].getAttribute('href'), 'tel:+10000000000');
assert.strictEqual(links[2].getAttribute('href'), 'tel:+19876543210');
assert.dom(links[0]).hasText('+1-234-567-8900');
assert.dom(links[1]).hasText('+1 000 000 0000');
assert.dom(links[2]).hasText('+1 987 654 3210');
});
test('it formats whatsapp tags into wa.me links', async function (assert) {
const place = {
title: 'Chat Shop',
osmTags: {
'contact:whatsapp': '+1 234-567 8900',
whatsapp: '+44 987 654 321', // Also tests multiple values
},
};
await render(<template><PlaceDetails @place={{place}} /></template>);
const metaInfos = Array.from(
this.element.querySelectorAll('.meta-info .content-with-icon')
);
const whatsappBlock = metaInfos.find((el) => {
const iconSpan = el.querySelector('span.icon[title="WhatsApp"]');
return !!iconSpan;
});
assert.ok(whatsappBlock, 'WhatsApp block is rendered');
const links = whatsappBlock.querySelectorAll('a[href^="https://wa.me/"]');
assert.strictEqual(links.length, 2, 'Rendered exactly 2 WhatsApp links');
// Verify it stripped the dashes and spaces for the wa.me URL
assert.strictEqual(
links[0].getAttribute('href'),
'https://wa.me/+44987654321'
);
assert.strictEqual(
links[1].getAttribute('href'),
'https://wa.me/+12345678900'
);
// Verify it kept the dashes and spaces for the visible text
assert.dom(links[0]).hasText('+44 987 654 321');
assert.dom(links[1]).hasText('+1 234-567 8900');
});
});

View File

@@ -0,0 +1,114 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'marco/tests/helpers';
import { render, click } from '@ember/test-helpers';
import PlacePhotosCarousel from 'marco/components/place-photos-carousel';
module('Integration | Component | place-photos-carousel', function (hooks) {
setupRenderingTest(hooks);
test('it renders gracefully with no photos', async function (assert) {
this.photos = [];
await render(
<template><PlacePhotosCarousel @photos={{this.photos}} /></template>
);
assert
.dom('.place-photos-carousel-wrapper')
.doesNotExist('it does not render the wrapper when there are no photos');
});
test('it renders a single photo without navigation chevrons', async function (assert) {
this.photos = [
{
url: 'photo1.jpg',
thumbUrl: 'thumb1.jpg',
blurhash: 'LKO2?U%2Tw=w]~RBVZRi};RPxuwH',
ratio: 1.5,
isLandscape: true,
},
];
await render(
<template>
<div class="test-container">
<PlacePhotosCarousel @photos={{this.photos}} />
</div>
</template>
);
assert
.dom('.place-photos-carousel-wrapper')
.exists('it renders the wrapper');
assert
.dom('.carousel-slide:not(.carousel-placeholder)')
.exists({ count: 1 }, 'it renders one real photo slide');
assert
.dom('.carousel-placeholder')
.exists({ count: 1 }, 'it renders one placeholder');
assert
.dom('img.place-header-photo')
.hasAttribute('data-src', 'photo1.jpg', 'it sets the data-src correctly');
// There should be no chevrons when there's only 1 photo
assert
.dom('.carousel-nav-btn')
.doesNotExist('it does not render chevrons for a single photo');
});
test('it renders multiple photos and shows chevrons', async function (assert) {
this.photos = [
{
url: 'photo1.jpg',
thumbUrl: 'thumb1.jpg',
blurhash: 'LKO2?U%2Tw=w]~RBVZRi};RPxuwH',
ratio: 1.5,
isLandscape: true,
},
{
url: 'photo2.jpg',
thumbUrl: 'thumb2.jpg',
blurhash: 'LKO2?U%2Tw=w]~RBVZRi};RPxuwH',
ratio: 1.0,
isLandscape: false,
},
{
url: 'photo3.jpg',
thumbUrl: 'thumb3.jpg',
blurhash: 'LKO2?U%2Tw=w]~RBVZRi};RPxuwH',
ratio: 0.8,
isLandscape: false,
},
];
await render(
<template>
<div class="test-container">
<PlacePhotosCarousel @photos={{this.photos}} />
</div>
</template>
);
await new Promise((resolve) => setTimeout(resolve, 100));
assert.dom('.carousel-slide').exists({ count: 3 }, 'it renders all slides');
assert
.dom('.carousel-nav-btn')
.exists({ count: 2 }, 'it renders both chevrons');
// Initially, it shouldn't be able to scroll left
assert
.dom('.carousel-nav-btn.prev')
.hasClass('disabled', 'the prev button is disabled initially');
assert
.dom('.carousel-nav-btn.next')
.doesNotHaveClass('disabled', 'the next button is enabled initially');
// We can't perfectly test native scroll behavior easily in JSDOM/QUnit without mocking the DOM elements' scroll properties,
// but we can test that clicking the next button triggers the scrolling method.
// However, since we mock scrollLeft in the component logic implicitly via template action, let's at least ensure clicking doesn't throw.
await click('.carousel-nav-btn.next');
assert.ok(true, 'clicking next button does not throw');
});
});

View File

@@ -52,6 +52,7 @@ module('Unit | Service | osm-auth', function (hooks) {
// Because restoreSession runs in the constructor, we might need to overwrite it after, but it's async.
// Let's just create it, let the original restoreSession fail or do nothing, and then we stub and re-call it.
service.oauthClient.ready = Promise.resolve();
service.oauthClient.isAuthorized = async () => true;
window.localStorage.setItem('marco:osm_user_display_name', 'CachedName');
@@ -68,6 +69,7 @@ module('Unit | Service | osm-auth', function (hooks) {
test('it fetches user info when logged in but no cached name', async function (assert) {
let service = this.owner.factoryFor('service:osm-auth').create();
service.oauthClient.ready = Promise.resolve();
service.oauthClient.isAuthorized = async () => true;
service.oauthClient.getTokens = async () => ({ accessToken: 'fake-token' });
// Ensure localStorage is empty for this key