Compare commits
5 Commits
f01b5f8faa
...
03583e5a52
| Author | SHA1 | Date | |
|---|---|---|---|
|
03583e5a52
|
|||
|
b9f64f30e1
|
|||
|
4bd5c4bf2a
|
|||
|
f875fc1877
|
|||
|
2268a607d5
|
43
app/components/modal.gjs
Normal file
43
app/components/modal.gjs
Normal file
@@ -0,0 +1,43 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { on } from '@ember/modifier';
|
||||
import Icon from './icon';
|
||||
|
||||
export default class Modal extends Component {
|
||||
@action
|
||||
stopProp(e) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
@action
|
||||
close() {
|
||||
if (this.args.onClose) {
|
||||
this.args.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="modal-overlay"
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
{{on "click" this.close}}
|
||||
>
|
||||
<div
|
||||
class="modal-content"
|
||||
role="document"
|
||||
tabindex="0"
|
||||
{{on "click" this.stopProp}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="close-modal-btn btn-text"
|
||||
{{on "click" this.close}}
|
||||
>
|
||||
<Icon @name="x" @size={{24}} />
|
||||
</button>
|
||||
{{yield}}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
168
app/components/place-photo-upload.gjs
Normal file
168
app/components/place-photo-upload.gjs
Normal 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>
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
78
app/services/nostr-auth.js
Normal file
78
app/services/nostr-auth.js
Normal 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);
|
||||
}
|
||||
}
|
||||
25
app/services/nostr-relay.js
Normal file
25
app/services/nostr-relay.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import Service from '@ember/service';
|
||||
import { RelayPool } from 'applesauce-relay';
|
||||
|
||||
export default class NostrRelayService extends Service {
|
||||
pool = new RelayPool();
|
||||
|
||||
// For Phase 1, we hardcode the local relay
|
||||
relays = ['ws://127.0.0.1:7777'];
|
||||
|
||||
async publish(event) {
|
||||
// The publish method is a wrapper around the event method that returns a Promise<PublishResponse[]>
|
||||
// and automatically handles reconnecting and retrying.
|
||||
const responses = await this.pool.publish(this.relays, event);
|
||||
|
||||
// Check if at least one relay accepted the event
|
||||
const success = responses.some((res) => res.ok);
|
||||
if (!success) {
|
||||
throw new Error(
|
||||
`Failed to publish event. Responses: ${JSON.stringify(responses)}`
|
||||
);
|
||||
}
|
||||
|
||||
return responses;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
* 1–3 aspects only
|
||||
- 1–3 aspects only
|
||||
|
||||
That’s 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.
|
||||
|
||||
@@ -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”
|
||||
|
||||
@@ -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
802
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user