Compare commits
37 Commits
v1.19.1
...
b23d54d74f
| Author | SHA1 | Date | |
|---|---|---|---|
|
b23d54d74f
|
|||
|
5bd4dba907
|
|||
|
54ba99673f
|
|||
|
54445f249b
|
|||
|
9828ad2714
|
|||
|
a89ba904c8
|
|||
|
4c540bc713
|
|||
|
bb2411972f
|
|||
|
5cd384cf3a
|
|||
|
ec31d1a59b
|
|||
|
4f55f26851
|
|||
|
b7cce6eb7e
|
|||
|
79777fb51a
|
|||
|
1ed66ca744
|
|||
|
a2a61b0fec
|
|||
|
d9ba73559e
|
|||
|
f1ebafc1f0
|
|||
|
10501b64bd
|
|||
|
7607f27013
|
|||
|
8cc579e271
|
|||
|
3a56464926
|
|||
|
1dc0c4119b
|
|||
|
c57a665655
|
|||
|
6cfe2b40b9
|
|||
|
99d8ca9174
|
|||
|
629a308b79
|
|||
|
798ed0c8dd
|
|||
|
03583e5a52
|
|||
|
b9f64f30e1
|
|||
|
4bd5c4bf2a
|
|||
|
f875fc1877
|
|||
|
2268a607d5
|
|||
|
f01b5f8faa
|
|||
|
9075089221
|
|||
|
5ad702e6e6
|
|||
|
bae01a3c9b
|
|||
|
0efc8994e9
|
359
.agents/skills/nak/SKILL.md
Normal file
359
.agents/skills/nak/SKILL.md
Normal 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)
|
||||
```
|
||||
|
||||
51
.agents/skills/nostr/SKILL.md
Normal file
51
.agents/skills/nostr/SKILL.md
Normal 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
49
AGENTS.md
Normal 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.
|
||||
@@ -7,10 +7,13 @@ import Icon from '#components/icon';
|
||||
import UserMenu from '#components/user-menu';
|
||||
import SearchBox from '#components/search-box';
|
||||
import CategoryChips from '#components/category-chips';
|
||||
import { and } from 'ember-truth-helpers';
|
||||
|
||||
export default class AppHeaderComponent extends Component {
|
||||
@service storage;
|
||||
@service settings;
|
||||
@service nostrAuth;
|
||||
@service nostrData;
|
||||
@tracked isUserMenuOpen = false;
|
||||
@tracked searchQuery = '';
|
||||
|
||||
@@ -64,9 +67,19 @@ export default class AppHeaderComponent extends Component {
|
||||
aria-label="User Menu"
|
||||
{{on "click" this.toggleUserMenu}}
|
||||
>
|
||||
<div class="user-avatar-placeholder">
|
||||
<Icon @name="user" @size={{20}} @color="white" />
|
||||
</div>
|
||||
{{#if
|
||||
(and this.nostrAuth.isConnected this.nostrData.profile.picture)
|
||||
}}
|
||||
<img
|
||||
src={{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}}
|
||||
|
||||
@@ -1,129 +1,25 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { on } from '@ember/modifier';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
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;
|
||||
<template>
|
||||
<div class="sidebar-header">
|
||||
<button type="button" class="back-btn" {{on "click" @onBack}}>
|
||||
<Icon @name="arrow-left" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
<h2>Settings</h2>
|
||||
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
||||
<Icon @name="x" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@action
|
||||
updateApi(event) {
|
||||
this.settings.updateOverpassApi(event.target.value);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="sidebar-header">
|
||||
<button type="button" class="back-btn" {{on "click" @onBack}}>
|
||||
<Icon @name="arrow-left" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
<h2>Settings</h2>
|
||||
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
||||
<Icon @name="x" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
<div class="sidebar-content">
|
||||
<section class="settings-section">
|
||||
<AppMenuSettingsMapUi />
|
||||
<AppMenuSettingsApis />
|
||||
<AppMenuSettingsNostr />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
69
app/components/app-menu/settings/apis.gjs
Normal file
69
app/components/app-menu/settings/apis.gjs
Normal file
@@ -0,0 +1,69 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { on } from '@ember/modifier';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import Icon from '#components/icon';
|
||||
import eq from 'ember-truth-helpers/helpers/eq';
|
||||
|
||||
export default class AppMenuSettingsApis extends Component {
|
||||
@service settings;
|
||||
|
||||
@action
|
||||
updateApi(event) {
|
||||
this.settings.updateOverpassApi(event.target.value);
|
||||
}
|
||||
|
||||
@action
|
||||
updatePhotonApi(event) {
|
||||
this.settings.updatePhotonApi(event.target.value);
|
||||
}
|
||||
|
||||
<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" 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>
|
||||
</div>
|
||||
</details>
|
||||
</template>
|
||||
}
|
||||
76
app/components/app-menu/settings/map-ui.gjs
Normal file
76
app/components/app-menu/settings/map-ui.gjs
Normal file
@@ -0,0 +1,76 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { on } from '@ember/modifier';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import Icon from '#components/icon';
|
||||
|
||||
export default class AppMenuSettingsMapUi extends Component {
|
||||
@service settings;
|
||||
|
||||
@action
|
||||
toggleKinetic(event) {
|
||||
this.settings.updateMapKinetic(event.target.value === 'true');
|
||||
}
|
||||
|
||||
@action
|
||||
toggleQuickSearchButtons(event) {
|
||||
this.settings.updateShowQuickSearchButtons(event.target.value === 'true');
|
||||
}
|
||||
|
||||
<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" 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>
|
||||
</details>
|
||||
</template>
|
||||
}
|
||||
53
app/components/app-menu/settings/nostr.gjs
Normal file
53
app/components/app-menu/settings/nostr.gjs
Normal file
@@ -0,0 +1,53 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { on } from '@ember/modifier';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import Icon from '#components/icon';
|
||||
|
||||
export default class AppMenuSettingsNostr extends Component {
|
||||
@service settings;
|
||||
|
||||
@action
|
||||
togglePhotoFallbackUploads(event) {
|
||||
this.settings.updateNostrPhotoFallbackUploads(
|
||||
event.target.value === 'true'
|
||||
);
|
||||
}
|
||||
|
||||
<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" this.togglePhotoFallbackUploads}}
|
||||
>
|
||||
<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>
|
||||
</details>
|
||||
</template>
|
||||
}
|
||||
43
app/components/modal.gjs
Normal file
43
app/components/modal.gjs
Normal 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>
|
||||
}
|
||||
93
app/components/nostr-connect.gjs
Normal file
93
app/components/nostr-connect.gjs
Normal 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>
|
||||
}
|
||||
@@ -9,14 +9,48 @@ import { getSocialInfo } from '../utils/social-links';
|
||||
import Icon from '../components/icon';
|
||||
import PlaceEditForm from './place-edit-form';
|
||||
import PlaceListsManager from './place-lists-manager';
|
||||
import PlacePhotoUpload from './place-photo-upload';
|
||||
import NostrConnect from './nostr-connect';
|
||||
import Modal from './modal';
|
||||
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
export default class PlaceDetails extends Component {
|
||||
@service storage;
|
||||
@service nostrAuth;
|
||||
@tracked isEditing = false;
|
||||
@tracked showLists = false;
|
||||
@tracked isPhotoUploadModalOpen = false;
|
||||
@tracked isNostrConnectModalOpen = false;
|
||||
|
||||
@action
|
||||
openPhotoUploadModal(e) {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
if (!this.nostrAuth.isConnected) {
|
||||
this.isNostrConnectModalOpen = true;
|
||||
} else {
|
||||
this.isPhotoUploadModalOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
closePhotoUploadModal() {
|
||||
this.isPhotoUploadModalOpen = false;
|
||||
}
|
||||
|
||||
@action
|
||||
closeNostrConnectModal() {
|
||||
this.isNostrConnectModalOpen = false;
|
||||
}
|
||||
|
||||
@action
|
||||
onNostrConnected() {
|
||||
this.isNostrConnectModalOpen = false;
|
||||
this.isPhotoUploadModalOpen = true;
|
||||
}
|
||||
|
||||
get isSaved() {
|
||||
return this.storage.isPlaceSaved(this.place.id || this.place.osmId);
|
||||
@@ -500,6 +534,34 @@ export default class PlaceDetails extends Component {
|
||||
{{/if}}
|
||||
|
||||
</div>
|
||||
|
||||
{{#if this.osmUrl}}
|
||||
<div class="meta-info">
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="camera" />
|
||||
<span>
|
||||
<a href="#" {{on "click" this.openPhotoUploadModal}}>
|
||||
Add a photo
|
||||
</a>
|
||||
</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>
|
||||
}
|
||||
|
||||
147
app/components/place-photo-upload-item.gjs
Normal file
147
app/components/place-photo-upload-item.gjs
Normal file
@@ -0,0 +1,147 @@
|
||||
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';
|
||||
|
||||
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 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
|
||||
);
|
||||
|
||||
// 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'}}"
|
||||
>
|
||||
<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>
|
||||
}
|
||||
269
app/components/place-photo-upload.gjs
Normal file
269
app/components/place-photo-upload.gjs
Normal file
@@ -0,0 +1,269 @@
|
||||
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 blossom;
|
||||
@service toast;
|
||||
|
||||
@tracked files = [];
|
||||
@tracked uploadedPhotos = [];
|
||||
@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.files.length > 0 && this.files.length === this.uploadedPhotos.length
|
||||
);
|
||||
}
|
||||
|
||||
get photoWord() {
|
||||
return this.files.length === 1 ? 'Photo' : 'Photos';
|
||||
}
|
||||
|
||||
@action
|
||||
handleFileSelect(event) {
|
||||
this.addFiles(event.target.files);
|
||||
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;
|
||||
this.addFiles(event.dataTransfer.files);
|
||||
}
|
||||
|
||||
addFiles(fileList) {
|
||||
if (!fileList) return;
|
||||
const newFiles = Array.from(fileList).filter((f) =>
|
||||
f.type.startsWith('image/')
|
||||
);
|
||||
this.files = [...this.files, ...newFiles];
|
||||
}
|
||||
|
||||
@action
|
||||
handleUploadSuccess(photoData) {
|
||||
this.uploadedPhotos = [...this.uploadedPhotos, photoData];
|
||||
}
|
||||
|
||||
@action
|
||||
removeFile(fileToRemove) {
|
||||
const photoData = this.uploadedPhotos.find((p) => p.file === fileToRemove);
|
||||
this.files = this.files.filter((f) => f !== fileToRemove);
|
||||
this.uploadedPhotos = this.uploadedPhotos.filter(
|
||||
(p) => p.file !== fileToRemove
|
||||
);
|
||||
|
||||
if (photoData && photoData.hash && photoData.url) {
|
||||
this.deletePhotoTask.perform(photoData);
|
||||
}
|
||||
}
|
||||
|
||||
deletePhotoTask = task(async (photoData) => {
|
||||
try {
|
||||
if (photoData.hash) {
|
||||
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)]);
|
||||
}
|
||||
|
||||
for (const photo of this.uploadedPhotos) {
|
||||
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(event);
|
||||
|
||||
this.toast.show('Photos published successfully');
|
||||
this.status = '';
|
||||
|
||||
// Clear out the files so user can upload more or be done
|
||||
this.files = [];
|
||||
this.uploadedPhotos = [];
|
||||
|
||||
if (this.args.onClose) {
|
||||
this.args.onClose();
|
||||
}
|
||||
} catch (e) {
|
||||
this.error = 'Failed to publish: ' + e.message;
|
||||
this.status = '';
|
||||
} finally {
|
||||
this.isPublishing = false;
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="place-photo-upload">
|
||||
<h2>Add Photos 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}}
|
||||
|
||||
<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 photos here, or click to browse</p>
|
||||
</label>
|
||||
<input
|
||||
id="photo-upload-input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
class="file-input-hidden"
|
||||
disabled={{this.isPublishing}}
|
||||
{{on "change" this.handleFileSelect}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{{#if this.files.length}}
|
||||
<div class="photo-grid">
|
||||
{{#each this.files as |file|}}
|
||||
<PlacePhotoUploadItem
|
||||
@file={{file}}
|
||||
@onSuccess={{this.handleUploadSuccess}}
|
||||
@onRemove={{this.removeFile}}
|
||||
/>
|
||||
{{/each}}
|
||||
</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
|
||||
{{this.files.length}}
|
||||
{{this.photoWord}}
|
||||
{{/if}}
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
@@ -3,10 +3,17 @@ import { action } from '@ember/object';
|
||||
import { service } from '@ember/service';
|
||||
import Icon from '#components/icon';
|
||||
import { on } from '@ember/modifier';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import 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() {
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
17
app/modifiers/qr-code.js
Normal file
17
app/modifiers/qr-code.js
Normal 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
205
app/services/blossom.js
Normal file
205
app/services/blossom.js
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
129
app/services/image-processor.js
Normal file
129
app/services/image-processor.js
Normal 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();
|
||||
}
|
||||
}
|
||||
295
app/services/nostr-auth.js
Normal file
295
app/services/nostr-auth.js
Normal 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);
|
||||
}
|
||||
}
|
||||
219
app/services/nostr-data.js
Normal file
219
app/services/nostr-data.js
Normal file
@@ -0,0 +1,219 @@
|
||||
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';
|
||||
|
||||
const BOOTSTRAP_RELAYS = [
|
||||
'wss://purplepag.es',
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
];
|
||||
|
||||
export default class NostrDataService extends Service {
|
||||
@service nostrRelay;
|
||||
@service nostrAuth;
|
||||
|
||||
store = new EventStore();
|
||||
|
||||
@tracked profile = null;
|
||||
@tracked mailboxes = null;
|
||||
@tracked blossomServers = [];
|
||||
|
||||
_profileSub = null;
|
||||
_mailboxesSub = null;
|
||||
_blossomSub = null;
|
||||
|
||||
_requestSub = null;
|
||||
_cachePromise = null;
|
||||
|
||||
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, and blossom servers
|
||||
const toCache = events.filter(
|
||||
(e) => e.kind === 0 || e.kind === 10002 || e.kind === 10063
|
||||
);
|
||||
|
||||
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>
|
||||
});
|
||||
}
|
||||
|
||||
async loadProfile(pubkey) {
|
||||
if (!pubkey) return;
|
||||
|
||||
// Reset state
|
||||
this.profile = null;
|
||||
this.mailboxes = null;
|
||||
this.blossomServers = [];
|
||||
|
||||
this._cleanupSubscriptions();
|
||||
|
||||
const relays = new Set(BOOTSTRAP_RELAYS);
|
||||
|
||||
// Try to get extension relays
|
||||
if (typeof window.nostr !== 'undefined' && window.nostr.getRelays) {
|
||||
try {
|
||||
const extRelays = await window.nostr.getRelays();
|
||||
for (const url of Object.keys(extRelays)) {
|
||||
relays.add(url);
|
||||
}
|
||||
} catch {
|
||||
console.warn('Failed to get NIP-07 relays');
|
||||
}
|
||||
}
|
||||
|
||||
const relayList = Array.from(relays);
|
||||
|
||||
// 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
|
||||
this._requestSub = this.nostrRelay.pool
|
||||
.request(relayList, [
|
||||
{
|
||||
authors: [pubkey],
|
||||
kinds: [0, 10002, 10063],
|
||||
},
|
||||
])
|
||||
.subscribe({
|
||||
next: (event) => {
|
||||
this.store.add(event);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error fetching profile events:', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
get userDisplayName() {
|
||||
if (this.profile) {
|
||||
if (this.profile.nip05) {
|
||||
return this.profile.nip05;
|
||||
}
|
||||
if (this.profile.displayName || this.profile.display_name) {
|
||||
return this.profile.displayName || this.profile.display_name;
|
||||
}
|
||||
if (this.profile.name) {
|
||||
return this.profile.name;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to npub
|
||||
if (this.nostrAuth.pubkey) {
|
||||
try {
|
||||
const npub = npubEncode(this.nostrAuth.pubkey);
|
||||
return `${npub.slice(0, 9)}...${npub.slice(-4)}`;
|
||||
} catch {
|
||||
return this.nostrAuth.pubkey;
|
||||
}
|
||||
}
|
||||
|
||||
return 'Not connected';
|
||||
}
|
||||
|
||||
_cleanupSubscriptions() {
|
||||
if (this._requestSub) {
|
||||
this._requestSub.unsubscribe();
|
||||
this._requestSub = null;
|
||||
}
|
||||
if (this._profileSub) {
|
||||
this._profileSub.unsubscribe();
|
||||
this._profileSub = null;
|
||||
}
|
||||
if (this._mailboxesSub) {
|
||||
this._mailboxesSub.unsubscribe();
|
||||
this._mailboxesSub = null;
|
||||
}
|
||||
if (this._blossomSub) {
|
||||
this._blossomSub.unsubscribe();
|
||||
this._blossomSub = null;
|
||||
}
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
super.willDestroy(...arguments);
|
||||
this._cleanupSubscriptions();
|
||||
|
||||
if (this._stopPersisting) {
|
||||
this._stopPersisting();
|
||||
}
|
||||
|
||||
if (this.cache) {
|
||||
this.cache.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
25
app/services/nostr-relay.js
Normal file
25
app/services/nostr-relay.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import Service from '@ember/service';
|
||||
import { RelayPool } from 'applesauce-relay';
|
||||
|
||||
export default class NostrRelayService extends Service {
|
||||
pool = new RelayPool();
|
||||
|
||||
// For Phase 1, we hardcode the local relay
|
||||
relays = ['ws://127.0.0.1:7777'];
|
||||
|
||||
async publish(event) {
|
||||
// The publish method is a wrapper around the event method that returns a Promise<PublishResponse[]>
|
||||
// and automatically handles reconnecting and retrying.
|
||||
const responses = await this.pool.publish(this.relays, event);
|
||||
|
||||
// Check if at least one relay accepted the event
|
||||
const success = responses.some((res) => res.ok);
|
||||
if (!success) {
|
||||
throw new Error(
|
||||
`Failed to publish event. Responses: ${JSON.stringify(responses)}`
|
||||
);
|
||||
}
|
||||
|
||||
return responses;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export default class SettingsService extends Service {
|
||||
@tracked mapKinetic = true;
|
||||
@tracked photonApi = 'https://photon.komoot.io/api/';
|
||||
@tracked showQuickSearchButtons = true;
|
||||
@tracked nostrPhotoFallbackUploads = false;
|
||||
|
||||
overpassApis = [
|
||||
{
|
||||
@@ -64,6 +65,14 @@ export default class SettingsService extends Service {
|
||||
if (savedShowQuickSearch !== null) {
|
||||
this.showQuickSearchButtons = savedShowQuickSearch === 'true';
|
||||
}
|
||||
|
||||
const savedNostrPhotoFallbackUploads = localStorage.getItem(
|
||||
'marco:nostr-photo-fallback-uploads'
|
||||
);
|
||||
if (savedNostrPhotoFallbackUploads !== null) {
|
||||
this.nostrPhotoFallbackUploads =
|
||||
savedNostrPhotoFallbackUploads === 'true';
|
||||
}
|
||||
}
|
||||
|
||||
updateOverpassApi(url) {
|
||||
@@ -84,4 +93,9 @@ export default class SettingsService extends Service {
|
||||
updatePhotonApi(url) {
|
||||
this.photonApi = url;
|
||||
}
|
||||
|
||||
updateNostrPhotoFallbackUploads(enabled) {
|
||||
this.nostrPhotoFallbackUploads = enabled;
|
||||
localStorage.setItem('marco:nostr-photo-fallback-uploads', String(enabled));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +180,9 @@ body {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.user-avatar-placeholder {
|
||||
@@ -190,7 +193,133 @@ 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;
|
||||
padding: 2rem 1.5rem;
|
||||
text-align: center;
|
||||
transition: all 0.2s ease;
|
||||
margin: 1.5rem 0 1rem;
|
||||
background-color: rgb(255 255 255 / 2%);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dropzone.is-dragging {
|
||||
border-color: #61afef;
|
||||
background-color: rgb(97 175 239 / 5%);
|
||||
}
|
||||
|
||||
.dropzone-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
cursor: pointer;
|
||||
color: #898989;
|
||||
}
|
||||
|
||||
.dropzone-label p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.file-input-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.photo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.photo-upload-item {
|
||||
position: relative;
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #1e262e;
|
||||
}
|
||||
|
||||
.photo-upload-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.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(--marker-color-primary);
|
||||
}
|
||||
|
||||
.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 +381,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 {
|
||||
@@ -463,7 +595,7 @@ body {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
margin-bottom: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
@@ -835,6 +967,7 @@ abbr[title] {
|
||||
display: inline-flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin: -6px 0;
|
||||
}
|
||||
|
||||
.app-logo-icon svg {
|
||||
@@ -1374,3 +1507,94 @@ 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;
|
||||
}
|
||||
|
||||
4
app/utils/device.js
Normal file
4
app/utils/device.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export function isMobile() {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
return /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
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 clock from 'feather-icons/dist/icons/clock.svg?raw';
|
||||
import edit from 'feather-icons/dist/icons/edit.svg?raw';
|
||||
@@ -25,8 +26,12 @@ import search from 'feather-icons/dist/icons/search.svg?raw';
|
||||
import server from 'feather-icons/dist/icons/server.svg?raw';
|
||||
import settings from 'feather-icons/dist/icons/settings.svg?raw';
|
||||
import target from 'feather-icons/dist/icons/target.svg?raw';
|
||||
import trash2 from 'feather-icons/dist/icons/trash-2.svg?raw';
|
||||
import uploadCloud from 'feather-icons/dist/icons/upload-cloud.svg?raw';
|
||||
import user from 'feather-icons/dist/icons/user.svg?raw';
|
||||
import x from 'feather-icons/dist/icons/x.svg?raw';
|
||||
import check from 'feather-icons/dist/icons/check.svg?raw';
|
||||
import alertCircle from 'feather-icons/dist/icons/alert-circle.svg?raw';
|
||||
import zap from 'feather-icons/dist/icons/zap.svg?raw';
|
||||
|
||||
import angelfish from '@waysidemapping/pinhead/dist/icons/angelfish.svg?raw';
|
||||
@@ -39,7 +44,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';
|
||||
@@ -130,6 +134,8 @@ const ICONS = {
|
||||
'check-square': checkSquare,
|
||||
'cigarette-with-smoke-curl': cigaretteWithSmokeCurl,
|
||||
climbing_wall: climbingWall,
|
||||
check,
|
||||
'alert-circle': alertCircle,
|
||||
'classical-building': classicalBuilding,
|
||||
'classical-building-with-dome-and-flag': classicalBuildingWithDomeAndFlag,
|
||||
'classical-building-with-flag': classicalBuildingWithFlag,
|
||||
@@ -214,6 +220,8 @@ const ICONS = {
|
||||
'tattoo-machine': tattooMachine,
|
||||
toolbox,
|
||||
target,
|
||||
'trash-2': trash2,
|
||||
'upload-cloud': uploadCloud,
|
||||
'tree-and-bench-with-backrest': treeAndBenchWithBackrest,
|
||||
user,
|
||||
'village-buildings': villageBuildings,
|
||||
@@ -235,7 +243,6 @@ const FILLED_ICONS = [
|
||||
'cup-and-saucer',
|
||||
'coffee-bean',
|
||||
'shopping-basket',
|
||||
'camera',
|
||||
'person-sleeping-in-bed',
|
||||
'loading-ring',
|
||||
'nostrich',
|
||||
|
||||
130
app/workers/image-processor.js
Normal file
130
app/workers/image-processor.js
Normal 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,
|
||||
});
|
||||
}
|
||||
};
|
||||
107
doc/nostr/nip-place-photos.md
Normal file
107
doc/nostr/nip-place-photos.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# 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
|
||||
|
||||
Media files MUST be attached using the `imeta` tag as defined in NIP-92. Each `imeta` tag represents one media item. The primary `url` SHOULD also be appended to the event's `.content` for backwards compatibility with clients that do not parse `imeta` tags.
|
||||
|
||||
Clients SHOULD include `alt` (accessibility descriptions), `dim` (dimensions), `m` (MIME type), and `blurhash` where possible.
|
||||
|
||||
```json
|
||||
[
|
||||
"imeta",
|
||||
"url https://example.com/photo.jpg",
|
||||
"m image/jpeg",
|
||||
"dim 3024x4032",
|
||||
"alt A steaming bowl of ramen on a wooden table at the restaurant.",
|
||||
"blurhash eVF$^OI:${M{o#*0-nNFxakD-?xVM}WEWB%iNKxvR-oetmo#R-aen$"
|
||||
]
|
||||
```
|
||||
|
||||
### Optional Tags
|
||||
|
||||
- `t`: Hashtags for categorization (e.g., `["t", "food"]`, `["t", "architecture"]`).
|
||||
- `content-warning`: If the media contains NSFW or sensitive imagery.
|
||||
- `published_at`: Unix timestamp of when the photo was originally taken or published.
|
||||
|
||||
## Example Event
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "<32-bytes hex>",
|
||||
"pubkey": "<32-bytes hex>",
|
||||
"created_at": 1713205000,
|
||||
"kind": 360,
|
||||
"content": "",
|
||||
"tags": [
|
||||
["i", "osm:node:987654321"],
|
||||
["g", "xn0m"],
|
||||
["g", "xn0m7h"],
|
||||
["g", "xn0m7hwq"],
|
||||
|
||||
[
|
||||
"imeta",
|
||||
"url https://example.com/ramen.jpg",
|
||||
"m image/jpeg",
|
||||
"dim 1080x1080",
|
||||
"alt A close-up of spicy miso ramen with chashu pork, soft boiled egg, and scallions.",
|
||||
"blurhash UHI=0o~q4T-o~q%MozM{x]t7RjRPt7oKkCWB"
|
||||
],
|
||||
|
||||
["t", "ramen"],
|
||||
["t", "food"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Rationale
|
||||
|
||||
### Why not use NIP-68 (Picture-first feeds)?
|
||||
|
||||
NIP-68 is designed for general-purpose social feeds (like Instagram). Place photos require strict guarantees about what entity is being depicted to be useful for map clients, directories, and review aggregators. By mandating the `i` tag for POI linking and the `g` tag for spatial querying, this kind ensures interoperability for geo-spatial applications without cluttering general picture feeds with mundane POI images (like photos of storefronts or menus).
|
||||
|
||||
### Separation from Place Reviews
|
||||
|
||||
Reviews (kind 30360) and media have different lifecycles and data models. A user might upload 10 photos of a park without writing a review, or write a detailed review without attaching photos. Keeping them as separate events allows clients to query `imeta` attachments for a specific `i` tag to quickly build a photo gallery for a place, regardless of whether a review was attached.
|
||||
313
doc/nostr/nip-place-reviews.md
Normal file
313
doc/nostr/nip-place-reviews.md
Normal 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
7
doc/nostr/notes.md
Normal 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
250
doc/nostr/ranking.md
Normal 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 1–10 → 0–1:
|
||||
|
||||
```text
|
||||
normalized_score = (score - 1) / 9
|
||||
```
|
||||
|
||||
Why:
|
||||
|
||||
- easier math
|
||||
- comparable across aspects
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Per-aspect aggregation (avoid averages trap)
|
||||
|
||||
Instead of mean, compute:
|
||||
|
||||
### A. Positive ratio
|
||||
|
||||
```text
|
||||
positive = score >= 7
|
||||
negative = score <= 4
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```text
|
||||
positive_ratio = positive_votes / total_votes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### B. Confidence-weighted score
|
||||
|
||||
Use something like a **Wilson score interval** (this is key):
|
||||
|
||||
- prevents small-sample abuse
|
||||
- avoids “1 review = #1 place”
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Build aspect scores
|
||||
|
||||
For each aspect:
|
||||
|
||||
```text
|
||||
aspect_score = f(
|
||||
positive_ratio,
|
||||
confidence,
|
||||
number_of_reviews
|
||||
)
|
||||
```
|
||||
|
||||
You can approximate with:
|
||||
|
||||
```text
|
||||
aspect_score = positive_ratio * log(1 + review_count)
|
||||
```
|
||||
|
||||
(Simple, works surprisingly well)
|
||||
|
||||
---
|
||||
|
||||
## Step 4: User preference weighting
|
||||
|
||||
User defines:
|
||||
|
||||
```json
|
||||
{
|
||||
"quality": 0.5,
|
||||
"value": 0.2,
|
||||
"service": 0.2,
|
||||
"speed": 0.1
|
||||
}
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```text
|
||||
final_score = Σ (aspect_score × weight)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Context filtering (this is your unfair advantage)
|
||||
|
||||
Filter reviews before scoring:
|
||||
|
||||
- time-based:
|
||||
- “last 6 months”
|
||||
|
||||
- context-based:
|
||||
- lunch vs dinner
|
||||
- solo vs group
|
||||
|
||||
This is something centralized platforms barely do.
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Reviewer weighting (later, but powerful)
|
||||
|
||||
Weight reviews by:
|
||||
|
||||
- consistency
|
||||
- similarity to user preferences
|
||||
- past agreement
|
||||
|
||||
This gives you:
|
||||
|
||||
> “people like you liked this”
|
||||
|
||||
---
|
||||
|
||||
# 3. Example end-to-end
|
||||
|
||||
### Raw reviews:
|
||||
|
||||
| User | Food | Service |
|
||||
| ---- | ---- | ------- |
|
||||
| A | 9 | 4 |
|
||||
| B | 8 | 5 |
|
||||
| C | 10 | 3 |
|
||||
|
||||
---
|
||||
|
||||
### Derived:
|
||||
|
||||
- food → high positive ratio (~100%)
|
||||
- service → low (~33%)
|
||||
|
||||
---
|
||||
|
||||
### User preferences:
|
||||
|
||||
```json
|
||||
{
|
||||
"food": 0.8,
|
||||
"service": 0.2
|
||||
}
|
||||
```
|
||||
|
||||
→ ranks high
|
||||
|
||||
Another user:
|
||||
|
||||
```json
|
||||
{
|
||||
"food": 0.3,
|
||||
"service": 0.7
|
||||
}
|
||||
```
|
||||
|
||||
→ ranks low
|
||||
|
||||
👉 Same data, different truth
|
||||
That’s your killer feature.
|
||||
|
||||
---
|
||||
|
||||
# 4. Critical design choices (don’t skip these)
|
||||
|
||||
## A. No global score in protocol
|
||||
|
||||
Let clients compute it.
|
||||
|
||||
---
|
||||
|
||||
## B. Embrace incomplete data
|
||||
|
||||
Most reviews will have:
|
||||
|
||||
- 1–3 aspects only
|
||||
|
||||
That’s fine.
|
||||
|
||||
---
|
||||
|
||||
## C. Time decay (important)
|
||||
|
||||
Recent reviews should matter more:
|
||||
|
||||
```text
|
||||
weight = e^(-λ × age)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## D. Anti-gaming baseline
|
||||
|
||||
Even in nostr:
|
||||
|
||||
- spam will happen
|
||||
|
||||
Mitigation later:
|
||||
|
||||
- require minimum interactions
|
||||
- reputation layers
|
||||
|
||||
---
|
||||
|
||||
# 5. What you’ve built (zooming out)
|
||||
|
||||
This is not a review system.
|
||||
|
||||
It’s:
|
||||
|
||||
> A decentralized, multi-dimensional reputation graph for real-world places
|
||||
|
||||
That’s much bigger.
|
||||
|
||||
---
|
||||
|
||||
# 6. Next step (if you want to go deeper)
|
||||
|
||||
We can design:
|
||||
|
||||
### A. Query layer
|
||||
|
||||
- how clients fetch & merge nostr reviews efficiently
|
||||
|
||||
### B. Anti-spam / trust model
|
||||
|
||||
- web-of-trust
|
||||
- staking / reputation
|
||||
|
||||
### C. OSM integration details
|
||||
|
||||
- handling duplicates
|
||||
- POI identity conflicts
|
||||
|
||||
---
|
||||
|
||||
If I had to pick one next:
|
||||
👉 **trust/reputation system** — because without it, everything you built _will_ get gamed.
|
||||
100
doc/nostr/ratings.md
Normal file
100
doc/nostr/ratings.md
Normal 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”
|
||||
92
doc/nostr/review-schema.json
Normal file
92
doc/nostr/review-schema.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
11
package.json
11
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
954
pnpm-lock.yaml
generated
954
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user