Compare commits
14 Commits
aea0388267
...
feature/no
| Author | SHA1 | Date | |
|---|---|---|---|
|
d221594a0a
|
|||
|
bae01a3c9b
|
|||
|
0efc8994e9
|
|||
|
5c71523d90
|
|||
|
bea1b97fb7
|
|||
|
6ba1cf31cf
|
|||
|
9cdd021cda
|
|||
|
ef53870b35
|
|||
|
918a794784
|
|||
|
344a3067fa
|
|||
|
ad3e6ea402
|
|||
|
9e2545da7b
|
|||
|
480c97fb9d
|
|||
|
179cf49370
|
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.
|
||||
@@ -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
|
||||
|
||||
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
|
||||
|
||||
@@ -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.3",
|
||||
"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
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
@@ -39,7 +39,7 @@
|
||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||
|
||||
<script type="module" crossorigin src="/assets/main-BPMVqwjL.js"></script>
|
||||
<script type="module" crossorigin src="/assets/main-BVEi_-zb.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-BF2Ls-fG.css">
|
||||
</head>
|
||||
<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