Compare commits
20 Commits
9d06898b15
...
feature/no
| Author | SHA1 | Date | |
|---|---|---|---|
|
d221594a0a
|
|||
|
bae01a3c9b
|
|||
|
0efc8994e9
|
|||
|
5c71523d90
|
|||
|
bea1b97fb7
|
|||
|
6ba1cf31cf
|
|||
|
9cdd021cda
|
|||
|
ef53870b35
|
|||
|
918a794784
|
|||
|
344a3067fa
|
|||
|
ad3e6ea402
|
|||
|
9e2545da7b
|
|||
|
480c97fb9d
|
|||
|
179cf49370
|
|||
|
aea0388267
|
|||
|
e4d02cda26
|
|||
|
27ebbaca60
|
|||
|
cbdd056dcb
|
|||
|
2423b67f94
|
|||
|
2a3ad26eb9
|
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.
|
||||
@@ -284,7 +284,9 @@ export default class MapComponent extends Component {
|
||||
const initialCenter = toLonLat(view.getCenter());
|
||||
this.mapUi.updateCenter(initialCenter[1], initialCenter[0]);
|
||||
|
||||
apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty');
|
||||
apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty', {
|
||||
webfonts: 'data:text/css,',
|
||||
});
|
||||
|
||||
this.searchOverlayElement = document.createElement('div');
|
||||
this.searchOverlayElement.className = 'search-pulse';
|
||||
@@ -392,7 +394,10 @@ export default class MapComponent extends Component {
|
||||
const locateElement = document.createElement('div');
|
||||
locateElement.className = 'ol-control ol-locate';
|
||||
const locateBtn = document.createElement('button');
|
||||
locateBtn.innerHTML = '⊙';
|
||||
locateBtn.style.display = 'flex';
|
||||
locateBtn.style.alignItems = 'center';
|
||||
locateBtn.style.justifyContent = 'center';
|
||||
locateBtn.innerHTML = `<span class="icon" style="width: 14px; height: 14px; display: flex;">${getIcon('navigation')}</span>`;
|
||||
locateBtn.title = 'Locate Me';
|
||||
locateElement.appendChild(locateBtn);
|
||||
|
||||
|
||||
@@ -130,15 +130,24 @@ export default class PlaceDetails extends Component {
|
||||
|
||||
formatMultiLine(val, type) {
|
||||
if (!val) return null;
|
||||
const parts = val
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const parts = [
|
||||
...new Set(
|
||||
val
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
),
|
||||
];
|
||||
if (parts.length === 0) return null;
|
||||
|
||||
if (type === 'phone') {
|
||||
return htmlSafe(
|
||||
parts.map((p) => `<a href="tel:${p}">${p}</a>`).join('<br>')
|
||||
parts
|
||||
.map((p) => {
|
||||
const safeTel = p.replace(/[\s-]+/g, '');
|
||||
return `<a href="tel:${safeTel}">${p}</a>`;
|
||||
})
|
||||
.join('<br>')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -148,6 +157,17 @@ export default class PlaceDetails extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'whatsapp') {
|
||||
return htmlSafe(
|
||||
parts
|
||||
.map((p) => {
|
||||
const safeTel = p.replace(/[\s-]+/g, '');
|
||||
return `<a href="https://wa.me/${safeTel}" target="_blank" rel="noopener noreferrer">${p}</a>`;
|
||||
})
|
||||
.join('<br>')
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'url') {
|
||||
return htmlSafe(
|
||||
parts
|
||||
@@ -165,8 +185,27 @@ export default class PlaceDetails extends Component {
|
||||
}
|
||||
|
||||
get phone() {
|
||||
const val = this.tags.phone || this.tags['contact:phone'];
|
||||
return this.formatMultiLine(val, 'phone');
|
||||
const rawValues = [
|
||||
this.tags.phone,
|
||||
this.tags['contact:phone'],
|
||||
this.tags.mobile,
|
||||
this.tags['contact:mobile'],
|
||||
].filter(Boolean);
|
||||
|
||||
if (rawValues.length === 0) return null;
|
||||
|
||||
return this.formatMultiLine(rawValues.join(';'), 'phone');
|
||||
}
|
||||
|
||||
get whatsapp() {
|
||||
const rawValues = [
|
||||
this.tags.whatsapp,
|
||||
this.tags['contact:whatsapp'],
|
||||
].filter(Boolean);
|
||||
|
||||
if (rawValues.length === 0) return null;
|
||||
|
||||
return this.formatMultiLine(rawValues.join(';'), 'whatsapp');
|
||||
}
|
||||
|
||||
get email() {
|
||||
@@ -343,6 +382,15 @@ export default class PlaceDetails extends Component {
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.whatsapp}}
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="whatsapp" @title="WhatsApp" />
|
||||
<span>
|
||||
{{this.whatsapp}}
|
||||
</span>
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.website}}
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="globe" @title="Website" />
|
||||
|
||||
@@ -11,7 +11,7 @@ export default class UserMenuComponent extends Component {
|
||||
@action
|
||||
connectRS() {
|
||||
this.args.onClose();
|
||||
this.args.storage.connect();
|
||||
this.args.storage.showConnectWidget();
|
||||
}
|
||||
|
||||
@action
|
||||
@@ -55,7 +55,7 @@ export default class UserMenuComponent extends Component {
|
||||
</div>
|
||||
<div class="account-status">
|
||||
{{#if @storage.connected}}
|
||||
{{@storage.userAddress}}
|
||||
<strong>{{@storage.userAddress}}</strong>
|
||||
{{else}}
|
||||
Not connected
|
||||
{{/if}}
|
||||
@@ -84,7 +84,7 @@ export default class UserMenuComponent extends Component {
|
||||
</div>
|
||||
<div class="account-status">
|
||||
{{#if this.osmAuth.isConnected}}
|
||||
{{this.osmAuth.userDisplayName}}
|
||||
<strong>{{this.osmAuth.userDisplayName}}</strong>
|
||||
{{else}}
|
||||
Not connected
|
||||
{{/if}}
|
||||
|
||||
4
app/icons/whatsapp.svg
Normal file
4
app/icons/whatsapp.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="-1.66 0 740.82 740.82" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m630.06 107.66c-69.329-69.387-161.53-107.62-259.76-107.66-202.4 0-367.13 164.67-367.22 367.07-0.027 64.699 16.883 127.86 49.016 183.52l-52.095 190.23 194.67-51.047c53.634 29.244 114.02 44.656 175.48 44.682h0.151c202.38 0 367.13-164.69 367.21-367.09 0.039-98.088-38.121-190.32-107.45-259.71m-259.76 564.8h-0.125c-54.766-0.021-108.48-14.729-155.34-42.529l-11.146-6.613-115.52 30.293 30.834-112.59-7.258-11.543c-30.552-48.58-46.689-104.73-46.665-162.38 0.067-168.23 136.99-305.1 305.34-305.1 81.521 0.031 158.15 31.81 215.78 89.482s89.342 134.33 89.311 215.86c-0.07 168.24-136.99 305.12-305.21 305.12m167.42-228.51c-9.176-4.591-54.286-26.782-62.697-29.843-8.41-3.061-14.526-4.591-20.644 4.592-6.116 9.182-23.7 29.843-29.054 35.964-5.351 6.122-10.703 6.888-19.879 2.296-9.175-4.591-38.739-14.276-73.786-45.526-27.275-24.32-45.691-54.36-51.043-63.542-5.352-9.183-0.569-14.148 4.024-18.72 4.127-4.11 9.175-10.713 13.763-16.07 4.587-5.356 6.116-9.182 9.174-15.303 3.059-6.122 1.53-11.479-0.764-16.07s-20.643-49.739-28.29-68.104c-7.447-17.886-15.012-15.466-20.644-15.746-5.346-0.266-11.469-0.323-17.585-0.323-6.117 0-16.057 2.296-24.468 11.478-8.41 9.183-32.112 31.374-32.112 76.521s32.877 88.763 37.465 94.885c4.587 6.122 64.699 98.771 156.74 138.5 21.891 9.45 38.982 15.093 52.307 19.323 21.981 6.979 41.983 5.994 57.793 3.633 17.628-2.633 54.285-22.19 61.932-43.616 7.646-21.426 7.646-39.791 5.352-43.617-2.293-3.826-8.41-6.122-17.585-10.714" clip-rule="evenodd" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -7,7 +7,15 @@ class MarcoOsmAuthStorage {
|
||||
localStorage.setItem('marco:osm_auth_state', serializedState);
|
||||
}
|
||||
loadState() {
|
||||
return localStorage.getItem('marco:osm_auth_state');
|
||||
const state = localStorage.getItem('marco:osm_auth_state');
|
||||
if (!state) return false;
|
||||
try {
|
||||
JSON.parse(state);
|
||||
return state;
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse OSM auth state', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +53,11 @@ export default class OsmAuthService extends Service {
|
||||
}
|
||||
|
||||
async restoreSession() {
|
||||
try {
|
||||
await this.oauthClient.ready;
|
||||
} catch (e) {
|
||||
console.warn('oauthClient.ready failed', e);
|
||||
}
|
||||
const isAuthorized = await this.oauthClient.isAuthorized();
|
||||
if (isAuthorized) {
|
||||
this.isConnected = true;
|
||||
|
||||
@@ -46,6 +46,14 @@ export default class StorageService extends Service {
|
||||
// console.debug('[rs] client ready');
|
||||
});
|
||||
|
||||
this.rs.on('error', (error) => {
|
||||
if (!error) return;
|
||||
console.info('[rs] Error —', `${error.name}: ${error.message}`);
|
||||
if (error.name === 'Unauthorized') {
|
||||
this.showConnectWidget();
|
||||
}
|
||||
});
|
||||
|
||||
this.rs.on('connected', () => {
|
||||
this.connected = true;
|
||||
this.userAddress = this.rs.remote.userAddress;
|
||||
@@ -445,7 +453,7 @@ export default class StorageService extends Service {
|
||||
}
|
||||
|
||||
@action
|
||||
connect() {
|
||||
showConnectWidget() {
|
||||
this.isWidgetOpen = true;
|
||||
|
||||
// Check if widget is already attached
|
||||
|
||||
@@ -14,6 +14,8 @@ html,
|
||||
body {
|
||||
height: 100%;
|
||||
overscroll-behavior: none; /* Prevent pull-to-refresh on mobile */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
@@ -26,6 +28,7 @@ body {
|
||||
margin: 0;
|
||||
font-family: 'Noto Sans', sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: normal;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
@@ -246,12 +249,15 @@ body {
|
||||
|
||||
.account-status {
|
||||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
color: #898989;
|
||||
margin-top: 0.35rem;
|
||||
margin-left: calc(18px + 0.75rem);
|
||||
}
|
||||
|
||||
.account-status strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.account-item.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
@@ -261,7 +267,6 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
|
||||
@@ -110,6 +110,7 @@ import womensAndMensRestroomSymbol from '@waysidemapping/pinhead/dist/icons/wome
|
||||
import loadingRing from '../icons/270-ring.svg?raw';
|
||||
import nostrich from '../icons/nostrich-2.svg?raw';
|
||||
import remotestorage from '../icons/remotestorage.svg?raw';
|
||||
import whatsapp from '../icons/whatsapp.svg?raw';
|
||||
import wikipedia from '../icons/wikipedia.svg?raw';
|
||||
|
||||
const ICONS = {
|
||||
@@ -218,6 +219,7 @@ const ICONS = {
|
||||
'village-buildings': villageBuildings,
|
||||
'wall-hanging-with-mountains-and-sun': wallHangingWithMountainsAndSun,
|
||||
'womens-and-mens-restroom-symbol': womensAndMensRestroomSymbol,
|
||||
whatsapp,
|
||||
wikipedia,
|
||||
parking_p: parkingP,
|
||||
car,
|
||||
@@ -229,6 +231,7 @@ const ICONS = {
|
||||
const FILLED_ICONS = [
|
||||
'fork-and-knife',
|
||||
'wikipedia',
|
||||
'whatsapp',
|
||||
'cup-and-saucer',
|
||||
'coffee-bean',
|
||||
'shopping-basket',
|
||||
|
||||
343
doc/nostr/nip-place-reviews.md
Normal file
343
doc/nostr/nip-place-reviews.md
Normal file
@@ -0,0 +1,343 @@
|
||||
# NIP-XX: Place Reviews
|
||||
|
||||
## Abstract
|
||||
|
||||
This NIP defines a standardized event format for decentralized place reviews using Nostr. Reviews are tied to real-world locations (e.g. OpenStreetMap POIs) via tags, and include structured, multi-aspect ratings, a binary recommendation signal, and optional contextual metadata.
|
||||
|
||||
The design prioritizes:
|
||||
|
||||
* Small event size
|
||||
* Interoperability across clients
|
||||
* Flexibility for different place types
|
||||
* Efficient geospatial querying using geohashes
|
||||
|
||||
---
|
||||
|
||||
## Event Kind
|
||||
|
||||
`kind: 30315` (suggested; subject to coordination)
|
||||
|
||||
---
|
||||
|
||||
## Tags
|
||||
|
||||
Additional tags MAY be included by clients but are not defined by this specification.
|
||||
|
||||
This NIP reuses and builds upon existing Nostr tag conventions:
|
||||
|
||||
* `i` tag: see NIP-73 (External Content Identifiers)
|
||||
* `g` tag: geohash-based geotagging (community conventions)
|
||||
|
||||
Where conflicts arise, this NIP specifies the behavior for review events.
|
||||
|
||||
### Required
|
||||
|
||||
#### `i` — Entity Identifier
|
||||
|
||||
Identifies the reviewed place using an external identifier. OpenStreetMap data is the default:
|
||||
|
||||
```
|
||||
["i", "osm:<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
|
||||
|
||||
---
|
||||
|
||||
## Copyright
|
||||
|
||||
This NIP is public domain.
|
||||
6
doc/nostr/notes.md
Normal file
6
doc/nostr/notes.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Notes
|
||||
|
||||
- NIP-73 for external IDs ("osm:node:123456"): https://github.com/nostr-protocol/nips/blob/744bce8fcae0aca07b936b6662db635c8b4253dd/73.md
|
||||
- Places NIP-XX draft PR: https://github.com/nostr-protocol/nips/pull/927
|
||||
- NPM package for generating multi-resolution geotags: https://sandwichfarm.github.io/nostr-geotags/#md:nostr-geotags
|
||||
- AppleSauce docs for AI agents: https://applesauce.build/introduction/mcp-server.html
|
||||
251
doc/nostr/ranking.md
Normal file
251
doc/nostr/ranking.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# Ranking Algorithm
|
||||
|
||||
Your inputs:
|
||||
|
||||
* many users
|
||||
* partial ratings
|
||||
* different priorities
|
||||
|
||||
Your output:
|
||||
|
||||
> “Best place *for this user right now*”
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Normalize scores
|
||||
|
||||
Convert 1–10 → 0–1:
|
||||
|
||||
```text
|
||||
normalized_score = (score - 1) / 9
|
||||
```
|
||||
|
||||
Why:
|
||||
|
||||
* easier math
|
||||
* comparable across aspects
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Per-aspect aggregation (avoid averages trap)
|
||||
|
||||
Instead of mean, compute:
|
||||
|
||||
### A. Positive ratio
|
||||
|
||||
```text
|
||||
positive = score >= 7
|
||||
negative = score <= 4
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```text
|
||||
positive_ratio = positive_votes / total_votes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### B. Confidence-weighted score
|
||||
|
||||
Use something like a **Wilson score interval** (this is key):
|
||||
|
||||
* prevents small-sample abuse
|
||||
* avoids “1 review = #1 place”
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Build aspect scores
|
||||
|
||||
For each aspect:
|
||||
|
||||
```text
|
||||
aspect_score = f(
|
||||
positive_ratio,
|
||||
confidence,
|
||||
number_of_reviews
|
||||
)
|
||||
```
|
||||
|
||||
You can approximate with:
|
||||
|
||||
```text
|
||||
aspect_score = positive_ratio * log(1 + review_count)
|
||||
```
|
||||
|
||||
(Simple, works surprisingly well)
|
||||
|
||||
---
|
||||
|
||||
## Step 4: User preference weighting
|
||||
|
||||
User defines:
|
||||
|
||||
```json
|
||||
{
|
||||
"quality": 0.5,
|
||||
"value": 0.2,
|
||||
"service": 0.2,
|
||||
"speed": 0.1
|
||||
}
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```text
|
||||
final_score = Σ (aspect_score × weight)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Context filtering (this is your unfair advantage)
|
||||
|
||||
Filter reviews before scoring:
|
||||
|
||||
* time-based:
|
||||
|
||||
* “last 6 months”
|
||||
* context-based:
|
||||
|
||||
* lunch vs dinner
|
||||
* solo vs group
|
||||
|
||||
This is something centralized platforms barely do.
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Reviewer weighting (later, but powerful)
|
||||
|
||||
Weight reviews by:
|
||||
|
||||
* consistency
|
||||
* similarity to user preferences
|
||||
* past agreement
|
||||
|
||||
This gives you:
|
||||
|
||||
> “people like you liked this”
|
||||
|
||||
---
|
||||
|
||||
# 3. Example end-to-end
|
||||
|
||||
### Raw reviews:
|
||||
|
||||
| User | Food | Service |
|
||||
| ---- | ---- | ------- |
|
||||
| A | 9 | 4 |
|
||||
| B | 8 | 5 |
|
||||
| C | 10 | 3 |
|
||||
|
||||
---
|
||||
|
||||
### Derived:
|
||||
|
||||
* food → high positive ratio (~100%)
|
||||
* service → low (~33%)
|
||||
|
||||
---
|
||||
|
||||
### User preferences:
|
||||
|
||||
```json
|
||||
{
|
||||
"food": 0.8,
|
||||
"service": 0.2
|
||||
}
|
||||
```
|
||||
|
||||
→ ranks high
|
||||
|
||||
Another user:
|
||||
|
||||
```json
|
||||
{
|
||||
"food": 0.3,
|
||||
"service": 0.7
|
||||
}
|
||||
```
|
||||
|
||||
→ ranks low
|
||||
|
||||
👉 Same data, different truth
|
||||
That’s your killer feature.
|
||||
|
||||
---
|
||||
|
||||
# 4. Critical design choices (don’t skip these)
|
||||
|
||||
## A. No global score in protocol
|
||||
|
||||
Let clients compute it.
|
||||
|
||||
---
|
||||
|
||||
## B. Embrace incomplete data
|
||||
|
||||
Most reviews will have:
|
||||
|
||||
* 1–3 aspects only
|
||||
|
||||
That’s fine.
|
||||
|
||||
---
|
||||
|
||||
## C. Time decay (important)
|
||||
|
||||
Recent reviews should matter more:
|
||||
|
||||
```text
|
||||
weight = e^(-λ × age)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## D. Anti-gaming baseline
|
||||
|
||||
Even in nostr:
|
||||
|
||||
* spam will happen
|
||||
|
||||
Mitigation later:
|
||||
|
||||
* require minimum interactions
|
||||
* reputation layers
|
||||
|
||||
---
|
||||
|
||||
# 5. What you’ve built (zooming out)
|
||||
|
||||
This is not a review system.
|
||||
|
||||
It’s:
|
||||
|
||||
> A decentralized, multi-dimensional reputation graph for real-world places
|
||||
|
||||
That’s much bigger.
|
||||
|
||||
---
|
||||
|
||||
# 6. Next step (if you want to go deeper)
|
||||
|
||||
We can design:
|
||||
|
||||
### A. Query layer
|
||||
|
||||
* how clients fetch & merge nostr reviews efficiently
|
||||
|
||||
### B. Anti-spam / trust model
|
||||
|
||||
* web-of-trust
|
||||
* staking / reputation
|
||||
|
||||
### C. OSM integration details
|
||||
|
||||
* handling duplicates
|
||||
* POI identity conflicts
|
||||
|
||||
---
|
||||
|
||||
If I had to pick one next:
|
||||
👉 **trust/reputation system** — because without it, everything you built *will* get gamed.
|
||||
110
doc/nostr/ratings.md
Normal file
110
doc/nostr/ratings.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Canonical Aspect Vocabulary (v0.1)
|
||||
|
||||
You want a **soft standard**, not a rigid schema.
|
||||
|
||||
Think:
|
||||
|
||||
* “recommended keys” (clients SHOULD use)
|
||||
* not “required keys” (protocol enforces)
|
||||
|
||||
---
|
||||
|
||||
## A. Core universal aspects (keep this small)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "marco",
|
||||
"version": "1.18.1",
|
||||
"version": "1.19.1",
|
||||
"private": true,
|
||||
"description": "Unhosted maps app",
|
||||
"repository": {
|
||||
@@ -87,7 +87,7 @@
|
||||
"prettier-plugin-ember-template-tag": "^2.1.2",
|
||||
"qunit": "^2.25.0",
|
||||
"qunit-dom": "^3.5.0",
|
||||
"remotestorage-widget": "^1.8.0",
|
||||
"remotestorage-widget": "^1.8.1",
|
||||
"remotestoragejs": "2.0.0-beta.8",
|
||||
"sinon": "^21.0.1",
|
||||
"stylelint": "^16.26.1",
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -166,8 +166,8 @@ importers:
|
||||
specifier: ^3.5.0
|
||||
version: 3.5.0
|
||||
remotestorage-widget:
|
||||
specifier: ^1.8.0
|
||||
version: 1.8.0
|
||||
specifier: ^1.8.1
|
||||
version: 1.8.1
|
||||
remotestoragejs:
|
||||
specifier: 2.0.0-beta.8
|
||||
version: 2.0.0-beta.8
|
||||
@@ -4564,8 +4564,8 @@ packages:
|
||||
resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==}
|
||||
hasBin: true
|
||||
|
||||
remotestorage-widget@1.8.0:
|
||||
resolution: {integrity: sha512-l8AE2npC2nNkC8bAU6WhWO5Wl4exaXbE2yR82s5Oiqg6h0KN2mYwvLLTQGkp6mSmZTA86e7XaOcNp4lXS8CsBA==}
|
||||
remotestorage-widget@1.8.1:
|
||||
resolution: {integrity: sha512-HxNu2VvIRW3wzkf5fLEzs56ySQ7+YQbRqyp3CKvmw/G+zKhRsmj06HtFoAcm3B14/nJh2SOAv3LyfKuXfUsKPw==}
|
||||
|
||||
remotestoragejs@2.0.0-beta.8:
|
||||
resolution: {integrity: sha512-rtyHTG2VbtiKTRmbwjponRf5VTPJMcHv/ijNid1zX48C0Z0F8ZCBBfkKD2QCxTQyQvCupkWNy3wuIu4HE+AEng==}
|
||||
@@ -10648,7 +10648,7 @@ snapshots:
|
||||
dependencies:
|
||||
jsesc: 3.1.0
|
||||
|
||||
remotestorage-widget@1.8.0: {}
|
||||
remotestorage-widget@1.8.1: {}
|
||||
|
||||
remotestoragejs@2.0.0-beta.8:
|
||||
dependencies:
|
||||
|
||||
File diff suppressed because one or more lines are too long
1
release/assets/main-BF2Ls-fG.css
Normal file
1
release/assets/main-BF2Ls-fG.css
Normal file
File diff suppressed because one or more lines are too long
2
release/assets/main-BVEi_-zb.js
Normal file
2
release/assets/main-BVEi_-zb.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -39,8 +39,8 @@
|
||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||
|
||||
<script type="module" crossorigin src="/assets/main-18-jE9H3.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-uF6fmHZ4.css">
|
||||
<script type="module" crossorigin src="/assets/main-BVEi_-zb.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-BF2Ls-fG.css">
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,4 +255,83 @@ module('Integration | Component | place-details', function (hooks) {
|
||||
assert.dom('.actions button').hasText('Save');
|
||||
assert.dom('.actions button').doesNotHaveClass('btn-secondary');
|
||||
});
|
||||
|
||||
test('it aggregates phone and mobile tags without duplicates', async function (assert) {
|
||||
const place = {
|
||||
title: 'Phone Shop',
|
||||
osmTags: {
|
||||
phone: '+1-234-567-8900',
|
||||
'contact:phone': '+1-234-567-8900; +1 000 000 0000',
|
||||
mobile: '+1 987 654 3210',
|
||||
'contact:mobile': '+1 987 654 3210',
|
||||
},
|
||||
};
|
||||
|
||||
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||
|
||||
// Use specific selector for the phone block since there's no cuisine or opening_hours
|
||||
const metaInfos = Array.from(
|
||||
this.element.querySelectorAll('.meta-info .content-with-icon')
|
||||
);
|
||||
const phoneBlock = metaInfos.find((el) => {
|
||||
const iconSpan = el.querySelector('span.icon[title="Phone"]');
|
||||
return !!iconSpan;
|
||||
});
|
||||
|
||||
assert.ok(phoneBlock, 'Phone block is rendered');
|
||||
|
||||
const links = phoneBlock.querySelectorAll('a[href^="tel:"]');
|
||||
assert.strictEqual(
|
||||
links.length,
|
||||
3,
|
||||
'Rendered exactly 3 unique phone links'
|
||||
);
|
||||
|
||||
assert.strictEqual(links[0].getAttribute('href'), 'tel:+12345678900');
|
||||
assert.strictEqual(links[1].getAttribute('href'), 'tel:+10000000000');
|
||||
assert.strictEqual(links[2].getAttribute('href'), 'tel:+19876543210');
|
||||
|
||||
assert.dom(links[0]).hasText('+1-234-567-8900');
|
||||
assert.dom(links[1]).hasText('+1 000 000 0000');
|
||||
assert.dom(links[2]).hasText('+1 987 654 3210');
|
||||
});
|
||||
|
||||
test('it formats whatsapp tags into wa.me links', async function (assert) {
|
||||
const place = {
|
||||
title: 'Chat Shop',
|
||||
osmTags: {
|
||||
'contact:whatsapp': '+1 234-567 8900',
|
||||
whatsapp: '+44 987 654 321', // Also tests multiple values
|
||||
},
|
||||
};
|
||||
|
||||
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||
|
||||
const metaInfos = Array.from(
|
||||
this.element.querySelectorAll('.meta-info .content-with-icon')
|
||||
);
|
||||
const whatsappBlock = metaInfos.find((el) => {
|
||||
const iconSpan = el.querySelector('span.icon[title="WhatsApp"]');
|
||||
return !!iconSpan;
|
||||
});
|
||||
|
||||
assert.ok(whatsappBlock, 'WhatsApp block is rendered');
|
||||
|
||||
const links = whatsappBlock.querySelectorAll('a[href^="https://wa.me/"]');
|
||||
assert.strictEqual(links.length, 2, 'Rendered exactly 2 WhatsApp links');
|
||||
|
||||
// Verify it stripped the dashes and spaces for the wa.me URL
|
||||
assert.strictEqual(
|
||||
links[0].getAttribute('href'),
|
||||
'https://wa.me/+44987654321'
|
||||
);
|
||||
assert.strictEqual(
|
||||
links[1].getAttribute('href'),
|
||||
'https://wa.me/+12345678900'
|
||||
);
|
||||
|
||||
// Verify it kept the dashes and spaces for the visible text
|
||||
assert.dom(links[0]).hasText('+44 987 654 321');
|
||||
assert.dom(links[1]).hasText('+1 234-567 8900');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,6 +52,7 @@ module('Unit | Service | osm-auth', function (hooks) {
|
||||
// Because restoreSession runs in the constructor, we might need to overwrite it after, but it's async.
|
||||
// Let's just create it, let the original restoreSession fail or do nothing, and then we stub and re-call it.
|
||||
|
||||
service.oauthClient.ready = Promise.resolve();
|
||||
service.oauthClient.isAuthorized = async () => true;
|
||||
window.localStorage.setItem('marco:osm_user_display_name', 'CachedName');
|
||||
|
||||
@@ -68,6 +69,7 @@ module('Unit | Service | osm-auth', function (hooks) {
|
||||
test('it fetches user info when logged in but no cached name', async function (assert) {
|
||||
let service = this.owner.factoryFor('service:osm-auth').create();
|
||||
|
||||
service.oauthClient.ready = Promise.resolve();
|
||||
service.oauthClient.isAuthorized = async () => true;
|
||||
service.oauthClient.getTokens = async () => ({ accessToken: 'fake-token' });
|
||||
// Ensure localStorage is empty for this key
|
||||
|
||||
Reference in New Issue
Block a user