Compare commits

..

5 Commits

13 changed files with 1358 additions and 96 deletions

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

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

View File

@@ -9,6 +9,8 @@ import { getSocialInfo } from '../utils/social-links';
import Icon from '../components/icon';
import PlaceEditForm from './place-edit-form';
import PlaceListsManager from './place-lists-manager';
import PlacePhotoUpload from './place-photo-upload';
import Modal from './modal';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
@@ -17,6 +19,20 @@ export default class PlaceDetails extends Component {
@service storage;
@tracked isEditing = false;
@tracked showLists = false;
@tracked isPhotoUploadModalOpen = false;
@action
openPhotoUploadModal(e) {
if (e) {
e.preventDefault();
}
this.isPhotoUploadModalOpen = true;
}
@action
closePhotoUploadModal() {
this.isPhotoUploadModalOpen = false;
}
get isSaved() {
return this.storage.isPlaceSaved(this.place.id || this.place.osmId);
@@ -499,7 +515,24 @@ export default class PlaceDetails extends Component {
</p>
{{/if}}
{{#if this.osmUrl}}
<p class="content-with-icon">
<Icon @name="camera" />
<span>
<a href="#" {{on "click" this.openPhotoUploadModal}}>
Add a photo
</a>
</span>
</p>
{{/if}}
</div>
</div>
{{#if this.isPhotoUploadModalOpen}}
<Modal @onClose={{this.closePhotoUploadModal}}>
<PlacePhotoUpload @place={{this.saveablePlace}} />
</Modal>
{{/if}}
</template>
}

View File

@@ -0,0 +1,168 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { on } from '@ember/modifier';
import { EventFactory } from 'applesauce-core';
import Geohash from 'latlon-geohash';
export default class PlacePhotoUpload extends Component {
@service nostrAuth;
@service nostrRelay;
@tracked photoUrl = '';
@tracked status = '';
@tracked error = '';
get place() {
return this.args.place || {};
}
get title() {
return this.place.title || 'this place';
}
@action
async login() {
try {
this.error = '';
await this.nostrAuth.login();
} catch (e) {
this.error = e.message;
}
}
@action
async uploadPhoto(event) {
event.preventDefault();
this.error = '';
this.status = 'Uploading...';
try {
// Mock upload
await new Promise((resolve) => setTimeout(resolve, 1000));
this.photoUrl =
'https://dummyimage.com/600x400/000/fff.jpg&text=Mock+Place+Photo';
this.status = 'Photo uploaded! Ready to publish.';
} catch (e) {
this.error = 'Upload failed: ' + e.message;
this.status = '';
}
}
@action
async publish() {
if (!this.nostrAuth.isConnected) {
this.error = 'You must connect Nostr first.';
return;
}
if (!this.photoUrl) {
this.error = 'Please upload a photo.';
return;
}
const { osmId, lat, lon } = this.place;
const osmType = this.place.osmType || 'node';
if (!osmId) {
this.error = 'This place does not have a valid OSM ID.';
return;
}
this.status = 'Publishing event...';
this.error = '';
try {
const factory = new EventFactory({ signer: this.nostrAuth.signer });
const tags = [['i', `osm:${osmType}:${osmId}`]];
if (lat && lon) {
tags.push(['g', Geohash.encode(lat, lon, 4)]);
tags.push(['g', Geohash.encode(lat, lon, 6)]);
tags.push(['g', Geohash.encode(lat, lon, 7)]);
tags.push(['g', Geohash.encode(lat, lon, 9)]);
}
tags.push([
'imeta',
`url ${this.photoUrl}`,
'm image/jpeg',
'dim 600x400',
'alt A photo of a place',
]);
// NIP-XX draft Place Photo event
const template = {
kind: 360,
content: '',
tags,
};
// Ensure created_at is present before signing
if (!template.created_at) {
template.created_at = Math.floor(Date.now() / 1000);
}
const event = await factory.sign(template);
await this.nostrRelay.publish(event);
this.status = 'Published successfully!';
// Reset form
this.photoUrl = '';
} catch (e) {
this.error = 'Failed to publish: ' + e.message;
this.status = '';
}
}
<template>
<div class="place-photo-upload">
<h2>Add Photo for {{this.title}}</h2>
{{#if this.error}}
<div class="alert alert-error">
{{this.error}}
</div>
{{/if}}
{{#if this.status}}
<div class="alert alert-info">
{{this.status}}
</div>
{{/if}}
{{#if this.nostrAuth.isConnected}}
<div class="connected-status">
<strong>Connected:</strong>
{{this.nostrAuth.pubkey}}
</div>
<form {{on "submit" this.uploadPhoto}}>
{{#if this.photoUrl}}
<div class="preview-group">
<p>Photo Preview:</p>
<img src={{this.photoUrl}} alt="Preview" />
</div>
<button
type="button"
class="btn btn-primary"
{{on "click" this.publish}}
>
Publish Event (kind: 360)
</button>
{{else}}
<button type="submit" class="btn btn-secondary">
Mock Upload Photo
</button>
{{/if}}
</form>
{{else}}
<button type="button" class="btn btn-primary" {{on "click" this.login}}>
Connect Nostr Extension
</button>
{{/if}}
</div>
</template>
}

View File

@@ -8,6 +8,8 @@ export default class UserMenuComponent extends Component {
@service storage;
@service osmAuth;
@service nostrAuth;
@action
connectRS() {
this.args.onClose();
@@ -30,6 +32,21 @@ export default class UserMenuComponent extends Component {
this.osmAuth.logout();
}
@action
async connectNostr() {
try {
await this.nostrAuth.login();
} catch (e) {
console.error(e);
alert(e.message);
}
}
@action
disconnectNostr() {
this.nostrAuth.logout();
}
<template>
<div class="user-menu-popover">
<ul class="account-list">
@@ -91,15 +108,34 @@ export default class UserMenuComponent extends Component {
</div>
</li>
<li class="account-item disabled">
<li class="account-item">
<div class="account-header">
<div class="account-info">
<Icon @name="zap" @size={{18}} />
<span>Nostr</span>
</div>
{{#if this.nostrAuth.isConnected}}
<button
class="btn-text text-danger"
type="button"
{{on "click" this.disconnectNostr}}
>Disconnect</button>
{{else}}
<button
class="btn-text text-primary"
type="button"
{{on "click" this.connectNostr}}
>Connect</button>
{{/if}}
</div>
<div class="account-status">
Coming soon
{{#if this.nostrAuth.isConnected}}
<strong title={{this.nostrAuth.pubkey}}>
{{this.nostrAuth.pubkey}}
</strong>
{{else}}
Not connected
{{/if}}
</div>
</li>
</ul>

View File

@@ -0,0 +1,78 @@
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { ExtensionSigner } from 'applesauce-signers';
const STORAGE_KEY = 'marco:nostr_pubkey';
export default class NostrAuthService extends Service {
@tracked pubkey = null;
constructor() {
super(...arguments);
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
this.pubkey = saved;
this._verifyPubkey();
}
}
async _verifyPubkey() {
if (typeof window.nostr === 'undefined') {
this.logout();
return;
}
try {
const signer = new ExtensionSigner();
const extensionPubkey = await signer.getPublicKey();
if (extensionPubkey !== this.pubkey) {
this.pubkey = extensionPubkey;
localStorage.setItem(STORAGE_KEY, this.pubkey);
}
} catch (e) {
console.warn('Failed to verify nostr pubkey, logging out', e);
this.logout();
}
}
get isConnected() {
return !!this.pubkey;
}
get signer() {
if (typeof window.nostr !== 'undefined') {
return new ExtensionSigner();
}
return null;
}
async login() {
if (typeof window.nostr === 'undefined') {
throw new Error('No NIP-07 Nostr extension found (e.g., Alby, nos2x).');
}
try {
this.pubkey = await this.signer.getPublicKey();
localStorage.setItem(STORAGE_KEY, this.pubkey);
return this.pubkey;
} catch (error) {
console.error('Failed to get public key from extension:', error);
throw error;
}
}
async signEvent(event) {
if (!this.signer) {
throw new Error(
'Not connected or extension missing. Please connect Nostr again.'
);
}
return await this.signer.signEvent(event);
}
logout() {
this.pubkey = null;
localStorage.removeItem(STORAGE_KEY);
}
}

View File

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

View File

@@ -252,6 +252,9 @@ body {
color: #898989;
margin-top: 0.35rem;
margin-left: calc(18px + 0.75rem);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.account-status strong {
@@ -1374,3 +1377,72 @@ button.create-place {
transform: translate(-50%, 0);
}
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgb(0 0 0 / 50%);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
padding: 1.5rem;
max-width: 90vw;
width: 450px;
position: relative;
}
.close-modal-btn {
position: absolute;
top: 1rem;
right: 1rem;
cursor: pointer;
}
/* Place Photo Upload */
.place-photo-upload h2 {
margin-top: 0;
}
.alert {
padding: 0.5rem;
margin-bottom: 1rem;
border-radius: 0.25rem;
}
.alert-error {
background: #fee;
color: #c00;
}
.alert-info {
background: #eef;
color: #00c;
}
.connected-status {
margin-bottom: 1rem;
color: #080;
word-break: break-all;
}
.preview-group {
margin-bottom: 1rem;
}
.preview-group p {
margin-bottom: 0.25rem;
font-weight: bold;
}
.preview-group img {
max-width: 100%;
border-radius: 0.25rem;
}

View File

@@ -29,7 +29,8 @@ Identifies the exact place the media depicts using an external identifier (as de
```json
["i", "osm:node:123456"]
```
* For OSM POIs, `<type>` MUST be one of: `node`, `way`, `relation`.
- For OSM POIs, `<type>` MUST be one of: `node`, `way`, `relation`.
#### 2. `g` — Geohash
@@ -61,9 +62,9 @@ Clients SHOULD include `alt` (accessibility descriptions), `dim` (dimensions), `
### Optional Tags
* `t`: Hashtags for categorization (e.g., `["t", "food"]`, `["t", "architecture"]`).
* `content-warning`: If the media contains NSFW or sensitive imagery.
* `published_at`: Unix timestamp of when the photo was originally taken or published.
- `t`: Hashtags for categorization (e.g., `["t", "food"]`, `["t", "architecture"]`).
- `content-warning`: If the media contains NSFW or sensitive imagery.
- `published_at`: Unix timestamp of when the photo was originally taken or published.
## Example Event
@@ -80,7 +81,8 @@ Clients SHOULD include `alt` (accessibility descriptions), `dim` (dimensions), `
["g", "xn0m7h"],
["g", "xn0m7hwq"],
["imeta",
[
"imeta",
"url https://example.com/ramen.jpg",
"m image/jpeg",
"dim 1080x1080",

View File

@@ -8,10 +8,10 @@ This NIP defines a standardized event format for decentralized place reviews usi
The design prioritizes:
* Small event size
* Interoperability across clients
* Flexibility for different place types
* Efficient geospatial querying using geohashes
- Small event size
- Interoperability across clients
- Flexibility for different place types
- Efficient geospatial querying using geohashes
## Event Kind
@@ -23,8 +23,8 @@ Additional tags MAY be included by clients but are not defined by this specifica
This NIP reuses and builds upon existing Nostr tag conventions:
* `i` tag: see NIP-73 (External Content Identifiers)
* `g` tag: geohash-based geotagging (community conventions)
- `i` tag: see NIP-73 (External Content Identifiers)
- `g` tag: geohash-based geotagging (community conventions)
Where conflicts arise, this NIP specifies the behavior for review events.
@@ -40,7 +40,7 @@ Identifies the reviewed place using an external identifier. OpenStreetMap data i
Requirements:
* For OSM POIs, `<type>` MUST be one of: `node`, `way`, `relation`
- For OSM POIs, `<type>` MUST be one of: `node`, `way`, `relation`
Examples:
@@ -57,15 +57,15 @@ Geohash tags are used for spatial indexing and discovery.
##### Requirements
* Clients MUST include at least one high-precision geohash (length ≥ 9)
- Clients MUST include at least one high-precision geohash (length ≥ 9)
##### Recommendations
Clients SHOULD include geohashes at the following resolutions:
* length 4 — coarse (city-scale discovery)
* length 6 — medium (default query level, ~1 km)
* length 7 — fine (neighborhood, ~150 m)
- length 4 — coarse (city-scale discovery)
- length 6 — medium (default query level, ~1 km)
- length 7 — fine (neighborhood, ~150 m)
Example:
@@ -80,10 +80,10 @@ Example:
Geospatial queries are performed using the `g` tag.
* Clients SHOULD query using a single geohash precision level per request
* Clients MAY include multiple geohash values in a filter to cover a bounding box
* Clients SHOULD limit the number of geohash values per query (e.g. ≤ 30)
* Clients MAY reduce precision or split queries when necessary
- Clients SHOULD query using a single geohash precision level per request
- Clients MAY include multiple geohash values in a filter to cover a bounding box
- Clients SHOULD limit the number of geohash values per query (e.g. ≤ 30)
- Clients MAY reduce precision or split queries when necessary
Note: Other queries (e.g. fetching reviews for a specific place) are performed using the `i` tag and are outside the scope of geospatial querying.
@@ -228,22 +228,22 @@ The event `content` MUST be valid JSON matching the following schema.
### Ratings
* Scores are integers from 1 to 10
* `quality` is required and represents the core evaluation of the place
* Other fields are optional and context-dependent
- Scores are integers from 1 to 10
- `quality` is required and represents the core evaluation of the place
- Other fields are optional and context-dependent
### Aspects
* Free-form keys allow domain-specific ratings
* Clients MAY define and interpret aspect keys
* Clients SHOULD reuse commonly established aspect keys where possible
- Free-form keys allow domain-specific ratings
- Clients MAY define and interpret aspect keys
- Clients SHOULD reuse commonly established aspect keys where possible
## Recommendation Signal
The `recommend` field represents a binary verdict:
* `true` → user recommends the place
* `false` → user does not recommend the place
- `true` → user recommends the place
- `false` → user does not recommend the place
Clients SHOULD strongly encourage users to provide this value.
@@ -251,9 +251,9 @@ Clients SHOULD strongly encourage users to provide this value.
Represents user familiarity with the place:
* `low` → first visit or limited exposure
* `medium` → occasional visits
* `high` → frequent or expert-level familiarity
- `low` → first visit or limited exposure
- `medium` → occasional visits
- `high` → frequent or expert-level familiarity
Clients MAY use this signal for weighting during aggregation.
@@ -261,16 +261,16 @@ Clients MAY use this signal for weighting during aggregation.
Optional metadata about the visit.
* `visited_at` is a Unix timestamp
* `duration_minutes` represents time spent
* `party_size` indicates group size
- `visited_at` is a Unix timestamp
- `duration_minutes` represents time spent
- `party_size` indicates group size
## Interoperability
This specification defines a content payload only.
* In Nostr: place identity is conveyed via tags
* In other protocols (e.g. ActivityPub, AT Protocol): identity MUST be mapped to the equivalent field (e.g. `object`)
- In Nostr: place identity is conveyed via tags
- In other protocols (e.g. ActivityPub, AT Protocol): identity MUST be mapped to the equivalent field (e.g. `object`)
Content payloads SHOULD NOT include place identifiers.
@@ -296,18 +296,18 @@ Provides a human-friendly proxy for confidence without requiring numeric input.
Multiple resolutions balance:
* efficient querying
* small event size
* early-stage discoverability
- efficient querying
- small event size
- early-stage discoverability
## Future Work
* Standardized aspect vocabularies
* Reputation and weighting models
* Indexing/aggregation services
* Cross-protocol mappings
- Standardized aspect vocabularies
- Reputation and weighting models
- Indexing/aggregation services
- Cross-protocol mappings
## Security Considerations
* Clients SHOULD validate all input
* Malicious or spam reviews may require external moderation or reputation systems
- Clients SHOULD validate all input
- Malicious or spam reviews may require external moderation or reputation systems

View File

@@ -2,13 +2,13 @@
Your inputs:
* many users
* partial ratings
* different priorities
- many users
- partial ratings
- different priorities
Your output:
> “Best place *for this user right now*
> “Best place _for this user right now_
---
@@ -22,8 +22,8 @@ normalized_score = (score - 1) / 9
Why:
* easier math
* comparable across aspects
- easier math
- comparable across aspects
---
@@ -50,8 +50,8 @@ positive_ratio = positive_votes / total_votes
Use something like a **Wilson score interval** (this is key):
* prevents small-sample abuse
* avoids “1 review = #1 place”
- prevents small-sample abuse
- avoids “1 review = #1 place”
---
@@ -102,13 +102,12 @@ final_score = Σ (aspect_score × weight)
Filter reviews before scoring:
* time-based:
- time-based:
- “last 6 months”
* “last 6 months”
* context-based:
* lunch vs dinner
* solo vs group
- context-based:
- lunch vs dinner
- solo vs group
This is something centralized platforms barely do.
@@ -118,9 +117,9 @@ This is something centralized platforms barely do.
Weight reviews by:
* consistency
* similarity to user preferences
* past agreement
- consistency
- similarity to user preferences
- past agreement
This gives you:
@@ -142,8 +141,8 @@ This gives you:
### Derived:
* food → high positive ratio (~100%)
* service → low (~33%)
- food → high positive ratio (~100%)
- service → low (~33%)
---
@@ -186,7 +185,7 @@ Let clients compute it.
Most reviews will have:
* 13 aspects only
- 13 aspects only
Thats fine.
@@ -206,12 +205,12 @@ weight = e^(-λ × age)
Even in nostr:
* spam will happen
- spam will happen
Mitigation later:
* require minimum interactions
* reputation layers
- require minimum interactions
- reputation layers
---
@@ -233,19 +232,19 @@ We can design:
### A. Query layer
* how clients fetch & merge nostr reviews efficiently
- how clients fetch & merge nostr reviews efficiently
### B. Anti-spam / trust model
* web-of-trust
* staking / reputation
- web-of-trust
- staking / reputation
### C. OSM integration details
* handling duplicates
* POI identity conflicts
- handling duplicates
- POI identity conflicts
---
If I had to pick one next:
👉 **trust/reputation system** — because without it, everything you built *will* get gamed.
👉 **trust/reputation system** — because without it, everything you built _will_ get gamed.

View File

@@ -2,23 +2,23 @@
## A. Core universal aspects
These should work for *any* place:
These should work for _any_ place:
```json
[
"quality", // core offering (food, repair, exhibits, etc.)
"value", // value for money/time
"experience", // comfort, usability, vibe
"quality", // core offering (food, repair, exhibits, etc.)
"value", // value for money/time
"experience", // comfort, usability, vibe
"accessibility" // ease of access, inclusivity
]
```
### Why these work
* **quality** → your “product” abstraction (critical)
* **value** → universally meaningful signal
* **experience** → captures everything “soft”
* **accessibility** → often ignored but high utility
- **quality** → your “product” abstraction (critical)
- **value** → universally meaningful signal
- **experience** → captures everything “soft”
- **accessibility** → often ignored but high utility
👉 Resist adding more. Every extra “universal” weakens the concept.
@@ -30,8 +30,8 @@ Not universal, but widely reusable:
```json
[
"service", // human interaction
"speed", // waiting time / turnaround
"service", // human interaction
"speed", // waiting time / turnaround
"cleanliness",
"safety",
"reliability",
@@ -41,7 +41,7 @@ Not universal, but widely reusable:
These apply to:
* restaurants, garages, clinics, parks, etc.
- restaurants, garages, clinics, parks, etc.
---
@@ -69,11 +69,10 @@ Let clients define freely:
To reduce fragmentation:
* publish a **public registry (GitHub repo)**
* clients can:
* suggest additions
* map synonyms
- publish a **public registry (GitHub repo)**
- clients can:
- suggest additions
- map synonyms
---
@@ -96,6 +95,6 @@ Not required, but useful for aggregation engines.
Map familiarity in UI to:
* high: “I know this place well”
* medium: “Been a few times”
* low: “First visit”
- high: “I know this place well”
- medium: “Been a few times”
- low: “First visit”

View File

@@ -103,8 +103,13 @@
},
"dependencies": {
"@waysidemapping/pinhead": "^15.20.0",
"applesauce-core": "^5.2.0",
"applesauce-factory": "^4.0.0",
"applesauce-relay": "^5.2.0",
"applesauce-signers": "^5.2.0",
"ember-concurrency": "^5.2.0",
"ember-lifeline": "^7.0.0",
"oauth2-pkce": "^2.1.3"
"oauth2-pkce": "^2.1.3",
"rxjs": "^7.8.2"
}
}

802
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff